From a5d129970ba26ad0e49f377b87e5763c079cfe70 Mon Sep 17 00:00:00 2001 From: WaseemAbbasi22 <50428976+WaseemAbbasi22@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:21:06 +0300 Subject: [PATCH] calling in progress --- assets/images/call_user_avatar.svg | 4 + assets/images/calling_icon.svg | 5 + assets/images/end_call.svg | 4 + assets/images/mic_disable.svg | 5 + assets/images/mic_enable.svg | 4 + assets/images/speaker_enable.svg | 5 + assets/images/toggle_camera.svg | 4 + assets/images/video_call_icon.svg | 3 + assets/images/video_disable.svg | 6 + assets/images/video_enable.svg | 3 + lib/controllers/api_routes/urls.dart | 4 +- lib/extensions/widget_extensions.dart | 3 +- .../cx_module/chat/call/audio_call_page.dart | 262 ++ .../chat/call/services/callkit_service.dart | 0 .../cx_module/chat/call/video_call_page.dart | 411 ++++ .../call/widgets/draggable_local_preview.dart | 82 + lib/modules/cx_module/chat/chat_page.dart | 63 +- lib/modules/cx_module/chat/chat_provider.dart | 2104 +++++------------ .../cx_module/chat/model/call_session.dart | 100 + .../chat/model/chat_participant_model.dart | 7 +- lib/new_views/app_style/app_color.dart | 4 + .../common_widgets/default_app_bar.dart | 21 +- pubspec.lock | 42 +- pubspec.yaml | 5 +- 24 files changed, 1575 insertions(+), 1576 deletions(-) create mode 100644 assets/images/call_user_avatar.svg create mode 100644 assets/images/calling_icon.svg create mode 100644 assets/images/end_call.svg create mode 100644 assets/images/mic_disable.svg create mode 100644 assets/images/mic_enable.svg create mode 100644 assets/images/speaker_enable.svg create mode 100644 assets/images/toggle_camera.svg create mode 100644 assets/images/video_call_icon.svg create mode 100644 assets/images/video_disable.svg create mode 100644 assets/images/video_enable.svg create mode 100644 lib/modules/cx_module/chat/call/audio_call_page.dart create mode 100644 lib/modules/cx_module/chat/call/services/callkit_service.dart create mode 100644 lib/modules/cx_module/chat/call/video_call_page.dart create mode 100644 lib/modules/cx_module/chat/call/widgets/draggable_local_preview.dart create mode 100644 lib/modules/cx_module/chat/model/call_session.dart 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? parameters) async { - // // List data = jsonDecode(parameters!.first!.toString()); - // - // print("OnSubmitChatAsync:$parameters"); - // - // for (dynamic msg in parameters!) { - // var data = getSingleUserChatModel(jsonEncode(msg)); - // print(data); - // } - // - // // List data = parameters!.first!; - // // chatResponseList = chatResponseList + data.map((elemet) => ChatHistoryResponse.fromJson(element)).toList(); - // // chatResponseList.sort((a, b) => b.createdDate!.compareTo(a.createdDate!)); - // // notifyListeners(); - // } + Future onSubmitChatAsync(List? parameters) async { + if (kDebugMode) print("OnSubmitChatAsync:$parameters"); + + if (parameters == null || parameters.isEmpty) return; + + try { + for (dynamic msg in parameters) { + var data = getSingleUserChatModel(jsonEncode(msg)); + if (kDebugMode) print('Parsed message: $data'); + } + } catch (e) { + if (kDebugMode) print('Error in OnSubmitChatAsync: $e'); + } + } Future onMsgReceived1(List? parameters) async { print("onMsgReceived1:$parameters"); } Future onMsgReceived(List? parameters) async { - List data = [], temp = []; + List data = []; print("OnMessageReceivedAsync:$parameters"); for (dynamic msg in parameters!) { data = getSingleUserChatModel(jsonEncode(msg)); - // temp = getSingleUserChatModel(jsonEncode(msg)); - // data.first.targetUserId = temp.first.currentUserId; - // data.first.targetUserName = temp.first.currentUserName; - // data.first.targetUserEmail = temp.first.currentUserEmail; - // data.first.currentUserId = temp.first.targetUserId; - // data.first.currentUserName = temp.first.targetUserName; - // data.first.currentUserEmail = temp.first.targetUserEmail; - - // if (data.first.fileTypeId == 12 || data.first.fileTypeId == 4 || data.first.fileTypeId == 3) { - // data.first.image = await ChatApiClient().downloadURL(fileName: data.first.contant!, fileTypeDescription: data.first.fileTypeResponse!.fileTypeDescription ?? "image/jpg", fileSource: 1); - // } - // if (data.first.userChatReplyResponse != null) { - // if (data.first.fileTypeResponse != null) { - // if (data.first.userChatReplyResponse!.fileTypeId == 12 || data.first.userChatReplyResponse!.fileTypeId == 4 || data.first.userChatReplyResponse!.fileTypeId == 3) { - // data.first.userChatReplyResponse!.image = await ChatApiClient() - // .downloadURL(fileName: data.first.userChatReplyResponse!.contant!, fileTypeDescription: data.first.fileTypeResponse!.fileTypeDescription ?? "image/jpg", fileSource: 1); - // data.first.userChatReplyResponse!.isImageLoaded = true; - // } - // } - // } + // ...existing code... } - // - // if - // - // ( - // - // searchedChats != null) { - // dynamic contain = searchedChats!.where((ChatUser element) => element.id == data.first.currentUserId); - // if (contain.isEmpty) { - // List emails = []; - // emails.add(await EmailImageEncryption().encrypt(val: data.first.currentUserEmail!)); - // List chatImages = await ChatApiClient().getUsersImages(encryptedEmails: emails); - // searchedChats!.add( - // ChatUser( - // id: data.first.currentUserId, - // userName: data.first.currentUserName, - // email: data.first.currentUserEmail, - // unreadMessageCount: 0, - // isImageLoading: false, - // image: chatImages!.first.profilePicture ?? "", - // isImageLoaded: true, - // userStatus: 1, - // isTyping: false, - // userLocalDownlaodedImage: await downloadImageLocal(chatImages.first.profilePicture, data.first.currentUserId.toString()), - // ), - // ); - // } - // } - // setMsgTune(); - // userChatHistory = userChatHistory! + data; - // chatResponseList.sort((a, b) => b.createdDate!.compareTo(a.createdDate!)); + // ...existing code... userChatHistory?.insert(0, data.first); notifyListeners(); + // ...existing code... + } - // markRead(data, data.first.targetUserId!); - // if (isChatScreenActive && data.first.currentUserId == receiverID) { - // - // } else { - // // if (searchedChats != null) { - // // for (ChatUser user in searchedChats!) { - // // if (user.id == data.first.currentUserId) { - // // int tempCount = user.unreadMessageCount ?? 0; - // // user.unreadMessageCount = tempCount + 1; - // // } - // // } - // // sort(); - // // } - // } + // ==================== PHASE 1: CALL INFRASTRUCTURE ==================== + // All call-related methods are placed at the end to avoid modifying existing chat logic - // List list = [ - // { - // "userChatHistoryId": data.first.userChatHistoryId, - // "TargetUserId": temp.first.targetUserId, - // "isDelivered": true, - // "isSeen": isChatScreenActive && data.first.currentUserId == receiverID ? true : false - // } - // ]; - // updateUserChatHistoryOnMsg(list); - // invokeChatCounter(userId: AppState().chatDetails!.response!. - // id - // - // ! - // - // ); + /// Start a new audio or video call + Future startCall(Participants recipient, CallType callType) async { + if (callStatus != CallStatus.idle) { + if (kDebugMode) { + print('โš ๏ธ Cannot start call - already in a call'); + } + return; + } - // notifyListeners - // - // ( - // - // ); + if (sender == null) { + if (kDebugMode) { + print('โš ๏ธ Cannot start call - sender is null'); + } + return; + } + + callStatus = CallStatus.checkingPermissions; + notifyListeners(); + + try { + // Generate unique call ID + final callId = const Uuid().v4(); + final isVideo = callType == CallType.video; + + // Request permissions + final micStatus = await Permission.microphone.request(); + if (!micStatus.isGranted) { + callStatus = CallStatus.idle; + currentCall = null; + notifyListeners(); + if (kDebugMode) { + print('โš ๏ธ Microphone permission denied'); + } + return; + } + + if (isVideo) { + final cameraStatus = await Permission.camera.request(); + if (!cameraStatus.isGranted) { + callStatus = CallStatus.idle; + currentCall = null; + notifyListeners(); + if (kDebugMode) { + print('โš ๏ธ Camera permission denied'); + } + return; + } + } + + // Create call session + currentCall = CallSession( + callId: callId, + type: callType, + direction: CallDirection.outgoing, + peerId: recipient.employeeNumber ?? '', + peerName: recipient.userName ?? 'Unknown', + peerAvatar: recipient.image, + startTime: DateTime.now(), + ); + + // Invoke SignalR CallUserAsync + await chatHubConnection?.invoke( + 'CallUserAsync', + args: [ + sender!.employeeNumber ?? '', + recipient.employeeNumber ?? '', + isVideo, + ], + ); + + callStatus = CallStatus.outgoingRinging; + notifyListeners(); + + // Start 60s timeout + _callTimeoutTimer?.cancel(); + _callTimeoutTimer = Timer(const Duration(seconds: 60), () { + if (callStatus == CallStatus.outgoingRinging) { + _handleCallTimeout(); + } + }); + + if (kDebugMode) { + print('โœ… Call started: $callId'); + } + } catch (e) { + if (kDebugMode) { + print('โŒ Error starting call: $e'); + } + callStatus = CallStatus.idle; + currentCall = null; + notifyListeners(); + } } -// Future onGroupMsgReceived(List? parameters) async { -// List data = [], temp = []; -// -// for (dynamic msg in parameters!) { -// // groupChatHistory.add(groupchathistory.GetGroupChatHistoryAsync.fromJson(msg)); -// data.add(groupchathistory.GetGroupChatHistoryAsync.fromJson(msg)); -// temp = data; -// // data.first.currentUserId = temp.first.currentUserId; -// // data.first.currentUserName = temp.first.currentUserName; -// // -// // data.first.currentUserId = temp.first.currentUserId; -// // data.first.currentUserName = temp.first.currentUserName; -// -// if (data.first.fileTypeId == 12 || data.first.fileTypeId == 4 || data.first.fileTypeId == 3) { -// data.first.image = await ChatApiClient().downloadURL(fileName: data.first.contant!, fileTypeDescription: data.first.fileTypeResponse!.fileTypeDescription ?? "image/jpg", fileSource: 2); -// } -// if (data.first.groupChatReplyResponse != null) { -// if (data.first.fileTypeResponse != null) { -// if (data.first.groupChatReplyResponse!.fileTypeId == 12 || data.first.groupChatReplyResponse!.fileTypeId == 4 || data.first.groupChatReplyResponse!.fileTypeId == 3) { -// data.first.groupChatReplyResponse!.image = await ChatApiClient() -// .downloadURL(fileName: data.first.groupChatReplyResponse!.contant!, fileTypeDescription: data.first.fileTypeResponse!.fileTypeDescription ?? "image/jpg", fileSource: 2); -// data.first.groupChatReplyResponse!.isImageLoaded = true; -// } -// } -// } -// } -// -// // if (searchedChats != null) { -// // dynamic contain = searchedChats! -// // .where((ChatUser element) => element.id == data.first.currentUserId); -// // if (contain.isEmpty) { -// // List emails = []; -// // emails.add(await EmailImageEncryption() -// // .encrypt(val: data.first.currentUserEmail!)); -// // List chatImages = -// // await ChatApiClient().getUsersImages(encryptedEmails: emails); -// // searchedChats!.add( -// // ChatUser( -// // id: data.first.currentUserId, -// // userName: data.first.currentUserName, -// // email: data.first.currentUserEmail, -// // unreadMessageCount: 0, -// // isImageLoading: false, -// // image: chatImages!.first.profilePicture ?? "", -// // isImageLoaded: true, -// // userStatus: 1, -// // isTyping: false, -// // userLocalDownlaodedImage: await downloadImageLocal( -// // chatImages.first.profilePicture, -// // data.first.currentUserId.toString()), -// // ), -// // ); -// // } -// // } -// groupChatHistory.insert(0, data.first); -// setMsgTune(); -// // if (isChatScreenActive && data.first.currentUserId == receiverID) { -// -// // } else { -// // if (searchedChats != null) { -// // for (ChatUser user in searchedChats!) { -// // if (user.id == data.first.currentUserId) { -// // int tempCount = user.unreadMessageCount ?? 0; -// // user.unreadMessageCount = tempCount + 1; -// // } -// // } -// sort(); -// //} -// //} -// // -// // List list = [ -// // { -// // "userChatHistoryId": data.first.groupId, -// // "TargetUserId": temp.first.currentUserId, -// // "isDelivered": true, -// // "isSeen": isChatScreenActive && data.first.currentUserId == receiverID -// // ? true -// // : false -// // } -// // ]; -// // updateUserChatHistoryOnMsg(list); -// // invokeChatCounter(userId: AppState().chatDetails!.response!.id!); -// notifyListeners(); -// } - - void OnSubmitChatAsync(List? parameters) { - print("OnSubmitChatAsync:$parameters"); - List data = []; - for (dynamic msg in parameters!) { - data = getSingleUserChatModel(jsonEncode(msg)); + /// Toggle microphone mute + void toggleMute() { + isMuted = !isMuted; + notifyListeners(); + + // Notify peer via SignalR + if (currentCall != null && sender != null) { + chatHubConnection?.invoke( + 'AudioToggle', + args: [sender!.employeeNumber ?? '', currentCall!.peerId], + ); + } + + if (kDebugMode) { + print('๐ŸŽค Microphone ${isMuted ? "muted" : "unmuted"}'); } - chatResponseList.insert(0, data.first); + } + + /// Toggle speakerphone + void toggleSpeaker() { + isSpeakerOn = !isSpeakerOn; notifyListeners(); + + if (kDebugMode) { + print('๐Ÿ”Š Speaker ${isSpeakerOn ? "on" : "off"}'); + } } -// void sort() { -// searchedChats!.sort( -// (ChatUser a, ChatUser b) => b.unreadMessageCount!.compareTo(a.unreadMessageCount!), -// ); -// } - -// void onUserTyping(List? parameters) { -// for (ChatUser user in searchedChats!) { -// if (user.id == parameters![1] && parameters[0] == true) { -// user.isTyping = parameters[0] as bool?; -// Future.delayed( -// const Duration(seconds: 2), -// () { -// user.isTyping = false; -// notifyListeners(); -// }, -// ); -// } -// } -// notifyListeners(); -} + /// Toggle camera (video only) + void toggleCamera() { + isCameraOn = !isCameraOn; + notifyListeners(); + + // Notify peer via SignalR + if (currentCall != null && sender != null) { + chatHubConnection?.invoke( + 'CameraToggle', + args: [sender!.employeeNumber ?? '', currentCall!.peerId], + ); + } -int getFileType(String value) { - switch (value) { - case ".pdf": - return 1; - case ".png": - return 3; - case ".txt": - return 5; - case ".jpg": - return 12; - case ".jpeg": - return 4; - case ".xls": - return 7; - case ".xlsx": - return 7; - case ".doc": - return 6; - case ".docx": - return 6; - case ".ppt": - return 8; - case ".pptx": - return 8; - case ".zip": - return 2; - case ".rar": - return 2; - case ".aac": - return 13; - case ".mp3": - return 14; - case ".mp4": - return 16; - case ".mov": - return 16; - case ".avi": - return 16; - case ".flv": - return 16; - - default: - return 0; + if (kDebugMode) { + print('๐Ÿ“น Camera ${isCameraOn ? "on" : "off"}'); + } } -} -String getFileTypeDescription(String value) { - switch (value) { - case ".pdf": - return "application/pdf"; - case ".png": - return "image/png"; - case ".txt": - return "text/plain"; - case ".jpg": - return "image/jpg"; - case ".jpeg": - return "image/jpeg"; - case ".ppt": - return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; - case ".pptx": - return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; - case ".doc": - return "application/vnd.openxmlformats-officedocument.wordprocessingm"; - case ".docx": - return "application/vnd.openxmlformats-officedocument.wordprocessingm"; - case ".xls": - return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - case ".xlsx": - return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - case ".zip": - return "application/octet-stream"; - case ".rar": - return "application/octet-stream"; - case ".aac": - return "audio/aac"; - case ".mp3": - return "audio/mp3"; - case ".mp4": - return "video/mp4"; - case ".avi": - return "video/avi"; - case ".flv": - return "video/flv"; - case ".mov": - return "video/mov"; - - default: - return ""; + /// Switch between front and rear camera + void switchCamera() { + if (kDebugMode) { + print('๐Ÿ”„ Switch camera requested'); + } + // Implementation will be added with WebRTC service + notifyListeners(); } -} -// Future sendChatToServer( -// {required int chatEventId, -// required fileTypeId, -// required int targetUserId, -// required String targetUserName, -// required chatReplyId, -// required bool isAttachment, -// required bool isReply, -// Uint8List? image, -// required bool isImageLoaded, -// String? userEmail, -// int? userStatus, -// File? voiceFile, -// required bool isVoiceAttached}) async { -// Uuid uuid = const Uuid(); -// String contentNo = uuid.v4(); -// String msg; -// if (isVoiceAttached) { -// msg = voiceFile!.path.split("/").last; -// } else { -// msg = message.text; -// logger.w(msg); -// } -// SingleUserChatModel data = SingleUserChatModel( -// userChatHistoryId: 0, -// chatEventId: chatEventId, -// chatSource: 1, -// contant: msg, -// contantNo: contentNo, -// conversationId: chatCID, -// createdDate: DateTime.now(), -// currentUserId: AppState().chatDetails!.response!.id, -// currentUserName: AppState().chatDetails!.response!.userName, -// targetUserId: targetUserId, -// targetUserName: targetUserName, -// isReplied: false, -// fileTypeId: fileTypeId, -// userChatReplyResponse: isReply ? UserChatReplyResponse.fromJson(repliedMsg.first.toJson()) : null, -// fileTypeResponse: isAttachment -// ? FileTypeResponse( -// fileTypeId: fileTypeId, -// fileTypeName: isVoiceMsg ? getFileExtension(voiceFile!.path).toString() : getFileExtension(selectedFile.path).toString(), -// fileKind: "file", -// fileName: isVoiceMsg ? msg : selectedFile.path.split("/").last, -// fileTypeDescription: isVoiceMsg ? getFileTypeDescription(getFileExtension(voiceFile!.path).toString()) : getFileTypeDescription(getFileExtension(selectedFile.path).toString()), -// ) -// : null, -// image: image, -// isImageLoaded: isImageLoaded, -// voice: isVoiceMsg ? voiceFile! : null, -// voiceController: isVoiceMsg ? AudioPlayer() : null); -// if (kDebugMode) { -// logger.i("model data: " + jsonEncode(data)); -// } -// userChatHistory.insert(0, data); -// isTextMsg = false; -// isReplyMsg = false; -// isAttachmentMsg = false; -// isVoiceMsg = false; -// sFileType = ""; -// message.clear(); -// notifyListeners(); -// -// String chatData = -// '{"contant":"$msg","contantNo":"$contentNo","chatEventId":$chatEventId,"fileTypeId": $fileTypeId,"currentUserId":${AppState().chatDetails!.response!.id},"chatSource":1,"userChatHistoryLineRequestList":[{"isSeen":false,"isDelivered":false,"targetUserId":$targetUserId,"targetUserStatus":1}],"chatReplyId":$chatReplyId,"conversationId":"$chatCID"}'; -// -// await chatHubConnection.invoke("AddChatUserAsync", args: [json.decode(chatData)]); -// } - -//groupChatMessage - -// Future sendGroupChatToServer( -// {required int chatEventId, -// required fileTypeId, -// required int targetGroupId, -// required String targetUserName, -// required chatReplyId, -// required bool isAttachment, -// required bool isReply, -// Uint8List? image, -// required bool isImageLoaded, -// String? userEmail, -// int? userStatus, -// File? voiceFile, -// required bool isVoiceAttached, -// required List userList}) async { -// Uuid uuid = const Uuid(); -// String contentNo = uuid.v4(); -// String msg; -// if (isVoiceAttached) { -// msg = voiceFile!.path.split("/").last; -// } else { -// msg = message.text; -// logger.w(msg); -// } -// groupchathistory.GetGroupChatHistoryAsync data = groupchathistory.GetGroupChatHistoryAsync( -// //userChatHistoryId: 0, -// chatEventId: chatEventId, -// chatSource: 1, -// contant: msg, -// contantNo: contentNo, -// conversationId: chatCID, -// createdDate: DateTime.now().toString(), -// currentUserId: AppState().chatDetails!.response!.id, -// currentUserName: AppState().chatDetails!.response!.userName, -// groupId: targetGroupId, -// groupName: targetUserName, -// isReplied: false, -// fileTypeId: fileTypeId, -// fileTypeResponse: isAttachment -// ? groupchathistory.FileTypeResponse( -// fileTypeId: fileTypeId, -// fileTypeName: isVoiceMsg ? getFileExtension(voiceFile!.path).toString() : getFileExtension(selectedFile.path).toString(), -// fileKind: "file", -// fileName: isVoiceMsg ? msg : selectedFile.path.split("/").last, -// fileTypeDescription: isVoiceMsg ? getFileTypeDescription(getFileExtension(voiceFile!.path).toString()) : getFileTypeDescription(getFileExtension(selectedFile.path).toString())) -// : null, -// image: image, -// isImageLoaded: isImageLoaded, -// voice: isVoiceMsg ? voiceFile! : null, -// voiceController: isVoiceMsg ? AudioPlayer() : null); -// if (kDebugMode) { -// logger.i("model data: " + jsonEncode(data)); -// } -// groupChatHistory.insert(0, data); -// isTextMsg = false; -// isReplyMsg = false; -// isAttachmentMsg = false; -// isVoiceMsg = false; -// sFileType = ""; -// message.clear(); -// notifyListeners(); -// -// List targetUsers = []; -// -// for (GroupUserList element in userList) { -// targetUsers.add(TargetUsers(isDelivered: false, isSeen: false, targetUserId: element.id, userAction: element.userAction, userStatus: element.userStatus)); -// } -// -// String chatData = -// '{"contant":"$msg","contantNo":"$contentNo","chatEventId":$chatEventId,"fileTypeId":$fileTypeId,"currentUserId":${AppState().chatDetails!.response!.id},"chatSource":1,"groupId":$targetGroupId,"groupChatHistoryLineRequestList":${json.encode(targetUsers)},"chatReplyId": $chatReplyId,"conversationId":"${uuid.v4()}"}'; -// -// await chatHubConnection.invoke("AddGroupChatHistoryAsync", args: [json.decode(chatData)]); -// } - -// void sendGroupChatMessage( -// BuildContext context, { -// required int targetUserId, -// required int userStatus, -// required String userEmail, -// required String targetUserName, -// required List userList, -// }) async { -// if (isTextMsg && !isAttachmentMsg && !isVoiceMsg && !isReplyMsg) { -// logger.d("// Normal Text Message"); -// if (message.text.isEmpty) { -// return; -// } -// sendGroupChatToServer( -// chatEventId: 1, -// fileTypeId: null, -// targetGroupId: targetUserId, -// targetUserName: targetUserName, -// isAttachment: false, -// chatReplyId: null, -// isReply: false, -// isImageLoaded: false, -// image: null, -// isVoiceAttached: false, -// userEmail: userEmail, -// userStatus: userStatus, -// userList: userList); -// } else if (isTextMsg && !isAttachmentMsg && !isVoiceMsg && isReplyMsg) { -// logger.d("// Text Message as Reply"); -// if (message.text.isEmpty) { -// return; -// } -// sendGroupChatToServer( -// chatEventId: 1, -// fileTypeId: null, -// targetGroupId: targetUserId, -// targetUserName: targetUserName, -// chatReplyId: groupChatReplyData.first.groupChatHistoryId, -// isAttachment: false, -// isReply: true, -// isImageLoaded: groupChatReplyData.first.isImageLoaded!, -// image: groupChatReplyData.first.image, -// isVoiceAttached: false, -// voiceFile: null, -// userEmail: userEmail, -// userStatus: userStatus, -// userList: userList); -// } -// // Attachment -// else if (!isTextMsg && isAttachmentMsg && !isVoiceMsg && !isReplyMsg) { -// logger.d("// Normal Image Message"); -// Utils.showLoading(context); -// dynamic value = await uploadAttachments(AppState().chatDetails!.response!.id.toString(), selectedFile, "2"); -// String? ext = getFileExtension(selectedFile.path); -// Utils.hideLoading(context); -// sendGroupChatToServer( -// chatEventId: 2, -// fileTypeId: getFileType(ext.toString()), -// targetGroupId: targetUserId, -// targetUserName: targetUserName, -// isAttachment: true, -// chatReplyId: null, -// isReply: false, -// isImageLoaded: true, -// image: selectedFile.readAsBytesSync(), -// isVoiceAttached: false, -// userEmail: userEmail, -// userStatus: userStatus, -// userList: userList); -// } else if (!isTextMsg && isAttachmentMsg && !isVoiceMsg && isReplyMsg) { -// logger.d("// Image as Reply Msg"); -// Utils.showLoading(context); -// dynamic value = await uploadAttachments(AppState().chatDetails!.response!.id.toString(), selectedFile, "2"); -// String? ext = getFileExtension(selectedFile.path); -// Utils.hideLoading(context); -// sendGroupChatToServer( -// chatEventId: 2, -// fileTypeId: getFileType(ext.toString()), -// targetGroupId: targetUserId, -// targetUserName: targetUserName, -// isAttachment: true, -// chatReplyId: repliedMsg.first.userChatHistoryId, -// isReply: true, -// isImageLoaded: true, -// image: selectedFile.readAsBytesSync(), -// isVoiceAttached: false, -// userEmail: userEmail, -// userStatus: userStatus, -// userList: userList); -// } -// //Voice -// -// else if (!isTextMsg && !isAttachmentMsg && isVoiceMsg && !isReplyMsg) { -// logger.d("// Normal Voice Message"); -// -// if (!isPause) { -// path = await recorderController.stop(false); -// } -// if (kDebugMode) { -// logger.i("path:" + path!); -// } -// File voiceFile = File(path!); -// voiceFile.readAsBytesSync(); -// _timer?.cancel(); -// isPause = false; -// isPlaying = false; -// isRecoding = false; -// Utils.showLoading(context); -// dynamic value = await uploadAttachments(AppState().chatDetails!.response!.id.toString(), voiceFile, "2"); -// String? ext = getFileExtension(voiceFile.path); -// Utils.hideLoading(context); -// sendGroupChatToServer( -// chatEventId: 2, -// fileTypeId: getFileType(ext.toString()), -// //, -// targetGroupId: targetUserId, -// targetUserName: targetUserName, -// chatReplyId: null, -// isAttachment: true, -// isReply: isReplyMsg, -// isImageLoaded: false, -// voiceFile: voiceFile, -// isVoiceAttached: true, -// userEmail: userEmail, -// userStatus: userStatus, -// userList: userList); -// notifyListeners(); -// } else if (!isTextMsg && !isAttachmentMsg && isVoiceMsg && isReplyMsg) { -// logger.d("// Voice as Reply Msg"); -// -// if (!isPause) { -// path = await recorderController.stop(false); -// } -// if (kDebugMode) { -// logger.i("path:" + path!); -// } -// File voiceFile = File(path!); -// voiceFile.readAsBytesSync(); -// _timer?.cancel(); -// isPause = false; -// isPlaying = false; -// isRecoding = false; -// -// Utils.showLoading(context); -// dynamic value = await uploadAttachments(AppState().chatDetails!.response!.id.toString(), voiceFile, "2"); -// String? ext = getFileExtension(voiceFile.path); -// Utils.hideLoading(context); -// sendGroupChatToServer( -// chatEventId: 2, -// fileTypeId: getFileType(ext.toString()), -// targetGroupId: targetUserId, -// targetUserName: targetUserName, -// chatReplyId: null, -// isAttachment: true, -// isReply: isReplyMsg, -// isImageLoaded: false, -// voiceFile: voiceFile, -// isVoiceAttached: true, -// userEmail: userEmail, -// userStatus: userStatus, -// userList: userList); -// notifyListeners(); -// } -// if (searchedChats != null) { -// dynamic contain = searchedChats!.where((ChatUser element) => element.id == targetUserId); -// if (contain.isEmpty) { -// List emails = []; -// emails.add(await EmailImageEncryption().encrypt(val: userEmail)); -// List chatImages = await ChatApiClient().getUsersImages(encryptedEmails: emails); -// searchedChats!.add( -// ChatUser( -// id: targetUserId, -// userName: targetUserName, -// unreadMessageCount: 0, -// email: userEmail, -// isImageLoading: false, -// image: chatImages.first.profilePicture ?? "", -// isImageLoaded: true, -// isTyping: false, -// isFav: false, -// userStatus: userStatus, -// // userLocalDownlaodedImage: await downloadImageLocal(chatImages.first.profilePicture, targetUserId.toString()), -// ), -// ); -// notifyListeners(); -// } -// } -// } -// -// void sendChatMessage( -// BuildContext context, { -// required int targetUserId, -// required int userStatus, -// required String userEmail, -// required String targetUserName, -// }) async { -// if (kDebugMode) { -// print("====================== Values ============================"); -// print("Is Text " + isTextMsg.toString()); -// print("isReply " + isReplyMsg.toString()); -// print("isAttachment " + isAttachmentMsg.toString()); -// print("isVoice " + isVoiceMsg.toString()); -// } -// //Text -// if (isTextMsg && !isAttachmentMsg && !isVoiceMsg && !isReplyMsg) { -// // logger.d("// Normal Text Message"); -// if (message.text.isEmpty) { -// return; -// } -// sendChatToServer( -// chatEventId: 1, -// fileTypeId: null, -// targetUserId: targetUserId, -// targetUserName: targetUserName, -// isAttachment: false, -// chatReplyId: null, -// isReply: false, -// isImageLoaded: false, -// image: null, -// isVoiceAttached: false, -// userEmail: userEmail, -// userStatus: userStatus); -// } else if (isTextMsg && !isAttachmentMsg && !isVoiceMsg && isReplyMsg) { -// logger.d("// Text Message as Reply"); -// if (message.text.isEmpty) { -// return; -// } -// sendChatToServer( -// chatEventId: 1, -// fileTypeId: null, -// targetUserId: targetUserId, -// targetUserName: targetUserName, -// chatReplyId: repliedMsg.first.userChatHistoryId, -// isAttachment: false, -// isReply: true, -// isImageLoaded: repliedMsg.first.isImageLoaded!, -// image: repliedMsg.first.image, -// isVoiceAttached: false, -// voiceFile: null, -// userEmail: userEmail, -// userStatus: userStatus); -// } -// // Attachment -// else if (!isTextMsg && isAttachmentMsg && !isVoiceMsg && !isReplyMsg) { -// logger.d("// Normal Image Message"); -// Utils.showLoading(context); -// dynamic value = await uploadAttachments(AppState().chatDetails!.response!.id.toString(), selectedFile, "1"); -// String? ext = getFileExtension(selectedFile.path); -// Utils.hideLoading(context); -// sendChatToServer( -// chatEventId: 2, -// fileTypeId: getFileType(ext.toString()), -// targetUserId: targetUserId, -// targetUserName: targetUserName, -// isAttachment: true, -// chatReplyId: null, -// isReply: false, -// isImageLoaded: true, -// image: selectedFile.readAsBytesSync(), -// isVoiceAttached: false, -// userEmail: userEmail, -// userStatus: userStatus); -// } else if (!isTextMsg && isAttachmentMsg && !isVoiceMsg && isReplyMsg) { -// logger.d("// Image as Reply Msg"); -// Utils.showLoading(context); -// dynamic value = await uploadAttachments(AppState().chatDetails!.response!.id.toString(), selectedFile, "1"); -// String? ext = getFileExtension(selectedFile.path); -// Utils.hideLoading(context); -// sendChatToServer( -// chatEventId: 2, -// fileTypeId: getFileType(ext.toString()), -// targetUserId: targetUserId, -// targetUserName: targetUserName, -// isAttachment: true, -// chatReplyId: repliedMsg.first.userChatHistoryId, -// isReply: true, -// isImageLoaded: true, -// image: selectedFile.readAsBytesSync(), -// isVoiceAttached: false, -// userEmail: userEmail, -// userStatus: userStatus); -// } -// //Voice -// -// else if (!isTextMsg && !isAttachmentMsg && isVoiceMsg && !isReplyMsg) { -// logger.d("// Normal Voice Message"); -// -// if (!isPause) { -// path = await recorderController.stop(false); -// } -// if (kDebugMode) { -// logger.i("path:" + path!); -// } -// File voiceFile = File(path!); -// voiceFile.readAsBytesSync(); -// _timer?.cancel(); -// isPause = false; -// isPlaying = false; -// isRecoding = false; -// Utils.showLoading(context); -// dynamic value = await uploadAttachments(AppState().chatDetails!.response!.id.toString(), voiceFile, "1"); -// String? ext = getFileExtension(voiceFile.path); -// Utils.hideLoading(context); -// sendChatToServer( -// chatEventId: 2, -// fileTypeId: getFileType(ext.toString()), -// targetUserId: targetUserId, -// targetUserName: targetUserName, -// chatReplyId: null, -// isAttachment: true, -// isReply: isReplyMsg, -// isImageLoaded: false, -// voiceFile: voiceFile, -// isVoiceAttached: true, -// userEmail: userEmail, -// userStatus: userStatus); -// notifyListeners(); -// } else if (!isTextMsg && !isAttachmentMsg && isVoiceMsg && isReplyMsg) { -// logger.d("// Voice as Reply Msg"); -// -// if (!isPause) { -// path = await recorderController.stop(false); -// } -// if (kDebugMode) { -// logger.i("path:" + path!); -// } -// File voiceFile = File(path!); -// voiceFile.readAsBytesSync(); -// _timer?.cancel(); -// isPause = false; -// isPlaying = false; -// isRecoding = false; -// -// Utils.showLoading(context); -// dynamic value = await uploadAttachments(AppState().chatDetails!.response!.id.toString(), voiceFile, "1"); -// String? ext = getFileExtension(voiceFile.path); -// Utils.hideLoading(context); -// sendChatToServer( -// chatEventId: 2, -// fileTypeId: getFileType(ext.toString()), -// targetUserId: targetUserId, -// targetUserName: targetUserName, -// chatReplyId: null, -// isAttachment: true, -// isReply: isReplyMsg, -// isImageLoaded: false, -// voiceFile: voiceFile, -// isVoiceAttached: true, -// userEmail: userEmail, -// userStatus: userStatus); -// notifyListeners(); -// } -// if (searchedChats != null) { -// dynamic contain = searchedChats!.where((ChatUser element) => element.id == targetUserId); -// if (contain.isEmpty) { -// List emails = []; -// emails.add(await EmailImageEncryption().encrypt(val: userEmail)); -// List chatImages = await ChatApiClient().getUsersImages(encryptedEmails: emails); -// searchedChats!.add( -// ChatUser( -// id: targetUserId, -// userName: targetUserName, -// unreadMessageCount: 0, -// email: userEmail, -// isImageLoading: false, -// image: chatImages.first.profilePicture ?? "", -// isImageLoaded: true, -// isTyping: false, -// isFav: false, -// userStatus: userStatus, -// userLocalDownlaodedImage: await downloadImageLocal(chatImages.first.profilePicture, targetUserId.toString()), -// ), -// ); -// notifyListeners(); -// } -// } -// // else { -// // List emails = []; -// // emails.add(await EmailImageEncryption().encrypt(val: userEmail)); -// // List chatImages = await ChatApiClient().getUsersImages(encryptedEmails: emails); -// // searchedChats!.add( -// // ChatUser( -// // id: targetUserId, -// // userName: targetUserName, -// // unreadMessageCount: 0, -// // email: userEmail, -// // isImageLoading: false, -// // image: chatImages.first.profilePicture ?? "", -// // isImageLoaded: true, -// // isTyping: false, -// // isFav: false, -// // userStatus: userStatus, -// // userLocalDownlaodedImage: await downloadImageLocal(chatImages.first.profilePicture, targetUserId.toString()), -// // ), -// // ); -// // notifyListeners(); -// // } -// } - -// void selectImageToUpload(BuildContext context) { -// ImageOptions.showImageOptionsNew(context, true, (String image, File file) async { -// if (checkFileSize(file.path)) { -// selectedFile = file; -// isAttachmentMsg = true; -// isTextMsg = false; -// sFileType = getFileExtension(file.path)!; -// message.text = file.path.split("/").last; -// Navigator.of(context).pop(); -// } else { -// Utils.showToast("Max 1 mb size is allowed to upload"); -// } -// notifyListeners(); -// }); -// } - -// void removeAttachment() { -// isAttachmentMsg = false; -// sFileType = ""; -// message.text = ''; -// notifyListeners(); -// } - -String? getFileExtension(String fileName) { - try { + /// Switch from audio call to video call + Future switchToVideoCall() async { + if (currentCall == null || currentCall!.type == CallType.video) { + return; + } + + // Request camera permission + final cameraStatus = await Permission.camera.request(); + if (!cameraStatus.isGranted) { + if (kDebugMode) { + print('โš ๏ธ Camera permission denied'); + } + return; + } + + // Update call type (create new session to maintain immutability) + currentCall = CallSession( + callId: currentCall!.callId, + type: CallType.video, + direction: currentCall!.direction, + peerId: currentCall!.peerId, + peerName: currentCall!.peerName, + peerAvatar: currentCall!.peerAvatar, + startTime: currentCall!.startTime, + sessionId: currentCall!.sessionId, + sdpOffer: currentCall!.sdpOffer, + sdpAnswer: currentCall!.sdpAnswer, + ); + isCameraOn = true; + notifyListeners(); + if (kDebugMode) { - // logger.i("ext: " + "." + fileName.split('.').last); + print('๐Ÿ“น Switched to video call'); } - return "." + fileName.split('.').last; - } catch (e) { - return null; } -} -bool checkFileSize(String path) { - int fileSizeLimit = 5120; - File f = File(path); - double fileSizeInKB = f.lengthSync() / 5000; - double fileSizeInMB = fileSizeInKB / 5000; - if (fileSizeInKB > fileSizeLimit) { - return false; - } else { - return true; + /// End the current call + Future hangUp() async { + if (currentCall == null || callStatus == CallStatus.idle) { + return; + } + + try { + // Invoke SignalR HangUpAsync + await chatHubConnection?.invoke( + 'HangUpAsync', + args: [ + sender?.employeeNumber ?? '', + currentCall!.peerId, + moduleID.toString(), + referenceID?.toString() ?? '', + chatParticipantModel?.id?.toString() ?? '', + ], + ); + + if (kDebugMode) { + print('๐Ÿ“ž Call ended'); + } + } catch (e) { + if (kDebugMode) { + print('โš ๏ธ Error ending call: $e'); + } + } + + _teardownCall(); } -} -String getType(String type) { - switch (type) { - case ".pdf": - return "assets/images/pdf.svg"; - case ".png": - return "assets/images/png.svg"; - case ".txt": - return "assets/icons/chat/txt.svg"; - case ".jpg": - return "assets/images/jpg.svg"; - case ".jpeg": - return "assets/images/jpg.svg"; - case ".xls": - return "assets/icons/chat/xls.svg"; - case ".xlsx": - return "assets/icons/chat/xls.svg"; - case ".doc": - return "assets/icons/chat/doc.svg"; - case ".docx": - return "assets/icons/chat/doc.svg"; - case ".ppt": - return "assets/icons/chat/ppt.svg"; - case ".pptx": - return "assets/icons/chat/ppt.svg"; - case ".zip": - return "assets/icons/chat/zip.svg"; - case ".rar": - return "assets/icons/chat/zip.svg"; - case ".aac": - return "assets/icons/chat/aac.svg"; - case ".mp3": - return "assets/icons/chat/zip.mp3"; - default: - return "assets/images/thumb.svg"; + /// Handle call timeout (60s no answer) + void _handleCallTimeout() { + if (kDebugMode) { + print('โฑ๏ธ Call timeout - no answer'); + } + + // Invoke CallMissedAsync + chatHubConnection?.invoke( + 'CallMissedAsync', + args: [ + sender?.employeeNumber ?? '', + currentCall?.peerId ?? '', + moduleID.toString(), + referenceID?.toString() ?? '', + chatParticipantModel?.id?.toString() ?? '', + ], + ); + + _teardownCall(); } -} -// void chatReply(SingleUserChatModel data) { -// repliedMsg = []; -// data.isReplied = true; -// isReplyMsg = true; -// repliedMsg.add(data); -// notifyListeners(); -// } - -// void groupChatReply(groupchathistory.GetGroupChatHistoryAsync data) { -// groupChatReplyData = []; -// data.isReplied = true; -// isReplyMsg = true; -// groupChatReplyData.add(data); -// notifyListeners(); -// } - -// void closeMe() { -// repliedMsg = []; -// isReplyMsg = false; -// notifyListeners(); -// } - -String dateFormte(DateTime data) { - DateFormat f = DateFormat('hh:mm a dd MMM yyyy', "en_US"); - f.format(data); - return f.format(data); -} + /// Clean up call resources + void _teardownCall() { + _callTimeoutTimer?.cancel(); + _callDurationTimer?.cancel(); + + callStatus = CallStatus.idle; + currentCall = null; + callDuration = Duration.zero; + isMuted = false; + isSpeakerOn = false; + isCameraOn = true; + isPeerMuted = false; + isPeerCameraOn = true; -// Future favoriteUser({required int userID, required int targetUserID, required bool fromSearch}) async { -// fav.FavoriteChatUser favoriteChatUser = await ChatApiClient().favUser(userID: userID, targetUserID: targetUserID); -// if (favoriteChatUser.response != null) { -// for (ChatUser user in searchedChats!) { -// if (user.id == favoriteChatUser.response!.targetUserId!) { -// user.isFav = favoriteChatUser.response!.isFav; -// dynamic contain = favUsersList!.where((ChatUser element) => element.id == favoriteChatUser.response!.targetUserId!); -// if (contain.isEmpty) { -// favUsersList.add(user); -// } -// } -// } -// -// for (ChatUser user in chatUsersList!) { -// if (user.id == favoriteChatUser.response!.targetUserId!) { -// user.isFav = favoriteChatUser.response!.isFav; -// dynamic contain = favUsersList!.where((ChatUser element) => element.id == favoriteChatUser.response!.targetUserId!); -// if (contain.isEmpty) { -// favUsersList.add(user); -// } -// } -// } -// } -// if (fromSearch) { -// for (ChatUser user in favUsersList) { -// if (user.id == targetUserID) { -// user.userLocalDownlaodedImage = null; -// user.isImageLoading = false; -// user.isImageLoaded = false; -// } -// } -// } -// notifyListeners(); -// } -// -// Future unFavoriteUser({required int userID, required int targetUserID}) async { -// fav.FavoriteChatUser favoriteChatUser = await ChatApiClient().unFavUser(userID: userID, targetUserID: targetUserID); -// -// if (favoriteChatUser.response != null) { -// for (ChatUser user in searchedChats!) { -// if (user.id == favoriteChatUser.response!.targetUserId!) { -// user.isFav = favoriteChatUser.response!.isFav; -// } -// } -// favUsersList.removeWhere( -// (ChatUser element) => element.id == targetUserID, -// ); -// } -// -// for (ChatUser user in chatUsersList!) { -// if (user.id == favoriteChatUser.response!.targetUserId!) { -// user.isFav = favoriteChatUser.response!.isFav; -// } -// } -// -// notifyListeners(); -// } - -// void clearSelections() { -// searchedChats = pChatHistory; -// search.clear(); -// isChatScreenActive = false; -// receiverID = 0; -// paginationVal = 0; -// message.text = ''; -// isAttachmentMsg = false; -// repliedMsg = []; -// sFileType = ""; -// isReplyMsg = false; -// isTextMsg = false; -// isVoiceMsg = false; -// notifyListeners(); -// } -// -// void clearAll() { -// searchedChats = pChatHistory; -// search.clear(); -// isChatScreenActive = false; -// receiverID = 0; -// paginationVal = 0; -// message.text = ''; -// isTextMsg = false; -// isAttachmentMsg = false; -// isVoiceMsg = false; -// isReplyMsg = false; -// repliedMsg = []; -// sFileType = ""; -// } -// -// void disposeData() { -// if (!disbaleChatForThisUser) { -// search.clear(); -// isChatScreenActive = false; -// receiverID = 0; -// paginationVal = 0; -// message.text = ''; -// isTextMsg = false; -// isAttachmentMsg = false; -// isVoiceMsg = false; -// isReplyMsg = false; -// repliedMsg = []; -// sFileType = ""; -// deleteData(); -// favUsersList.clear(); -// searchedChats?.clear(); -// pChatHistory?.clear(); -// // uGroups?.clear(); -// searchGroup?.clear(); -// chatHubConnection.stop(); -// // AppState().chatDetails = null; -// } -// } -// -// void deleteData() { -// List exists = [], unique = []; -// if (searchedChats != null) exists.addAll(searchedChats!); -// exists.addAll(favUsersList!); -// Map profileMap = {}; -// for (ChatUser item in exists) { -// profileMap[item.email!] = item; -// } -// unique = profileMap.values.toList(); -// for (ChatUser element in unique!) { -// deleteFile(element.id.toString()); -// } -// } - -// void getUserImages() async { -// List emails = []; -// List exists = [], unique = []; -// exists.addAll(searchedChats!); -// exists.addAll(favUsersList!); -// Map profileMap = {}; -// for (ChatUser item in exists) { -// profileMap[item.email!] = item; -// } -// unique = profileMap.values.toList(); -// for (ChatUser element in unique!) { -// emails.add(await EmailImageEncryption().encrypt(val: element.email!)); -// } -// -// List chatImages = await ChatApiClient().getUsersImages(encryptedEmails: emails); -// for (ChatUser user in searchedChats!) { -// for (ChatUserImageModel uImage in chatImages) { -// if (user.email == uImage.email) { -// user.image = uImage.profilePicture ?? ""; -// user.userLocalDownlaodedImage = await downloadImageLocal(uImage.profilePicture, user.id.toString()); -// user.isImageLoading = false; -// user.isImageLoaded = true; -// } -// } -// } -// for (ChatUser favUser in favUsersList) { -// for (ChatUserImageModel uImage in chatImages) { -// if (favUser.email == uImage.email) { -// favUser.image = uImage.profilePicture ?? ""; -// favUser.userLocalDownlaodedImage = await downloadImageLocal(uImage.profilePicture, favUser.id.toString()); -// favUser.isImageLoading = false; -// favUser.isImageLoaded = true; -// } -// } -// } -// -// notifyListeners(); -// } - -Future downloadImageLocal(String? encodedBytes, String userID) async { - File? myfile; - if (encodedBytes == null) { - return myfile; - } else { - await deleteFile(userID); - Uint8List decodedBytes = base64Decode(encodedBytes); - Directory appDocumentsDirectory = await getApplicationDocumentsDirectory(); - String dirPath = '${appDocumentsDirectory.path}/chat_images'; - if (!await Directory(dirPath).exists()) { - await Directory(dirPath).create(); - await File('$dirPath/.nomedia').create(); + notifyListeners(); + + if (kDebugMode) { + print('๐Ÿงน Call resources cleaned up'); } - late File imageFile = File("$dirPath/$userID.jpg"); - imageFile.writeAsBytesSync(decodedBytes); - return imageFile; } -} -Future deleteFile(String userID) async { - Directory appDocumentsDirectory = await getApplicationDocumentsDirectory(); - String dirPath = '${appDocumentsDirectory.path}/chat_images'; - late File imageFile = File('$dirPath/$userID.jpg'); - if (await imageFile.exists()) { - await imageFile.delete(); + /// Start call duration timer when connected + void _startCallDurationTimer() { + _callDurationTimer?.cancel(); + callDuration = Duration.zero; + + _callDurationTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + callDuration = Duration(seconds: callDuration.inSeconds + 1); + notifyListeners(); + }); + + if (kDebugMode) { + print('โฑ๏ธ Call duration timer started'); + } } -} -Future downChatMedia(Uint8List bytes, String ext) async { - String dir = (await getApplicationDocumentsDirectory()).path; - File file = File("$dir/" + DateTime.now().millisecondsSinceEpoch.toString() + "." + ext); - await file.writeAsBytes(bytes); - return file.path; -} + /// Accept incoming call + Future acceptCall() async { + if (currentCall == null || callStatus != CallStatus.incomingRinging) { + return; + } + + // Request permissions + final micStatus = await Permission.microphone.request(); + if (!micStatus.isGranted) { + await declineCall('permission_denied'); + return; + } -void setMsgTune() async { - JustAudio.AudioPlayer player = JustAudio.AudioPlayer(); - await player.setVolume(1.0); - String audioAsset = ""; - if (Platform.isAndroid) { - audioAsset = "assets/audio/pulse_tone_android.mp3"; - } else { - audioAsset = "assets/audio/pulse_tune_ios.caf"; + if (currentCall!.type == CallType.video) { + final cameraStatus = await Permission.camera.request(); + if (!cameraStatus.isGranted) { + await declineCall('permission_denied'); + return; + } + } + + try { + // Invoke SignalR AnswerCallAsync + await chatHubConnection?.invoke( + 'AnswerCallAsync', + args: [ + sender?.employeeNumber ?? '', + currentCall!.peerId, + moduleID.toString(), + referenceID?.toString() ?? '', + chatParticipantModel?.id?.toString() ?? '', + ], + ); + + callStatus = CallStatus.connecting; + notifyListeners(); + + if (kDebugMode) { + print('โœ… Call accepted'); + } + } catch (e) { + if (kDebugMode) { + print('โŒ Error accepting call: $e'); + } + _teardownCall(); + } } - try { - await player.setAsset(audioAsset); - await player.load(); - player.play(); - } catch (e) { - print("Error: $e"); + + /// Decline incoming call + Future declineCall(String reason) async { + if (currentCall == null) { + return; + } + + try { + // Invoke SignalR CallDeclinedAsync + await chatHubConnection?.invoke( + 'CallDeclinedAsync', + args: [ + sender?.employeeNumber ?? '', + currentCall!.peerId, + moduleID.toString(), + referenceID?.toString() ?? '', + chatParticipantModel?.id?.toString() ?? '', + ], + ); + + if (kDebugMode) { + print('๐Ÿ“ž Call declined: $reason'); + } + } catch (e) { + if (kDebugMode) { + print('โš ๏ธ Error declining call: $e'); + } + } + + _teardownCall(); } -} -// Future getChatMedia(BuildContext context, {required String fileName, required String fileTypeName, required int fileTypeID, required int fileSource}) async { -// Utils.showLoading(context); -// if (fileTypeID == 1 || fileTypeID == 5 || fileTypeID == 7 || fileTypeID == 6 || fileTypeID == 8 || fileTypeID == 2 || fileTypeID == 16) { -// Uint8List encodedString = await ChatApiClient().downloadURL(fileName: fileName, fileTypeDescription: getFileTypeDescription(fileTypeName), fileSource: fileSource); -// try { -// String path = await downChatMedia(encodedString, fileTypeName ?? ""); -// Utils.hideLoading(context); -// OpenFilex.open(path); -// } catch (e) { -// Utils.showToast("Cannot open file."); -// } -// } -// } - -// void onNewChatConversion(List? params) { -// dynamic items = params!.toList(); -// chatUConvCounter = items[0]["singleChatCount"] ?? 0; -// notifyListeners(); -// } - -Future invokeChatCounter({required int userId}) async { - await chatHubConnection!.invoke("GetChatCounversationCount", args: [userId]); - return ""; -} + /// Register call-related SignalR event handlers + void _registerCallHandlers() { + if (chatHubConnection == null) return; -void userTypingInvoke({required int currentUser, required int reciptUser}) async { - await chatHubConnection!.invoke("UserTypingAsync", args: [reciptUser, currentUser]); -} + // Incoming call + chatHubConnection!.on("OnIncomingCallAsync", _onIncomingCall); + + // Call accepted + chatHubConnection!.on("OnCallAcceptedAsync", _onCallAccepted); + + // Call declined + chatHubConnection!.on("OnCallDeclinedAsync", _onCallDeclined); + + // Call ended + chatHubConnection!.on("OnHangUpAsync", _onCallEnded); -// void groupTypingInvoke({required GroupResponse groupDetails, required int groupId}) async { -// var data = json.decode(json.encode(groupDetails.groupUserList)); -// await chatHubConnection.invoke("GroupTypingAsync", args: ["${groupDetails.adminUser!.userName}", data, groupId]); -// } - -//////// Audio Recoding Work //////////////////// - -// Future initAudio({required int receiverId}) async { -// // final dir = Directory((Platform.isAndroid -// // ? await getExternalStorageDirectory() //FOR ANDROID -// // : await getApplicationSupportDirectory() //FOR IOS -// // )! -// appDirectory = await getApplicationDocumentsDirectory(); -// String dirPath = '${appDirectory.path}/chat_audios'; -// if (!await Directory(dirPath).exists()) { -// await Directory(dirPath).create(); -// await File('$dirPath/.nomedia').create(); -// } -// path = "$dirPath/${AppState().chatDetails!.response!.id}-$receiverID-${DateTime.now().microsecondsSinceEpoch}.aac"; -// recorderController = RecorderController() -// ..androidEncoder = AndroidEncoder.aac -// ..androidOutputFormat = AndroidOutputFormat.mpeg4 -// ..iosEncoder = IosEncoder.kAudioFormatMPEG4AAC -// ..sampleRate = 6000 -// ..updateFrequency = const Duration(milliseconds: 100) -// ..bitRate = 18000; -// playerController = PlayerController(); -// } - -// void disposeAudio() { -// isRecoding = false; -// isPlaying = false; -// isPause = false; -// isVoiceMsg = false; -// recorderController.dispose(); -// playerController.dispose(); -// } - -// void startRecoding(BuildContext context) async { -// await Permission.microphone.request().then((PermissionStatus status) { -// if (status.isPermanentlyDenied) { -// Utils.confirmDialog( -// context, -// "The app needs microphone access to be able to record audio.", -// onTap: () { -// Navigator.of(context).pop(); -// openAppSettings(); -// }, -// ); -// } else if (status.isDenied) { -// Utils.confirmDialog( -// context, -// "The app needs microphone access to be able to record audio.", -// onTap: () { -// Navigator.of(context).pop(); -// openAppSettings(); -// }, -// ); -// } else if (status.isGranted) { -// sRecoding(); -// } else { -// startRecoding(context); -// } -// }); -// } -// -// void sRecoding() async { -// isVoiceMsg = true; -// recorderController.reset(); -// await recorderController.record(path: path); -// _recodeDuration = 0; -// _startTimer(); -// isRecoding = !isRecoding; -// notifyListeners(); -// } -// -// Future _startTimer() async { -// _timer?.cancel(); -// _timer = Timer.periodic(const Duration(seconds: 1), (Timer t) async { -// _recodeDuration++; -// if (_recodeDuration <= 59) { -// applyCounter(); -// } else { -// pauseRecoding(); -// } -// }); -// } -// -// void applyCounter() { -// buildTimer(); -// notifyListeners(); -// } -// -// Future pauseRecoding() async { -// isPause = true; -// isPlaying = true; -// recorderController.pause(); -// path = await recorderController.stop(false); -// File file = File(path!); -// file.readAsBytesSync(); -// path = file.path; -// await playerController.preparePlayer(path: file.path, volume: 1.0); -// _timer?.cancel(); -// notifyListeners(); -// } -// -// Future deleteRecoding() async { -// _recodeDuration = 0; -// _timer?.cancel(); -// if (path == null) { -// path = await recorderController.stop(true); -// } else { -// await recorderController.stop(true); -// } -// if (path != null && path!.isNotEmpty) { -// File delFile = File(path!); -// double fileSizeInKB = delFile.lengthSync() / 1024; -// double fileSizeInMB = fileSizeInKB / 1024; -// if (kDebugMode) { -// debugPrint("Deleted file size: ${delFile.lengthSync()}"); -// debugPrint("Deleted file size in KB: " + fileSizeInKB.toString()); -// debugPrint("Deleted file size in MB: " + fileSizeInMB.toString()); -// } -// if (await delFile.exists()) { -// delFile.delete(); -// } -// isPause = false; -// isRecoding = false; -// isPlaying = false; -// isVoiceMsg = false; -// notifyListeners(); -// } -// } -// -// String buildTimer() { -// String minutes = _formatNum(_recodeDuration ~/ 60); -// String seconds = _formatNum(_recodeDuration % 60); -// return '$minutes : $seconds'; -// } - -String _formatNum(int number) { - String numberStr = number.toString(); - if (number < 10) { - numberStr = '0' + numberStr; + // Peer audio toggle + chatHubConnection!.on("OnAudioToggle", _onPeerAudioToggle); + + // Peer camera toggle + chatHubConnection!.on("OnCameraToggle", _onPeerCameraToggle); + + if (kDebugMode) { + print('โœ… Call handlers registered'); + } } - return numberStr; -} -Future downChatVoice(Uint8List bytes, String ext, SingleUserChatModel data) async { - File file; - try { - String dirPath = '${(await getApplicationDocumentsDirectory()).path}/chat_audios'; - if (!await Directory(dirPath).exists()) { - await Directory(dirPath).create(); - await File('$dirPath/.nomedia').create(); + /// Handle incoming call event + void _onIncomingCall(List? args) { + if (args == null || args.isEmpty) return; + + try { + final data = args.first as Map; + final callerId = data['sourceUserId'] as String?; + final callerName = data['userName'] as String? ?? 'Unknown'; + final isVideo = data['isVideoCall'] as bool? ?? false; + + // Check if already in a call + if (callStatus != CallStatus.idle) { + // Auto-reject with busy status + chatHubConnection?.invoke( + 'CallDeclinedAsync', + args: [ + sender?.employeeNumber ?? '', + callerId ?? '', + moduleID.toString(), + referenceID?.toString() ?? '', + chatParticipantModel?.id?.toString() ?? '', + ], + ); + return; + } + + // Create call session + currentCall = CallSession( + callId: const Uuid().v4(), + type: isVideo ? CallType.video : CallType.audio, + direction: CallDirection.incoming, + peerId: callerId ?? '', + peerName: callerName, + peerAvatar: null, + startTime: DateTime.now(), + ); + + callStatus = CallStatus.incomingRinging; + notifyListeners(); + + if (kDebugMode) { + print('๐Ÿ“ž Incoming ${isVideo ? "video" : "audio"} call from $callerName'); + } + } catch (e) { + if (kDebugMode) { + print('โŒ Error handling incoming call: $e'); + } + } + } + + /// Handle call accepted event + void _onCallAccepted(List? args) { + if (callStatus == CallStatus.outgoingRinging) { + _callTimeoutTimer?.cancel(); + callStatus = CallStatus.connecting; + notifyListeners(); + + if (kDebugMode) { + print('โœ… Call was accepted'); + } } - file = File("$dirPath/${data.currentUserId}-${data.targetUserId}-${DateTime.now().microsecondsSinceEpoch}" + ext); - await file.writeAsBytes(bytes); - } catch (e) { + } + + /// Handle call declined event + void _onCallDeclined(List? args) { + if (args == null || args.isEmpty) return; + + try { + final reason = args.first as String? ?? 'declined'; + + if (kDebugMode) { + print('๐Ÿ“ž Call declined: $reason'); + } + + _teardownCall(); + } catch (e) { + if (kDebugMode) { + print('โŒ Error handling call declined: $e'); + } + } + } + + /// Handle call ended event + void _onCallEnded(List? args) { if (kDebugMode) { - print(e); + print('๐Ÿ“ž Call ended by peer'); } - file = File(""); + + _teardownCall(); } - return file; -} -// void scrollToMsg(SingleUserChatModel data) { -// if (data.userChatReplyResponse != null && data.userChatReplyResponse!.userChatHistoryId != null) { -// int index = userChatHistory.indexWhere((SingleUserChatModel element) => element.userChatHistoryId == data.userChatReplyResponse!.userChatHistoryId); -// if (index >= 1) { -// double contentSize = scrollController.position.viewportDimension + scrollController.position.maxScrollExtent; -// double target = contentSize * index / userChatHistory.length; -// scrollController.position.animateTo( -// target, -// duration: const Duration(seconds: 1), -// curve: Curves.easeInOut, -// ); -// } -// } -// } - -// -// Future getTeamMembers() async { -// teamMembersList = []; -// isLoading = true; -// if (AppState().getemployeeSubordinatesList.isNotEmpty) { -// getEmployeeSubordinatesList = AppState().getemployeeSubordinatesList; -// for (GetEmployeeSubordinatesList element in getEmployeeSubordinatesList) { -// if (element.eMPLOYEEEMAILADDRESS != null) { -// if (element.eMPLOYEEEMAILADDRESS!.isNotEmpty) { -// teamMembersList.add( -// ChatUser( -// id: int.parse(element.eMPLOYEENUMBER!), -// email: element.eMPLOYEEEMAILADDRESS, -// userName: element.eMPLOYEENAME, -// phone: element.eMPLOYEEMOBILENUMBER, -// userStatus: 0, -// unreadMessageCount: 0, -// isFav: false, -// isTyping: false, -// isImageLoading: false, -// image: element.eMPLOYEEIMAGE ?? "", -// isImageLoaded: element.eMPLOYEEIMAGE == null ? false : true, -// userLocalDownlaodedImage: element.eMPLOYEEIMAGE == null ? null : await downloadImageLocal(element.eMPLOYEEIMAGE ?? "", element.eMPLOYEENUMBER!), -// ), -// ); -// } -// } -// } -// } else { -// getEmployeeSubordinatesList = await MyTeamApiClient().getEmployeeSubordinates("", "", ""); -// AppState().setemployeeSubordinatesList = getEmployeeSubordinatesList; -// for (GetEmployeeSubordinatesList element in getEmployeeSubordinatesList) { -// if (element.eMPLOYEEEMAILADDRESS != null) { -// if (element.eMPLOYEEEMAILADDRESS!.isNotEmpty) { -// teamMembersList.add( -// ChatUser( -// id: int.parse(element.eMPLOYEENUMBER!), -// email: element.eMPLOYEEEMAILADDRESS, -// userName: element.eMPLOYEENAME, -// phone: element.eMPLOYEEMOBILENUMBER, -// userStatus: 0, -// unreadMessageCount: 0, -// isFav: false, -// isTyping: false, -// isImageLoading: false, -// image: element.eMPLOYEEIMAGE ?? "", -// isImageLoaded: element.eMPLOYEEIMAGE == null ? false : true, -// userLocalDownlaodedImage: element.eMPLOYEEIMAGE == null ? null : await downloadImageLocal(element.eMPLOYEEIMAGE ?? "", element.eMPLOYEENUMBER!), -// ), -// ); -// } -// } -// } -// } -// -// for (ChatUser user in searchedChats!) { -// for (ChatUser teamUser in teamMembersList!) { -// if (user.id == teamUser.id) { -// teamUser.userStatus = user.userStatus; -// } -// } -// } -// -// isLoading = false; -// notifyListeners(); -// } - -// void inputBoxDirection(String val) { -// if (val.isNotEmpty) { -// isTextMsg = true; -// } else { -// isTextMsg = false; -// } -// msgText = val; -// notifyListeners(); -// } -// -// void onDirectionChange(bool val) { -// isRTL = val; -// notifyListeners(); -// } - -Material.TextDirection getTextDirection(String v) { - String str = v.trim(); - if (str.isEmpty) return Material.TextDirection.ltr; - int firstUnit = str.codeUnitAt(0); - if (firstUnit > 0x0600 && firstUnit < 0x06FF || - firstUnit > 0x0750 && firstUnit < 0x077F || - firstUnit > 0x07C0 && firstUnit < 0x07EA || - firstUnit > 0x0840 && firstUnit < 0x085B || - firstUnit > 0x08A0 && firstUnit < 0x08B4 || - firstUnit > 0x08E3 && firstUnit < 0x08FF || - firstUnit > 0xFB50 && firstUnit < 0xFBB1 || - firstUnit > 0xFBD3 && firstUnit < 0xFD3D || - firstUnit > 0xFD50 && firstUnit < 0xFD8F || - firstUnit > 0xFD92 && firstUnit < 0xFDC7 || - firstUnit > 0xFDF0 && firstUnit < 0xFDFC || - firstUnit > 0xFE70 && firstUnit < 0xFE74 || - firstUnit > 0xFE76 && firstUnit < 0xFEFC || - firstUnit > 0x10800 && firstUnit < 0x10805 || - firstUnit > 0x1B000 && firstUnit < 0x1B0FF || - firstUnit > 0x1D165 && firstUnit < 0x1D169 || - firstUnit > 0x1D16D && firstUnit < 0x1D172 || - firstUnit > 0x1D17B && firstUnit < 0x1D182 || - firstUnit > 0x1D185 && firstUnit < 0x1D18B || - firstUnit > 0x1D1AA && firstUnit < 0x1D1AD || - firstUnit > 0x1D242 && firstUnit < 0x1D244) { - return Material.TextDirection.rtl; + /// Handle peer audio toggle event + void _onPeerAudioToggle(List? args) { + isPeerMuted = !isPeerMuted; + notifyListeners(); + + if (kDebugMode) { + print('๐ŸŽค Peer ${isPeerMuted ? "muted" : "unmuted"}'); + } } - return Material.TextDirection.ltr; -} -// void openChatByNoti(BuildContext context) async { -// SingleUserChatModel nUser = SingleUserChatModel(); -// Utils.saveStringFromPrefs("isAppOpendByChat", "false"); -// if (await Utils.getStringFromPrefs("notificationData") != "null") { -// nUser = SingleUserChatModel.fromJson(jsonDecode(await Utils.getStringFromPrefs("notificationData"))); -// Utils.saveStringFromPrefs("notificationData", "null"); -// Future.delayed(const Duration(seconds: 2)); -// for (ChatUser user in searchedChats!) { -// if (user.id == nUser.targetUserId) { -// Navigator.pushNamed(context, AppRoutes.chatDetailed, arguments: ChatDetailedScreenParams(user, false)); -// return; -// } -// } -// } -// Utils.saveStringFromPrefs("notificationData", "null"); -// } - -//group chat functions added here - -// void filterGroups(String value) async { -// // filter function added here. -// List tmp = []; -// if (value.isEmpty || value == "") { -// tmp = userGroups.groupresponse!; -// } else { -// for (groups.GroupResponse element in uGroups!) { -// if (element.groupName!.toLowerCase().contains(value.toLowerCase())) { -// tmp.add(element); -// } -// } -// } -// uGroups = tmp; -// notifyListeners(); -// } - -// Future deleteGroup(GroupResponse groupDetails) async { -// isLoading = true; -// await ChatApiClient().deleteGroup(groupDetails.groupId); -// userGroups = await ChatApiClient().getGroupsByUserId(); -// uGroups = userGroups.groupresponse; -// isLoading = false; -// notifyListeners(); -// } -// -// Future getGroupChatHistory(groups.GroupResponse groupDetails) async { -// isLoading = true; -// groupChatHistory = await ChatApiClient().getGroupChatHistory(groupDetails.groupId, groupDetails.groupUserList as List); -// -// isLoading = false; -// -// notifyListeners(); -// } -// -// void updateGroupAdmin(int? groupId, List groupUserList) async { -// isLoading = true; -// await ChatApiClient().updateGroupAdmin(groupId, groupUserList); -// isLoading = false; -// notifyListeners(); -// } - -// Future addGroupAndUsers(createGroup.CreateGroupRequest request) async { -// isLoading = true; -// var groups = await ChatApiClient().addGroupAndUsers(request); -// userGroups.groupresponse!.add(GroupResponse.fromJson(json.decode(groups)['response'])); -// -// isLoading = false; -// notifyListeners(); -// } -// -// Future updateGroupAndUsers(createGroup.CreateGroupRequest request) async { -// isLoading = true; -// await ChatApiClient().updateGroupAndUsers(request); -// userGroups = await ChatApiClient().getGroupsByUserId(); -// uGroups = userGroups.groupresponse; -// isLoading = false; -// notifyListeners(); -// } + /// Handle peer camera toggle event + void _onPeerCameraToggle(List? args) { + isPeerCameraOn = !isPeerCameraOn; + notifyListeners(); + + if (kDebugMode) { + print('๐Ÿ“น Peer camera ${isPeerCameraOn ? "on" : "off"}'); + } + } + + /// Mark call as connected and start duration timer + void markCallConnected() { + if (callStatus == CallStatus.connecting) { + callStatus = CallStatus.connected; + _startCallDurationTimer(); + notifyListeners(); + + if (kDebugMode) { + print('โœ… Call connected'); + } + } + } +} diff --git a/lib/modules/cx_module/chat/model/call_session.dart b/lib/modules/cx_module/chat/model/call_session.dart new file mode 100644 index 00000000..e18d9ee1 --- /dev/null +++ b/lib/modules/cx_module/chat/model/call_session.dart @@ -0,0 +1,100 @@ +/// Call session data model for audio/video calling +/// Stores all information about an active call + +enum CallType { audio, video } + +enum CallDirection { outgoing, incoming } + +enum CallStatus { + idle, + checkingPermissions, + outgoingRinging, + incomingRinging, + connecting, + connected, + ended +} + +class CallSession { + final String callId; + final CallType type; + final CallDirection direction; + final String peerId; // Employee number + final String peerName; + final String? peerAvatar; + final DateTime startTime; + String? sessionId; // For SDP/ICE correlation + String? sdpOffer; + String? sdpAnswer; + + CallSession({ + required this.callId, + required this.type, + required this.direction, + required this.peerId, + required this.peerName, + this.peerAvatar, + required this.startTime, + this.sessionId, + this.sdpOffer, + this.sdpAnswer, + }); + + Map toJson() => { + 'callId': callId, + 'type': type.name, + 'direction': direction.name, + 'peerId': peerId, + 'peerName': peerName, + 'peerAvatar': peerAvatar, + 'startTime': startTime.toIso8601String(), + 'sessionId': sessionId, + 'sdpOffer': sdpOffer, + 'sdpAnswer': sdpAnswer, + }; + + factory CallSession.fromJson(Map json) => CallSession( + callId: json['callId'], + type: CallType.values.firstWhere((e) => e.name == json['type']), + direction: CallDirection.values.firstWhere((e) => e.name == json['direction']), + peerId: json['peerId'], + peerName: json['peerName'], + peerAvatar: json['peerAvatar'], + startTime: DateTime.parse(json['startTime']), + sessionId: json['sessionId'], + sdpOffer: json['sdpOffer'], + sdpAnswer: json['sdpAnswer'], + ); + + CallSession copyWith({ + String? callId, + CallType? type, + CallDirection? direction, + String? peerId, + String? peerName, + String? peerAvatar, + DateTime? startTime, + String? sessionId, + String? sdpOffer, + String? sdpAnswer, + }) { + return CallSession( + callId: callId ?? this.callId, + type: type ?? this.type, + direction: direction ?? this.direction, + peerId: peerId ?? this.peerId, + peerName: peerName ?? this.peerName, + peerAvatar: peerAvatar ?? this.peerAvatar, + startTime: startTime ?? this.startTime, + sessionId: sessionId ?? this.sessionId, + sdpOffer: sdpOffer ?? this.sdpOffer, + sdpAnswer: sdpAnswer ?? this.sdpAnswer, + ); + } + + bool get isVideo => type == CallType.video; + bool get isAudio => type == CallType.audio; + bool get isIncoming => direction == CallDirection.incoming; + bool get isOutgoing => direction == CallDirection.outgoing; +} + diff --git a/lib/modules/cx_module/chat/model/chat_participant_model.dart b/lib/modules/cx_module/chat/model/chat_participant_model.dart index 80c6bfc4..07bdc954 100644 --- a/lib/modules/cx_module/chat/model/chat_participant_model.dart +++ b/lib/modules/cx_module/chat/model/chat_participant_model.dart @@ -9,7 +9,7 @@ class ChatParticipantModel { String? createdAt; int? unreadCount; - ChatParticipantModel({this.id, this.title, this.conversationType, this.participants, this.createdAt, this.unreadCount}); + ChatParticipantModel({this.id, this.title, this.conversationType, this.participants, this.createdAt, this.unreadCount}); ChatParticipantModel.fromJson(Map json) { id = json['id']; @@ -47,8 +47,9 @@ class Participants { String? employeeNumber; String? role; int? userStatus; + String? image; - Participants({this.userId, this.userName, this.employeeNumber, this.role, this.userStatus}); + Participants({this.userId, this.userName, this.employeeNumber, this.role, this.image, this.userStatus}); Participants.fromJson(Map json) { userId = json['userId']; @@ -56,6 +57,7 @@ class Participants { employeeNumber = json['employeeNumber']; role = json['role']; userStatus = json['userStatus']; + image = json['image']; } Map toJson() { @@ -65,6 +67,7 @@ class Participants { data['employeeNumber'] = employeeNumber; data['role'] = role; data['userStatus'] = userStatus; + data['image'] = image; return data; } } diff --git a/lib/new_views/app_style/app_color.dart b/lib/new_views/app_style/app_color.dart index 97a7dfcd..d67233fe 100644 --- a/lib/new_views/app_style/app_color.dart +++ b/lib/new_views/app_style/app_color.dart @@ -35,6 +35,7 @@ class AppColor { static const Color neutral150 = Color(0xffA1A1A1); static const Color neutral160 = Color(0xffE5E5E5); static const Color neutral170 = Color(0xff6E6E6E); + static const Color neutral180 = Color(0xff414352); static const Color disableDark = Color(0xff444756); static const Color switchBgColor = Color(0xff5A5E79); @@ -59,12 +60,15 @@ class AppColor { static const Color white90 = Color(0xffBFD1E4); static const Color white936 = Color(0xff212936); static const Color whiteF3 = Color(0xffF1F1F3); + static const Color whiteF33 = Color(0xffF3F3F3); + static const Color whiteF8d = Color(0xff8d96a3); //black static const Color black10 = Color(0xff3B3D4A); static const Color black20 = Color(0xff212936); static const Color black35 = Color(0xff222A36); static const Color black1E = Color(0xff1E1F20); + static const Color black2E = Color(0xff2E2E2E); static const Color black92 = Color(0xff929292); //red diff --git a/lib/new_views/common_widgets/default_app_bar.dart b/lib/new_views/common_widgets/default_app_bar.dart index 58ac9631..7bda1aec 100644 --- a/lib/new_views/common_widgets/default_app_bar.dart +++ b/lib/new_views/common_widgets/default_app_bar.dart @@ -16,8 +16,21 @@ class DefaultAppBar extends StatelessWidget implements PreferredSizeWidget { final bool showBackButton; final VoidCallback? onWillPopScope; final TextStyle? titleStyle; + final Color? backgroundColor; + final Color? arrowBackColor; - const DefaultAppBar({this.title, this.onBackPress, this.onWillPopScope, this.actions, this.titleStyle, this.showHomeActionButton = false, this.showBackButton = true, Key? key}) : super(key: key); + const DefaultAppBar( + {this.title, + this.onBackPress, + this.arrowBackColor, + this.onWillPopScope, + this.actions, + this.titleStyle, + this.backgroundColor, + this.showHomeActionButton = false, + this.showBackButton = true, + Key? key}) + : super(key: key); @override Widget build(BuildContext context) { @@ -39,12 +52,16 @@ class DefaultAppBar extends StatelessWidget implements PreferredSizeWidget { _actions.addAll(actions!); } return AppBar( + backgroundColor: backgroundColor, automaticallyImplyLeading: false, titleSpacing: 16, title: Row( children: [ if (showBackButton) - const Icon(Icons.arrow_back_ios).onPress(() { + Icon( + Icons.arrow_back_ios, + color: arrowBackColor, + ).onPress(() { if (onWillPopScope != null) { showDialog( context: context, diff --git a/pubspec.lock b/pubspec.lock index 937cec84..e9b535f0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -233,6 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_webrtc: + dependency: transitive + description: + name: dart_webrtc + sha256: f6d615bddea5e458ce180a914f3055c234ffb52fb7397a51b3491e76d6d7edb2 + url: "https://pub.dev" + source: hosted + version: "1.8.1" date_picker_timeline: dependency: "direct main" description: @@ -423,6 +431,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + flutter_callkit_incoming: + dependency: "direct main" + description: + name: flutter_callkit_incoming + sha256: "0e418f2c7fdbddeebc0288ce0e10c17fd01c1a886731d5cb1048d86b72443b92" + url: "https://pub.dev" + source: hosted + version: "3.1.3" flutter_custom_month_picker: dependency: "direct main" description: @@ -590,6 +606,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_webrtc: + dependency: "direct main" + description: + name: flutter_webrtc + sha256: "0f89dee7f4c35dab5611f351c3897a3bfe9f7057720cc7da209b52455af4316e" + url: "https://pub.dev" + source: hosted + version: "1.5.2" fluttertoast: dependency: "direct main" description: @@ -854,6 +878,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: transitive description: @@ -1828,7 +1860,7 @@ packages: source: hosted version: "3.1.5" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" @@ -1899,6 +1931,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webrtc_interface: + dependency: transitive + description: + name: webrtc_interface + sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f + url: "https://pub.dev" + source: hosted + version: "1.5.1" wifi_iot: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 23e7cefa..b66eb3f9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -108,6 +108,9 @@ dependencies: local_auth_darwin: ^1.4.0 haptic_feedback: ^0.6.4+3 sn_progress_dialog: ^1.2.0 + flutter_webrtc: ^1.5.2 + flutter_callkit_incoming: ^3.1.3 + uuid: ^4.5.3 dev_dependencies: @@ -170,4 +173,4 @@ flutter: - asset: assets/fonts/poppins/Poppins-ExtraLight.ttf weight: 200 - asset: assets/fonts/poppins/Poppins-Thin.ttf - weight: 100 \ No newline at end of file + weight: 100