diff --git a/assets/images/call_user_avatar.svg b/assets/images/call_user_avatar.svg
new file mode 100644
index 00000000..f4560bac
--- /dev/null
+++ b/assets/images/call_user_avatar.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/images/calling_icon.svg b/assets/images/calling_icon.svg
new file mode 100644
index 00000000..d6eacdeb
--- /dev/null
+++ b/assets/images/calling_icon.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/images/end_call.svg b/assets/images/end_call.svg
new file mode 100644
index 00000000..7831d55f
--- /dev/null
+++ b/assets/images/end_call.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/images/mic_disable.svg b/assets/images/mic_disable.svg
new file mode 100644
index 00000000..304f862b
--- /dev/null
+++ b/assets/images/mic_disable.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/images/mic_enable.svg b/assets/images/mic_enable.svg
new file mode 100644
index 00000000..8d32e34c
--- /dev/null
+++ b/assets/images/mic_enable.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/images/speaker_enable.svg b/assets/images/speaker_enable.svg
new file mode 100644
index 00000000..e038d929
--- /dev/null
+++ b/assets/images/speaker_enable.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/images/toggle_camera.svg b/assets/images/toggle_camera.svg
new file mode 100644
index 00000000..e3cb6c2d
--- /dev/null
+++ b/assets/images/toggle_camera.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/images/video_call_icon.svg b/assets/images/video_call_icon.svg
new file mode 100644
index 00000000..60a51f29
--- /dev/null
+++ b/assets/images/video_call_icon.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/video_disable.svg b/assets/images/video_disable.svg
new file mode 100644
index 00000000..ee07a8aa
--- /dev/null
+++ b/assets/images/video_disable.svg
@@ -0,0 +1,6 @@
+
diff --git a/assets/images/video_enable.svg b/assets/images/video_enable.svg
new file mode 100644
index 00000000..9d5edc6a
--- /dev/null
+++ b/assets/images/video_enable.svg
@@ -0,0 +1,3 @@
+
diff --git a/lib/controllers/api_routes/urls.dart b/lib/controllers/api_routes/urls.dart
index 7cad5fc3..14d81867 100644
--- a/lib/controllers/api_routes/urls.dart
+++ b/lib/controllers/api_routes/urls.dart
@@ -7,8 +7,8 @@ class URLs {
// static const host1 = "https://atomsmdev.hmg.com"; // local DEV url
static const host1 = "https://atomsmuat.hmg.com"; // local UAT url
- static final String _baseUrl = "$_host/mobile"; // host local UAT
- // static final String _baseUrl = "$_host/v2/mobile"; // new V2 apis
+ // static final String _baseUrl = "$_host/mobile"; // host local UAT
+ static final String _baseUrl = "$_host/v2/mobile"; // new V2 apis
// static final String _baseUrl = "$_host/v3/mobile"; // v3 for production CM,PM,TM
// static final String _baseUrl = "$_host/v4/mobile"; // v4 for Demo module
// static final String _baseUrl = "$_host/v5/mobile"; // v5 for data segregation
diff --git a/lib/extensions/widget_extensions.dart b/lib/extensions/widget_extensions.dart
index 2dc5cf00..6d21e5bd 100644
--- a/lib/extensions/widget_extensions.dart
+++ b/lib/extensions/widget_extensions.dart
@@ -114,6 +114,7 @@ extension WidgetExtensions on Widget {
BuildContext context, {
bool showShadow = true,
double borderRadius = 14,
+ double width = double.infinity,
bool withShadow = true,
Color? backgroundColor,
Color? borderColor,
@@ -125,7 +126,7 @@ extension WidgetExtensions on Widget {
? Container(
padding: paddingObject ?? EdgeInsets.all(padding),
margin: margin,
- width: double.infinity,
+ width: width,
decoration: ShapeDecoration(
color: backgroundColor ?? AppColor.background(context),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(borderRadius), side: BorderSide(color: borderColor ?? AppColor.borderColor(context))),
diff --git a/lib/modules/cx_module/chat/call/audio_call_page.dart b/lib/modules/cx_module/chat/call/audio_call_page.dart
new file mode 100644
index 00000000..5db3c0b0
--- /dev/null
+++ b/lib/modules/cx_module/chat/call/audio_call_page.dart
@@ -0,0 +1,262 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:test_sa/extensions/int_extensions.dart';
+import 'package:test_sa/extensions/text_extensions.dart';
+import 'package:test_sa/extensions/widget_extensions.dart';
+import 'package:test_sa/modules/cx_module/chat/chat_provider.dart';
+import 'package:test_sa/modules/cx_module/chat/model/call_session.dart';
+import 'package:test_sa/modules/cx_module/chat/call/video_call_page.dart';
+import 'package:test_sa/new_views/app_style/app_color.dart';
+import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
+
+class AudioCallPage extends StatefulWidget {
+ const AudioCallPage({Key? key}) : super(key: key);
+
+ @override
+ State createState() => _AudioCallPageState();
+}
+
+class _AudioCallPageState extends State {
+ @override
+ void initState() {
+ super.initState();
+ // Initialize call on page load
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ // Will be implemented when connecting to provider
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: AppColor.backgroundTabBarDark,
+ appBar: const DefaultAppBar(
+ backgroundColor: Colors.transparent,
+ arrowBackColor: AppColor.white10,
+ ),
+ body: SafeArea(
+ child: Selector(
+ selector: (_, provider) => provider.currentCall,
+ builder: (context, session, _) {
+ if (session == null) {
+ return const Center(child: Text('No active call'));
+ }
+ return Column(
+ children: [
+ const Spacer(flex: 2),
+ _buildContactInfo(context, session),
+ 8.height,
+ _buildCallStatusOrDuration(context),
+ const Spacer(flex: 4),
+ _buildControls(context, session),
+ 32.height,
+ ],
+ );
+ },
+ ),
+ ),
+ );
+ }
+ Widget _buildContactInfo(BuildContext context, CallSession session) {
+ return Column(
+ children: [
+ Container(
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: AppColor.whiteF8d,
+ //TODO need to check opacity
+ border: Border.all(color: AppColor.white10.withOpacity(0.2), width: 1),
+ ),
+ child: ClipOval(
+ child: session.peerAvatar != null
+ ? CachedNetworkImage(
+ imageUrl: session.peerAvatar!,
+ fit: BoxFit.cover,
+ placeholder: (context, url) => Center(
+ child: CircularProgressIndicator(
+ color: Colors.white.withOpacity(0.5),
+ ),
+ ),
+ errorWidget: (context, url, error) => 'call_user_avatar'.toSvgAsset(height: 48, width: 48),
+ )
+ : 'call_user_avatar'.toSvgAsset(height: 48, width: 48),
+ ),
+ ),
+ 24.height,
+ Text(
+ session.peerName,
+ style: AppTextStyles.heading2.copyWith(
+ color: Colors.white,
+ fontWeight: FontWeight.w600,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ );
+ }
+
+ Widget _buildCallStatusOrDuration(BuildContext context) {
+ return Selector(
+ selector: (_, provider) => provider.callStatus,
+ builder: (context, status, _) {
+ String statusText;
+ switch (status) {
+ case CallStatus.outgoingRinging:
+ statusText = 'Calling...';
+ break;
+ case CallStatus.incomingRinging:
+ statusText = 'Incoming call...';
+ break;
+ case CallStatus.connecting:
+ statusText = 'Connecting...';
+ break;
+ case CallStatus.connected:
+ return _buildDuration(context);
+ default:
+ statusText = '';
+ }
+ return Text(
+ statusText,
+ style: AppTextStyles.heading5.copyWith(
+ color: AppColor.neutral100,
+ ),
+ );
+ },
+ );
+ }
+
+ Widget _buildDuration(BuildContext context) {
+ return Selector(
+ selector: (_, provider) => provider.callDuration,
+ builder: (context, duration, _) {
+ if (duration.inSeconds == 0) {
+ return const SizedBox.shrink();
+ }
+
+ String twoDigits(int n) => n.toString().padLeft(2, '0');
+ final minutes = twoDigits(duration.inMinutes.remainder(60));
+ final seconds = twoDigits(duration.inSeconds.remainder(60));
+
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ decoration: BoxDecoration(
+ color: Colors.white.withAlpha(51),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Text(
+ '$minutes:$seconds',
+ style: AppTextStyles.heading5.copyWith(
+ color: AppColor.neutral100,
+ fontWeight: FontWeight.w400,
+ ),
+ ),
+ );
+ },
+ );
+ }
+
+ Widget _buildControls(BuildContext context, CallSession session) {
+ final chatProvider = context.read();
+ return Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: [
+ Selector(
+ selector: (_, provider) => provider.isMuted,
+ builder: (context, isMuted, _) {
+ return _buildControlButton(
+ icon: isMuted ? 'mic_disable' : 'mic_enable',
+ label: isMuted ? 'Unmute' : 'Mute',
+ isActive: isMuted,
+ onPressed: () {
+ chatProvider.toggleMute();
+ },
+ );
+ },
+ ),
+ _buildControlButton(
+ icon: 'video_call_icon',
+ label: 'Video',
+ isActive: false,
+ onPressed: () {
+ // Switch to video call view
+ Navigator.pushReplacement(
+ context,
+ MaterialPageRoute(
+ builder: (context) => const VideoCallPage(),
+ ),
+ );
+ },
+ ),
+ Selector(
+ selector: (_, provider) => provider.isSpeakerOn,
+ builder: (context, isSpeakerOn, _) {
+ return _buildControlButton(
+ icon: 'speaker_enable',
+ label: 'Speaker',
+ isActive: isSpeakerOn,
+ onPressed: () {
+ chatProvider.toggleSpeaker();
+ },
+ );
+ },
+ ),
+ ],
+ ),
+ 48.height,
+ _buildEndCallButton(context, chatProvider),
+ ],
+ );
+ }
+
+ Widget _buildControlButton({
+ required String icon,
+ required String label,
+ required bool isActive,
+ required VoidCallback onPressed,
+ }) {
+ return Column(
+ children: [
+ Container(
+ padding: const EdgeInsets.all(18),
+ decoration: BoxDecoration(color: isActive ? AppColor.primary10 : AppColor.black2E, shape: BoxShape.circle, border: BoxBorder.all(color: AppColor.white10.withOpacity(0.2))),
+ child: icon.toSvgAsset(width: 24, height: 24, color: AppColor.white10),
+ ).onPress(() {
+ onPressed();
+ }),
+ 8.height,
+ Text(
+ label,
+ style: AppTextStyles.heading5.copyWith(color: AppColor.neutral100, fontWeight: FontWeight.w400),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildEndCallButton(BuildContext context, ChatProvider provider) {
+ return Container(
+ padding: const EdgeInsets.all(18),
+ decoration: BoxDecoration(
+ color: AppColor.red30,
+ shape: BoxShape.circle,
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.3),
+ blurRadius: 8,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ alignment: Alignment.center,
+ child: 'end_call'.toSvgAsset(height: 32, width: 32, color: AppColor.white10),
+ ).onPress(() async {
+ await provider.hangUp();
+ if (mounted) {
+ Navigator.pop(context);
+ }
+ });
+ }
+}
diff --git a/lib/modules/cx_module/chat/call/services/callkit_service.dart b/lib/modules/cx_module/chat/call/services/callkit_service.dart
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/modules/cx_module/chat/call/video_call_page.dart b/lib/modules/cx_module/chat/call/video_call_page.dart
new file mode 100644
index 00000000..e1a02b55
--- /dev/null
+++ b/lib/modules/cx_module/chat/call/video_call_page.dart
@@ -0,0 +1,411 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+// import 'package:flutter_webrtc/flutter_webrtc.dart';
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+import 'package:test_sa/extensions/context_extension.dart';
+import 'package:test_sa/extensions/int_extensions.dart';
+import 'package:test_sa/extensions/text_extensions.dart';
+import 'package:test_sa/extensions/widget_extensions.dart';
+import 'package:test_sa/modules/cx_module/chat/chat_provider.dart';
+import 'package:test_sa/modules/cx_module/chat/model/call_session.dart';
+import 'package:test_sa/new_views/app_style/app_color.dart';
+import 'package:test_sa/new_views/app_style/app_themes.dart';
+import 'package:test_sa/modules/cx_module/chat/call/widgets/draggable_local_preview.dart';
+
+class VideoCallPage extends StatefulWidget {
+ const VideoCallPage({Key? key}) : super(key: key);
+
+ @override
+ State createState() => _VideoCallPageState();
+}
+
+class _VideoCallPageState extends State {
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ // Initialize video call
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: AppColor.backgroundTabBarDark,
+ body: Selector(
+ selector: (_, provider) => provider.currentCall,
+ builder: (context, session, _) {
+ if (session == null) {
+ return const Center(
+ child: Text(
+ 'No active call',
+ style: TextStyle(color: Colors.white),
+ ),
+ );
+ }
+
+ return Stack(
+ children: [
+ _buildRemoteVideo(context),
+ _buildLocalVideo(context),
+ _buildTopBar(context, session),
+ _buildBottomControls(context, session),
+ ],
+ );
+ },
+ ),
+ );
+ }
+
+ Widget _buildRemoteVideo(BuildContext context) {
+ final chatProvider = context.read();
+
+ return Positioned.fill(
+ child: Selector(
+ selector: (_, provider) => provider.isPeerCameraOn,
+ builder: (context, isPeerCameraOn, _) {
+ if (!isPeerCameraOn) {
+ // Show avatar when peer camera is off
+ return _buildRemoteAvatarPlaceholder(context);
+ }
+
+ // TODO: Replace with actual RTCVideoView when WebRTC is wired
+ return Container(
+ color: Colors.black,
+ child: const Center(
+ child: Text(
+ 'Remote Video',
+ style: TextStyle(color: Colors.white54),
+ ),
+ ),
+ );
+
+ /* Will be replaced with:
+ return RTCVideoView(
+ chatProvider.webrtcService.remoteRenderer,
+ objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
+ mirror: false,
+ );
+ */
+ },
+ ),
+ );
+ }
+
+ Widget _buildRemoteAvatarPlaceholder(BuildContext context) {
+ return Selector(
+ selector: (_, provider) => provider.currentCall,
+ builder: (context, session, _) {
+ if (session == null) return const SizedBox.shrink();
+
+ return Container(
+ color: AppColor.backgroundTabBarDark,
+ child: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Container(
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: AppColor.whiteF8d,
+ border: Border.all(color: AppColor.white10.withAlpha(51), width: 1),
+ ),
+ child: ClipOval(
+ child: session.peerAvatar != null
+ ? CachedNetworkImage(
+ imageUrl: session.peerAvatar!,
+ fit: BoxFit.cover,
+ placeholder: (context, url) => Center(
+ child: CircularProgressIndicator(
+ color: Colors.white.withAlpha(128),
+ ),
+ ),
+ errorWidget: (context, url, error) => 'call_user_avatar'.toSvgAsset(height: 48, width: 48),
+ )
+ : 'call_user_avatar'.toSvgAsset(height: 48, width: 48),
+ ),
+ ),
+ 24.height,
+ Text(
+ session.peerName,
+ style: AppTextStyles.heading2.copyWith(
+ color: Colors.white,
+ fontWeight: FontWeight.w600,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ 8.height,
+ Text(
+ 'Camera is off',
+ style: AppTextStyles.heading5.copyWith(
+ color: AppColor.neutral100,
+ fontWeight: FontWeight.w400,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ }
+
+ Widget _buildLocalVideo(BuildContext context) {
+ return Selector(
+ selector: (_, provider) => provider.isCameraOn,
+ builder: (context, isCameraOn, _) {
+ if (!isCameraOn) {
+ // Show avatar when local camera is off
+ return DraggableLocalPreview(
+ child: Container(
+ width: 120,
+ height: 160,
+ decoration: BoxDecoration(
+ color: Colors.grey[800],
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Colors.white, width: 2),
+ ),
+ child: const Icon(
+ Icons.videocam_off,
+ color: Colors.white,
+ size: 32,
+ ),
+ ),
+ );
+ }
+
+ // TODO: Replace with actual RTCVideoView when WebRTC is wired
+ return DraggableLocalPreview(
+ child: Container(
+ width: 120,
+ height: 160,
+ decoration: BoxDecoration(
+ color: Colors.black,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Colors.white, width: 2),
+ ),
+ child: const Center(
+ child: Text(
+ 'You',
+ style: TextStyle(color: Colors.white54),
+ ),
+ ),
+ ),
+ );
+
+ /* Will be replaced with:
+ final chatProvider = context.read();
+ return DraggableLocalPreview(
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(12),
+ child: RTCVideoView(
+ chatProvider.webrtcService.localRenderer,
+ objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
+ mirror: true,
+ ),
+ ),
+ );
+ */
+ },
+ );
+ }
+
+ Widget _buildTopBar(BuildContext context, CallSession session) {
+ return Align(
+ alignment: AlignmentGeometry.topLeft,
+ child: Padding(
+ padding: EdgeInsets.only(
+ top: MediaQuery.of(context).padding.top,
+ ),
+ child: IconButton(
+ icon: const Icon(
+ Icons.arrow_back_ios,
+ color: AppColor.white10,
+ ),
+ onPressed: () => Navigator.pop(context),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildBottomControls(BuildContext context, CallSession session) {
+ final chatProvider = context.read();
+ return Positioned(
+ bottom: 24,
+ left: 0,
+ right: 0,
+ //for now use these colors, later we can change to use the theme colors
+ child: Container(
+ decoration: BoxDecoration(
+ gradient: const LinearGradient(
+ begin: Alignment.centerLeft,
+ end: Alignment.centerRight,
+ colors: [
+ Color.fromRGBO(65, 67, 82, 0.7),
+ Color.fromRGBO(105, 109, 133, 0.7),
+ Color.fromRGBO(65, 67, 82, 0.7),
+ ],
+ stops: [0.0, 0.5, 1.0],
+ ),
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(color: AppColor.white10.withAlpha(51)),
+ ),
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ session.peerName,
+ style: AppTextStyles.heading2.copyWith(
+ color: Colors.white,
+ fontWeight: FontWeight.w600,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ 16.height,
+ _buildCallStatusOrDuration(context),
+ 16.height,
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: [
+ Selector(
+ selector: (_, provider) => provider.isMuted,
+ builder: (context, isMuted, _) {
+ return _buildVideoControlButton(
+ icon: isMuted ? 'mic_disable' : 'mic_enable',
+ isActive: isMuted,
+ onPressed: () => chatProvider.toggleMute(),
+ );
+ },
+ ),
+
+ // Camera toggle
+ Selector(
+ selector: (_, provider) => provider.isCameraOn,
+ builder: (context, isCameraOn, _) {
+ return _buildVideoControlButton(
+ icon: isCameraOn ? 'video_enable' : 'video_disable',
+ isActive: !isCameraOn,
+ onPressed: () => chatProvider.toggleCamera(),
+ );
+ },
+ ),
+
+ // Switch camera
+ _buildVideoControlButton(
+ icon: 'toggle_camera',
+ isActive: false,
+ onPressed: () => chatProvider.switchCamera(),
+ ),
+
+ // Speaker
+ Selector(
+ selector: (_, provider) => provider.isSpeakerOn,
+ builder: (context, isSpeakerOn, _) {
+ return _buildVideoControlButton(
+ icon: 'speaker_enable',
+ isActive: isSpeakerOn,
+ onPressed: () => chatProvider.toggleSpeaker(),
+ );
+ },
+ ),
+ _buildVideoControlButton(
+ icon: 'end_call',
+ isHangUp: true,
+ showBorder: false,
+ onPressed: () {
+ chatProvider.hangUp();
+ if (mounted) {
+ Navigator.pop(context);
+ }
+ },
+ ),
+ ],
+ ),
+ ],
+ ),
+ ).paddingOnly(bottom: 16, start: 16, end: 16),
+ );
+ }
+
+ Widget _buildCallStatusOrDuration(BuildContext context) {
+ return Selector(
+ selector: (_, provider) => provider.callStatus,
+ builder: (context, status, _) {
+ String statusText;
+ switch (status) {
+ case CallStatus.outgoingRinging:
+ statusText = 'Calling...';
+ break;
+ case CallStatus.incomingRinging:
+ statusText = 'Incoming call...';
+ break;
+ case CallStatus.connecting:
+ statusText = 'Connecting...';
+ break;
+ case CallStatus.connected:
+ return _buildDuration(context);
+ default:
+ statusText = '';
+ }
+ return Text(
+ statusText,
+ style: AppTextStyles.heading5.copyWith(
+ color: AppColor.neutral100,
+ fontWeight: FontWeight.w400,
+ ),
+ );
+ },
+ );
+ }
+
+ Widget _buildDuration(BuildContext context) {
+ return Selector(
+ selector: (_, provider) => provider.callDuration,
+ builder: (context, duration, _) {
+ if (duration.inSeconds == 0) {
+ return const SizedBox.shrink();
+ }
+
+ String twoDigits(int n) => n.toString().padLeft(2, '0');
+ final minutes = twoDigits(duration.inMinutes.remainder(60));
+ final seconds = twoDigits(duration.inSeconds.remainder(60));
+
+ return Text(
+ '$minutes:$seconds',
+ style: AppTextStyles.heading5.copyWith(
+ color: AppColor.neutral100,
+ fontWeight: FontWeight.w400,
+ fontFeatures: [const FontFeature.tabularFigures()],
+ ),
+ );
+ },
+ );
+ }
+
+ Widget _buildVideoControlButton({
+ required String icon,
+ bool? isActive,
+ bool isHangUp = false,
+ bool showBorder = true,
+ required VoidCallback onPressed,
+ }) {
+ return Container(
+ padding: const EdgeInsets.all(18),
+ decoration: BoxDecoration(
+ color: isHangUp
+ ? AppColor.red30
+ : isActive != null && isActive
+ ? AppColor.primary10
+ : AppColor.black2E,
+ shape: BoxShape.circle,
+ border: showBorder ? BoxBorder.all(color: AppColor.white10.withOpacity(0.2)) : null),
+ child: icon.toSvgAsset(width: 24, height: 24, color: AppColor.white10),
+ ).onPress(() {
+ onPressed();
+ });
+ }
+}
diff --git a/lib/modules/cx_module/chat/call/widgets/draggable_local_preview.dart b/lib/modules/cx_module/chat/call/widgets/draggable_local_preview.dart
new file mode 100644
index 00000000..bb17cb8a
--- /dev/null
+++ b/lib/modules/cx_module/chat/call/widgets/draggable_local_preview.dart
@@ -0,0 +1,82 @@
+import 'package:flutter/material.dart';
+
+/// Draggable widget for local video preview in video calls
+/// Allows user to move the PiP (Picture-in-Picture) preview around the screen
+class DraggableLocalPreview extends StatefulWidget {
+ final Widget child;
+
+ const DraggableLocalPreview({
+ Key? key,
+ required this.child,
+ }) : super(key: key);
+
+ @override
+ State createState() => _DraggableLocalPreviewState();
+}
+
+class _DraggableLocalPreviewState extends State {
+ Offset position = const Offset(20, 100); // Initial position (top-right area)
+
+ @override
+ Widget build(BuildContext context) {
+ final screenSize = MediaQuery.of(context).size;
+
+ return Positioned(
+ left: position.dx,
+ top: position.dy,
+ child: GestureDetector(
+ onPanUpdate: (details) {
+ setState(() {
+ // Update position while dragging
+ position = Offset(
+ (position.dx + details.delta.dx).clamp(
+ 0.0,
+ screenSize.width - 120, // Subtract preview width
+ ),
+ (position.dy + details.delta.dy).clamp(
+ 0.0,
+ screenSize.height - 160, // Subtract preview height
+ ),
+ );
+ });
+ },
+ onPanEnd: (details) {
+ // Snap to nearest edge for better UX
+ setState(() {
+ final middleX = screenSize.width / 2;
+ final middleY = screenSize.height / 2;
+
+ // Snap to left or right edge
+ if (position.dx < middleX) {
+ position = Offset(20, position.dy);
+ } else {
+ position = Offset(screenSize.width - 140, position.dy);
+ }
+
+ // Keep within vertical bounds
+ position = Offset(
+ position.dx,
+ position.dy.clamp(80.0, screenSize.height - 200),
+ );
+ });
+ },
+ child: Container(
+ width: 120,
+ height: 160,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.3),
+ blurRadius: 8,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ child: widget.child,
+ ),
+ ),
+ );
+ }
+}
+
diff --git a/lib/modules/cx_module/chat/chat_page.dart b/lib/modules/cx_module/chat/chat_page.dart
index ea9893b6..cf0eba68 100644
--- a/lib/modules/cx_module/chat/chat_page.dart
+++ b/lib/modules/cx_module/chat/chat_page.dart
@@ -16,8 +16,11 @@ import 'package:test_sa/helper/utils.dart';
import 'package:test_sa/models/service_request/service_request.dart';
import 'package:test_sa/modules/cm_module/cm_detail_provider.dart';
import 'package:test_sa/modules/cm_module/views/components/action_button/footer_action_button.dart';
+import 'package:test_sa/modules/cx_module/chat/call/audio_call_page.dart';
+import 'package:test_sa/modules/cx_module/chat/call/video_call_page.dart';
import 'package:test_sa/modules/cx_module/chat/chat_api_client.dart';
import 'package:test_sa/modules/cx_module/chat/chat_provider.dart';
+import 'package:test_sa/modules/cx_module/chat/model/call_session.dart';
import 'package:test_sa/modules/cx_module/chat/view_all_attachment_page.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import 'package:test_sa/new_views/common_widgets/app_filled_button.dart';
@@ -194,9 +197,57 @@ class _ChatPageState extends State {
@override
Widget build(BuildContext context) {
+ final chatProvider = Provider.of(context, listen: false);
+
return Scaffold(
// backgroundColor: AppColor.white10,
- appBar: DefaultAppBar(title: widget.title),
+ appBar: DefaultAppBar(
+ title: widget.title,
+ actions: [
+ Selector(
+ selector: (_, provider) => provider.recipient,
+ builder: (context, recipient, _) => IconButton(
+ icon: "calling_icon".toSvgAsset(width: 24, height: 24),
+ onPressed: () {
+ log('recipient: ${recipient?.userName}');
+ if (recipient != null) {
+ chatProvider.startCall(recipient, CallType.audio);
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => const AudioCallPage(),
+ ),
+ );
+ }
+ },
+ padding: EdgeInsets.zero,
+ constraints: const BoxConstraints(),
+ ),
+ ),
+ Selector(
+ selector: (_, provider) => provider.recipient,
+ builder: (context, recipient, _) => IconButton(
+ icon: "video_call_icon".toSvgAsset(
+ width: 24,
+ height: 24,
+ ),
+ onPressed: () {
+ if (recipient != null) {
+ chatProvider.startCall(recipient, CallType.video);
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => const VideoCallPage(),
+ ),
+ );
+ }
+ },
+ padding: EdgeInsets.zero,
+ constraints: const BoxConstraints(),
+ ),
+ ),
+ ],
+ ),
// OPTIMIZATION: Using Selector for connection state (rarely changes)
// This prevents rebuilding entire chat UI on every message/typing event
body: Selector(
@@ -272,11 +323,7 @@ class _ChatPageState extends State {
4.width,
Text(
"View All Documents",
- style: AppTextStyles.bodyText.copyWith(
- color: AppColor.white10,
- decoration: TextDecoration.underline,
- decorationColor: AppColor.white10
- ),
+ style: AppTextStyles.bodyText.copyWith(color: AppColor.white10, decoration: TextDecoration.underline, decorationColor: AppColor.white10),
).onPress(() {
Navigator.push(context, CupertinoPageRoute(builder: (context) => ViewAllAttachmentPage(moduleId: widget.moduleId, requestId: widget.requestId)));
}),
@@ -396,7 +443,7 @@ class _ChatPageState extends State {
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap, // or .padded
),
- icon: "chat_attachment".toSvgAsset(width: 24, height: 24,color: AppColor.icon2Color(context)),
+ icon: "chat_attachment".toSvgAsset(width: 24, height: 24, color: AppColor.icon2Color(context)),
constraints: const BoxConstraints(),
),
IconButton(
@@ -417,7 +464,7 @@ class _ChatPageState extends State {
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap, // or .padded
),
- icon: "chat_mic".toSvgAsset(width: 24, height: 24,color: AppColor.icon2Color(context)),
+ icon: "chat_mic".toSvgAsset(width: 24, height: 24, color: AppColor.icon2Color(context)),
constraints: const BoxConstraints(),
),
] else if (chatState == ChatState.voiceRecordingStarted) ...[
diff --git a/lib/modules/cx_module/chat/chat_provider.dart b/lib/modules/cx_module/chat/chat_provider.dart
index dc1cbbc7..5d15835a 100644
--- a/lib/modules/cx_module/chat/chat_provider.dart
+++ b/lib/modules/cx_module/chat/chat_provider.dart
@@ -56,7 +56,7 @@ import 'model/get_search_user_chat_model.dart';
import 'model/get_single_user_chat_list_model.dart';
import 'model/unread_message_model.dart';
import 'model/user_chat_history_model.dart';
-// import 'get_single_user_chat_list_model.dart';
+import 'model/call_session.dart'; // NEW - Import call session model
//Need to refactor this remove unused code.
@@ -88,6 +88,22 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
late int moduleID;
int? referenceID;
+ // === NEW: CALL STATE VARIABLES ===
+ CallStatus callStatus = CallStatus.idle;
+ CallSession? currentCall;
+ Duration callDuration = Duration.zero;
+ Timer? _callDurationTimer;
+ Timer? _callTimeoutTimer;
+
+ // Call control state
+ bool isMuted = false;
+ bool isSpeakerOn = false;
+ bool isCameraOn = true;
+ bool isPeerMuted = false;
+ bool isPeerCameraOn = true;
+
+ bool get isCallInProgress => callStatus != CallStatus.idle;
+
/// OPTIMIZATION: Improved connection disposal to prevent memory leaks
/// This properly handles errors and ensures connection is always cleaned up
Future _disposeConnection() async {
@@ -112,7 +128,7 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
Future reset() async {
// OPTIMIZATION: Use async/await for proper cleanup
await _disposeConnection();
-
+
chatLoginTokenLoading = false;
chatParticipantLoading = false;
userChatHistoryLoading = false;
@@ -123,7 +139,7 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
recipient = null;
ChatApiClient().chatLoginResponse = null;
}
-
+
/// OPTIMIZATION: Override dispose to ensure SignalR connection cleanup
/// This prevents connection leaks when provider is removed from widget tree
@override
@@ -163,14 +179,43 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
notifyListeners();
}
try {
+ log('i am called ...');
chatLoginResponse = await ChatApiClient().getChatLoginToken(moduleId, requestId, title, myId, assigneeEmployeeNumber);
+ log('โ
Got chatLoginResponse');
+
chatParticipantModel = await ChatApiClient().loadParticipants(moduleId, requestId, assigneeEmployeeNumber);
- sender = chatParticipantModel?.participants?.firstWhere((participant) => participant.employeeNumber == myId);
- recipient = chatParticipantModel?.participants?.firstWhere((participant) => participant.employeeNumber == assigneeEmployeeNumber);
+ log('โ
Got chatParticipantModel: ${chatParticipantModel?.toJson()}');
+
+ // Log available participants for debugging
+ log('๐ Available participants: ${chatParticipantModel?.participants?.map((p) => '${p.employeeNumber} (${p.userName})').toList()}');
+ log('๐ Looking for myId: $myId');
+ log('๐ Looking for assigneeEmployeeNumber: $assigneeEmployeeNumber');
+
+ // Use case-insensitive matching and handle not found gracefully
+ try {
+ sender = chatParticipantModel?.participants?.firstWhere(
+ (participant) => participant.employeeNumber?.toLowerCase() == myId.toLowerCase()
+ );
+ log('โ
sender i got is ${sender?.toJson()}');
+ } catch (e) {
+ log('โ ๏ธ Sender NOT FOUND for myId: $myId. Error: $e');
+ sender = null;
+ }
+
+ try {
+ recipient = chatParticipantModel?.participants?.firstWhere(
+ (participant) => participant.employeeNumber?.toLowerCase() == assigneeEmployeeNumber.toLowerCase()
+ );
+ log('โ
recipient i got is ${recipient?.toJson()}');
+ } catch (e) {
+ log('โ ๏ธ Recipient NOT FOUND for assigneeEmployeeNumber: $assigneeEmployeeNumber. Error: $e');
+ recipient = null;
+ }
} catch (ex) {
if (kDebugMode) {
print('โ ๏ธ Error in getUserAutoLoginTokenSilent: $ex');
}
+ log('โ EXCEPTION in getUserAutoLoginTokenSilent: $ex');
}
chatLoginTokenLoading = false;
if (isMounted) {
@@ -206,7 +251,7 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
}
moduleID = moduleId;
referenceID = requestId;
-
+
// OPTIMIZATION: Wrap connection in try-catch to prevent leaks on error
if (!readOnly) {
try {
@@ -218,7 +263,7 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
// Continue loading chat history even if hub connection fails
}
}
-
+
userChatHistory = null;
userChatHistory = await ChatApiClient().loadChatHistory(moduleId, requestId, myId, assigneeEmployeeNumber);
chatResponseList = userChatHistory ?? [];
@@ -297,7 +342,7 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
try {
// Dispose existing connection if any
await _disposeConnection();
-
+
chatHubConnection = await getHubConnection();
await chatHubConnection!.start();
if (kDebugMode) {
@@ -305,17 +350,20 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
}
await chatHubConnection!.invoke("JoinConversation", args: [conversationID]);
+
+ // Register chat event handlers
chatHubConnection!.on("ReceiveMessage", onMsgReceived1);
chatHubConnection!.on("OnMessageReceivedAsync", onMsgReceived);
- chatHubConnection!.on("OnSubmitChatAsync", OnSubmitChatAsync);
+ chatHubConnection!.on("OnSubmitChatAsync", onSubmitChatAsync);
chatHubConnection!.on("OnTypingAsync", OnTypingAsync);
chatHubConnection!.on("OnStopTypingAsync", OnStopTypingAsync);
- //Need by Chat Backend for seen and un seen.
chatHubConnection!.on("OnSeenChatUserAsync", onSeenUserChatAsync);
chatHubConnection!.on("OnAckSeenAsync", onAckSeenAsync);
- //group On message
+ // Register call event handlers (PHASE 1)
+ _registerCallHandlers();
+ //group On message
// chatHubConnection.on("OnDeliveredGroupChatHistoryAsync", onGroupMsgReceived);
} catch (e) {
if (kDebugMode) {
@@ -331,17 +379,17 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
if (kDebugMode) {
print('๐ง Creating new SignalR hub connection...');
}
-
+
HubConnection hub;
HttpConnectionOptions httpOp = HttpConnectionOptions(skipNegotiation: false, logMessageContent: true);
hub = HubConnectionBuilder()
.withUrl("${URLs.chatHubUrlChat}?UserId=${chatLoginResponse!.userId}&source=Desktop&access_token=${chatLoginResponse!.token}", options: httpOp)
.withAutomaticReconnect(retryDelays: [2000, 5000, 10000, 20000]).build();
-
+
if (kDebugMode) {
print('โ
SignalR hub connection created');
}
-
+
return hub;
}
@@ -731,1603 +779,541 @@ class ChatProvider with ChangeNotifier, DiagnosticableTreeMixin {
}
}
- // Future OnSubmitChatAsync(List