one to one calling implemented

ui_ux_rollout_merge_audio_video_call
WaseemAbbasi22 1 month ago
parent e9df60bd48
commit 8aa40f7b8a

File diff suppressed because it is too large Load Diff

@ -18,6 +18,16 @@
<uses-permission android:name="android.permission.READ_CALENDAR" /> <uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<!-- CallKit/ConnectionService permissions -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<queries> <queries>
<intent> <intent>
<action android:name="android.speech.RecognitionService" /> <action android:name="android.speech.RecognitionService" />

@ -66,6 +66,8 @@
<array> <array>
<string>fetch</string> <string>fetch</string>
<string>remote-notification</string> <string>remote-notification</string>
<string>voip</string>
<string>audio</string>
</array> </array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.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/int_extensions.dart';
import 'package:test_sa/extensions/text_extensions.dart'; import 'package:test_sa/extensions/text_extensions.dart';
import 'package:test_sa/extensions/widget_extensions.dart'; import 'package:test_sa/extensions/widget_extensions.dart';
@ -8,6 +10,7 @@ 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/model/call_session.dart';
import 'package:test_sa/modules/cx_module/chat/call/video_call_page.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/app_style/app_color.dart';
import 'package:test_sa/new_views/app_style/app_themes.dart';
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart'; import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
class AudioCallPage extends StatefulWidget { class AudioCallPage extends StatefulWidget {
@ -21,12 +24,35 @@ class _AudioCallPageState extends State<AudioCallPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Initialize call on page load // Listen for call end and navigate back
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// Will be implemented when connecting to provider final chatProvider = context.read<ChatProvider>();
// Add listener to detect when call ends
chatProvider.addListener(_checkCallStatus);
}); });
} }
void _checkCallStatus() {
final chatProvider = context.read<ChatProvider>();
// If call status is idle (call ended), navigate back to chat
if (chatProvider.callStatus == CallStatus.idle && mounted) {
// Remove listener before popping to avoid memory leaks
chatProvider.removeListener(_checkCallStatus);
Navigator.of(context).pop();
}
}
@override
void dispose() {
// Clean up listener
try {
context.read<ChatProvider>().removeListener(_checkCallStatus);
} catch (e) {
// Provider might already be disposed
}
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -58,31 +84,86 @@ class _AudioCallPageState extends State<AudioCallPage> {
), ),
); );
} }
Widget _buildTopBar(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
const Spacer(),
Text(
'Audio Call',
style: AppTextStyles.heading6.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
const SizedBox(width: 48), // Balance the back button
],
),
);
}
Widget _buildContactInfo(BuildContext context, CallSession session) { Widget _buildContactInfo(BuildContext context, CallSession session) {
return Column( return Column(
children: [ children: [
Container( Stack(
padding: const EdgeInsets.all(14), alignment: Alignment.center,
decoration: BoxDecoration( children: [
shape: BoxShape.circle, Container(
color: AppColor.whiteF8d, padding: const EdgeInsets.all(14),
//TODO need to check opacity decoration: BoxDecoration(
border: Border.all(color: AppColor.white10.withOpacity(0.2), width: 1), shape: BoxShape.circle,
), color: AppColor.whiteF8d,
child: ClipOval( //TODO need to check opacity
child: session.peerAvatar != null border: Border.all(color: AppColor.white10.withOpacity(0.2), width: 1),
? CachedNetworkImage( ),
imageUrl: session.peerAvatar!, child: ClipOval(
fit: BoxFit.cover, child: session.peerAvatar != null
placeholder: (context, url) => Center( ? CachedNetworkImage(
child: CircularProgressIndicator( imageUrl: session.peerAvatar!,
color: Colors.white.withOpacity(0.5), 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),
),
),
// Show mute indicator when peer is muted
Selector<ChatProvider, bool>(
selector: (_, provider) => provider.isPeerMuted,
builder: (context, isPeerMuted, _) {
if (!isPeerMuted) return const SizedBox.shrink();
return Positioned(
bottom: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColor.red30,
shape: BoxShape.circle,
border: Border.all(color: AppColor.white10, width: 2),
), ),
errorWidget: (context, url, error) => 'call_user_avatar'.toSvgAsset(height: 48, width: 48), child: 'mic_disable'.toSvgAsset(
) width: 16,
: 'call_user_avatar'.toSvgAsset(height: 48, width: 48), height: 16,
), color: AppColor.white10,
),
),
);
},
),
],
), ),
24.height, 24.height,
Text( Text(
@ -93,6 +174,44 @@ class _AudioCallPageState extends State<AudioCallPage> {
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
// Show "Microphone is off" text when peer is muted
Selector<ChatProvider, bool>(
selector: (_, provider) => provider.isPeerMuted,
builder: (context, isPeerMuted, _) {
if (!isPeerMuted) return const SizedBox.shrink();
return Column(
children: [
8.height,
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColor.red30.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
'mic_disable'.toSvgAsset(
width: 14,
height: 14,
color: AppColor.white10,
),
6.width,
Text(
'Microphone is off',
style: AppTextStyles.bodyText2.copyWith(
color: AppColor.white10,
fontSize: 12,
),
),
],
),
),
],
);
},
),
], ],
); );
} }
@ -177,20 +296,20 @@ class _AudioCallPageState extends State<AudioCallPage> {
); );
}, },
), ),
_buildControlButton( // _buildControlButton(
icon: 'video_call_icon', // icon: 'video_call_icon',
label: 'Video', // label: 'Video',
isActive: false, // isActive: false,
onPressed: () { // onPressed: () {
// Switch to video call view // // Switch to video call view
Navigator.pushReplacement( // Navigator.pushReplacement(
context, // context,
MaterialPageRoute( // MaterialPageRoute(
builder: (context) => const VideoCallPage(), // builder: (context) => const VideoCallPage(),
), // ),
); // );
}, // },
), // ),
Selector<ChatProvider, bool>( Selector<ChatProvider, bool>(
selector: (_, provider) => provider.isSpeakerOn, selector: (_, provider) => provider.isSpeakerOn,
builder: (context, isSpeakerOn, _) { builder: (context, isSpeakerOn, _) {
@ -254,9 +373,7 @@ class _AudioCallPageState extends State<AudioCallPage> {
child: 'end_call'.toSvgAsset(height: 32, width: 32, color: AppColor.white10), child: 'end_call'.toSvgAsset(height: 32, width: 32, color: AppColor.white10),
).onPress(() async { ).onPress(() async {
await provider.hangUp(); await provider.hangUp();
if (mounted) { // Removed Navigator.pop() - automatic listener will handle navigation
Navigator.pop(context);
}
}); });
} }
} }

@ -0,0 +1,92 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
/// Helper class to debug call issues
class CallDebugHelper {
static void logCallInitiation({
required String callerEmployeeNumber,
required String calleeEmployeeNumber,
required bool isVideo,
required String hubConnectionState,
}) {
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
log('📞 INITIATING CALL', name: 'CALL_DEBUG');
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
log('Caller: $callerEmployeeNumber', name: 'CALL_DEBUG');
log('Callee: $calleeEmployeeNumber', name: 'CALL_DEBUG');
log('Type: ${isVideo ? "VIDEO" : "AUDIO"}', name: 'CALL_DEBUG');
log('Hub State: $hubConnectionState', name: 'CALL_DEBUG');
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
}
static void logConnectionStatus({
required String employeeNumber,
required bool isConnected,
required bool handlersRegistered,
String? additionalInfo,
}) {
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
log('📡 CONNECTION STATUS CHECK', name: 'CALL_DEBUG');
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
log('Employee: $employeeNumber', name: 'CALL_DEBUG');
log('Connected: $isConnected', name: 'CALL_DEBUG');
log('Handlers Registered: $handlersRegistered', name: 'CALL_DEBUG');
if (additionalInfo != null) {
log('Info: $additionalInfo', name: 'CALL_DEBUG');
}
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
}
static void logEventReceived({
required String eventName,
required dynamic rawData,
required String currentStatus,
}) {
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
log('📨 SIGNALR EVENT RECEIVED', name: 'CALL_DEBUG');
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
log('Event: $eventName', name: 'CALL_DEBUG');
log('Current Status: $currentStatus', name: 'CALL_DEBUG');
log('Raw Data: $rawData', name: 'CALL_DEBUG');
log('═══════════════════════════════════════════', name: 'CALL_DEBUG');
}
static void printCallTroubleshooting() {
if (kDebugMode) {
print('\n');
print('╔════════════════════════════════════════════════════════════╗');
print('║ CALL NOT RECEIVED - TROUBLESHOOTING ║');
print('╚════════════════════════════════════════════════════════════╝');
print('');
print('Check the following on BOTH devices:');
print('');
print('1. SignalR Connection:');
print(' Look for: "🔌 SignalR Hub Connection: Started"');
print(' Look for: "✅ All call handlers registered successfully"');
print('');
print('2. Employee Numbers:');
print(' Verify sender and recipient employee numbers are correct');
print(' Look for: "🔵 [CALL] Sender: XXX"');
print(' Look for: " - target: XXX"');
print('');
print('3. Backend Response:');
print(' On receiving device, look for:');
print(' "📞 [CALL EVENT] OnIncomingCallAsync received"');
print('');
print('4. Common Issues:');
print(' ❌ Receiving device not connected to SignalR');
print(' ❌ Call handlers not registered (app not in chat screen)');
print(' ❌ Wrong employee number in backend mapping');
print(' ❌ Backend feature flag disabled for audio/video calls');
print(' ❌ Network connectivity issues');
print('');
print('5. Backend Logs:');
print(' Check backend logs for CallUserAsync invocation');
print(' Verify backend is routing to correct user connection');
print('');
print('╚════════════════════════════════════════════════════════════╝');
print('\n');
}
}
}

@ -0,0 +1,471 @@
import 'package:flutter/material.dart';
import 'package:test_sa/new_views/swipe_module/dialoge/single_btn_dialog.dart';
import 'package:test_sa/new_views/swipe_module/dialoge/info_dialog.dart';
import 'package:permission_handler/permission_handler.dart';
import 'dart:io';
/// Centralized error handler for call-related errors
class CallErrorHandler {
/// Show permission denied dialog
static void showPermissionDenied(
BuildContext context, {
required String permissionName,
VoidCallback? onSettingsTap,
}) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => InfoDialog(
title: 'Permission Required',
message: '$permissionName permission is required to make calls.',
content: [
InfoContent(
title: 'Action needed',
message: 'Please grant $permissionName permission in Settings to continue.',
),
],
okTitle: 'Open Settings',
onTap: () {
Navigator.pop(context);
openAppSettings();
onSettingsTap?.call();
},
),
);
}
/// Show microphone permission denied
static void showMicrophonePermissionDenied(BuildContext context) {
showPermissionDenied(
context,
permissionName: 'Microphone',
);
}
/// Show camera permission denied
static void showCameraPermissionDenied(BuildContext context) {
showPermissionDenied(
context,
permissionName: 'Camera',
);
}
/// Show user is busy dialog
static void showUserBusy(BuildContext context, String userName) {
showDialog(
context: context,
builder: (context) => SingleBtnDialog(
title: 'User Busy',
message: '$userName is currently on another call. Please try again later.',
okTitle: 'OK',
),
);
}
/// Show call declined dialog
static void showCallDeclined(BuildContext context, String userName, String? reason) {
String message;
switch (reason) {
case 'declined':
message = '$userName declined your call.';
break;
case 'busy':
message = '$userName is busy on another call.';
break;
case 'no answer':
message = '$userName didn\'t answer the call.';
break;
default:
message = 'Call was not answered.';
}
showDialog(
context: context,
builder: (context) => SingleBtnDialog(
title: 'Call Ended',
message: message,
okTitle: 'OK',
),
);
}
/// Show connection failed dialog
static void showConnectionFailed(BuildContext context) {
showDialog(
context: context,
builder: (context) => const SingleBtnDialog(
title: 'Connection Failed',
message: 'Unable to establish call connection. Please check your internet connection and try again.',
okTitle: 'OK',
),
);
}
/// Show WebRTC initialization failed
static void showWebRTCInitFailed(BuildContext context) {
showDialog(
context: context,
builder: (context) => const SingleBtnDialog(
title: 'Call Setup Failed',
message: 'Failed to initialize call. Please check your device settings and try again.',
okTitle: 'OK',
),
);
}
/// Show SignalR not connected
static void showSignalRNotConnected(BuildContext context) {
showDialog(
context: context,
builder: (context) => const SingleBtnDialog(
title: 'Connection Error',
message: 'Not connected to server. Please check your internet connection.',
okTitle: 'OK',
),
);
}
/// Show ICE connection failed
static void showICEConnectionFailed(BuildContext context) {
showDialog(
context: context,
builder: (context) => InfoDialog(
title: 'Connection Issue',
message: 'Unable to establish peer-to-peer connection.',
content: [
InfoContent(
title: 'Possible causes',
message: 'Network firewall, poor internet connection, or restricted network.',
),
InfoContent(
title: 'Suggestion',
message: 'Try switching to a different network (WiFi/Mobile data).',
),
],
okTitle: 'OK',
),
);
}
/// Show call timeout dialog
static void showCallTimeout(BuildContext context, String userName) {
showDialog(
context: context,
builder: (context) => SingleBtnDialog(
title: 'No Answer',
message: '$userName didn\'t answer the call.',
okTitle: 'OK',
),
);
}
/// Show network error during call
static void showNetworkErrorDuringCall(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const SingleBtnDialog(
title: 'Connection Lost',
message: 'Network connection was lost during the call.',
okTitle: 'OK',
),
);
}
/// Show generic error
static void showGenericError(BuildContext context, String message) {
showDialog(
context: context,
builder: (context) => SingleBtnDialog(
title: 'Error',
message: message,
okTitle: 'OK',
),
);
}
/// Show call already in progress
static void showCallAlreadyInProgress(BuildContext context) {
showDialog(
context: context,
builder: (context) => const SingleBtnDialog(
title: 'Call In Progress',
message: 'You are already in a call. Please end the current call before starting a new one.',
okTitle: 'OK',
),
);
}
/// Show user offline
static void showUserOffline(BuildContext context, String userName) {
showDialog(
context: context,
builder: (context) => SingleBtnDialog(
title: 'User Offline',
message: '$userName is currently offline and cannot receive calls.',
okTitle: 'OK',
),
);
}
/// Show reconnecting dialog
static void showReconnecting(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const PopScope(
canPop: false,
child: Dialog(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(width: 24),
Text('Reconnecting...'),
],
),
),
),
),
);
}
/// Check internet connectivity
static Future<bool> hasInternetConnection() async {
try {
final result = await InternetAddress.lookup('google.com');
return result.isNotEmpty && result[0].rawAddress.isNotEmpty;
} on SocketException catch (_) {
return false;
}
}
/// Show no internet connection dialog
static void showNoInternetConnection(BuildContext context) {
showDialog(
context: context,
builder: (context) => InfoDialog(
title: 'No Internet Connection',
message: 'Please check your internet connection and try again.',
content: [
InfoContent(
title: 'Action needed',
message: 'Make sure you are connected to WiFi or mobile data.',
),
],
okTitle: 'OK',
),
);
}
/// Show poor network quality warning
static void showPoorNetworkQuality(BuildContext context) {
showDialog(
context: context,
barrierDismissible: true,
builder: (context) => InfoDialog(
title: 'Poor Network Quality',
message: 'Your network connection is weak. Call quality may be affected.',
content: [
InfoContent(
title: 'Recommendation',
message: 'Try moving to an area with better signal or connect to WiFi.',
),
],
okTitle: 'Continue Anyway',
),
);
}
/// Show server error dialog
static void showServerError(BuildContext context, {String? errorMessage}) {
showDialog(
context: context,
builder: (context) => SingleBtnDialog(
title: 'Server Error',
message: errorMessage ?? 'Unable to reach the server. Please try again later.',
okTitle: 'OK',
),
);
}
/// Show call initialization timeout
static void showCallInitializationTimeout(BuildContext context) {
showDialog(
context: context,
builder: (context) => const SingleBtnDialog(
title: 'Connection Timeout',
message: 'Call setup is taking too long. Please check your connection and try again.',
okTitle: 'OK',
),
);
}
/// Show media device error (camera/microphone)
static void showMediaDeviceError(BuildContext context, String deviceName) {
showDialog(
context: context,
builder: (context) => InfoDialog(
title: 'Device Error',
message: 'Unable to access your $deviceName.',
content: [
InfoContent(
title: 'Possible causes',
message: 'Another app may be using the $deviceName, or the device may be unavailable.',
),
InfoContent(
title: 'Solution',
message: 'Close other apps using the $deviceName and try again.',
),
],
okTitle: 'OK',
),
);
}
/// Show call ended unexpectedly
static void showCallEndedUnexpectedly(BuildContext context, {String? reason}) {
showDialog(
context: context,
builder: (context) => SingleBtnDialog(
title: 'Call Ended',
message: reason ?? 'The call ended unexpectedly. This may be due to network issues.',
okTitle: 'OK',
),
);
}
/// Show peer connection lost
static void showPeerConnectionLost(BuildContext context) {
showDialog(
context: context,
builder: (context) => const SingleBtnDialog(
title: 'Connection Lost',
message: 'Lost connection to the other participant. The call has ended.',
okTitle: 'OK',
),
);
}
/// Check and handle internet connectivity before call
static Future<bool> checkInternetConnectionForCall(BuildContext context) async {
final hasInternet = await hasInternetConnection();
if (!hasInternet) {
showNoInternetConnection(context);
return false;
}
return true;
}
/// Show backend not reachable error
static void showBackendNotReachable(BuildContext context) {
showDialog(
context: context,
builder: (context) => InfoDialog(
title: 'Server Unreachable',
message: 'Unable to connect to the server.',
content: [
InfoContent(
title: 'Please check',
message: 'Your internet connection and try again. If the problem persists, the server may be down.',
),
],
okTitle: 'OK',
),
);
}
/// Show SignalR disconnected during call
static void showSignalRDisconnected(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const SingleBtnDialog(
title: 'Connection Lost',
message: 'Lost connection to the server. The call has ended.',
okTitle: 'OK',
),
);
}
/// Show call setup timeout error
static void showCallSetupTimeout(BuildContext context) {
showDialog(
context: context,
builder: (context) => const SingleBtnDialog(
title: 'Setup Failed',
message: 'Call setup timed out. Please check your connection and try again.',
okTitle: 'OK',
),
);
}
/// Handle WebRTC media error with specific message
static void handleMediaError(BuildContext context, dynamic error) {
final errorString = error.toString().toLowerCase();
if (errorString.contains('permission') || errorString.contains('denied')) {
if (errorString.contains('camera') || errorString.contains('video')) {
showCameraPermissionDenied(context);
} else {
showMicrophonePermissionDenied(context);
}
} else if (errorString.contains('notfound') || errorString.contains('not found')) {
showMediaDeviceError(context, 'device');
} else if (errorString.contains('notreadable') || errorString.contains('in use')) {
showMediaDeviceError(context, 'camera or microphone');
} else {
showWebRTCInitFailed(context);
}
}
/// Handle ICE connection error
static void handleICEConnectionError(BuildContext context) {
showICEConnectionFailed(context);
}
/// Handle unexpected error with fallback message
static void handleUnexpectedError(BuildContext context, dynamic error, {String? customMessage}) {
final message = customMessage ?? 'An unexpected error occurred. Please try again.';
showGenericError(context, message);
}
/// Validate and show appropriate error for call failure
static Future<bool> validateCallPreconditions(
BuildContext context, {
required bool checkInternet,
required bool checkPermissions,
required bool isVideoCall,
}) async {
// Check internet connection
if (checkInternet) {
final hasInternet = await hasInternetConnection();
if (!hasInternet) {
showNoInternetConnection(context);
return false;
}
}
// Check microphone permission
if (checkPermissions) {
final micStatus = await Permission.microphone.status;
if (!micStatus.isGranted) {
showMicrophonePermissionDenied(context);
return false;
}
// Check camera permission for video calls
if (isVideoCall) {
final cameraStatus = await Permission.camera.status;
if (!cameraStatus.isGranted) {
showCameraPermissionDenied(context);
return false;
}
}
}
return true;
}
}

@ -0,0 +1,216 @@
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/string_extensions.dart';
import 'package:test_sa/extensions/text_extensions.dart';
import 'package:test_sa/extensions/widget_extensions.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
import '../chat_provider.dart';
import '../model/call_session.dart';
import 'audio_call_page.dart';
import 'video_call_page.dart';
/// Native-style full-screen incoming call dialog matching audio call page design
class IncomingCallDialog extends StatefulWidget {
final CallSession call;
const IncomingCallDialog({
Key? key,
required this.call,
}) : super(key: key);
static void show(BuildContext context, CallSession call) {
Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (context) => IncomingCallDialog(call: call),
),
);
}
@override
State<IncomingCallDialog> createState() => _IncomingCallDialogState();
}
class _IncomingCallDialogState extends State<IncomingCallDialog> {
@override
void initState() {
super.initState();
// Listen for call status changes
WidgetsBinding.instance.addPostFrameCallback((_) {
final chatProvider = context.read<ChatProvider>();
chatProvider.addListener(_checkCallStatus);
});
}
void _checkCallStatus() {
if (!mounted) return; // Early exit if widget is unmounted
final chatProvider = context.read<ChatProvider>();
// If call status is idle (call cancelled/ended), automatically dismiss dialog
if (chatProvider.callStatus == CallStatus.idle) {
// Remove listener before popping
chatProvider.removeListener(_checkCallStatus);
Navigator.of(context).pop();
}
}
@override
void dispose() {
// Clean up listener
try {
context.read<ChatProvider>().removeListener(_checkCallStatus);
} catch (e) {
// Provider might already be disposed
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final chatProvider = context.read<ChatProvider>();
return PopScope(
canPop: false, // Prevent dismissing by back button
child: Scaffold(
backgroundColor: AppColor.backgroundTabBarDark,
body: Column(
children: [
const Spacer(flex: 2),
_buildContactInfo(context, widget.call),
8.height,
_buildIncomingCallStatus(context),
const Spacer(flex: 4),
_buildActions(context, chatProvider, widget.call),
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,
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 _buildIncomingCallStatus(BuildContext context) {
return Column(
children: [
Text(
widget.call.type == CallType.video ? 'Atoms Video Call' : 'Atoms Audio Call',
style: AppTextStyles.heading5.copyWith(
color: AppColor.neutral100,
fontWeight: FontWeight.w400,
),
),
8.height,
Text(
'Incoming call...',
style: AppTextStyles.heading5.copyWith(
color: AppColor.neutral100,
fontWeight: FontWeight.w400,
),
),
],
);
}
Widget _buildActions(BuildContext context, ChatProvider chatProvider, CallSession session) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Decline button
_buildActionButton(
icon: 'end_call',
label: 'Decline',
onPressed: () async {
Navigator.of(context).pop();
await chatProvider.declineCall('declined');
},
),
// Accept button
_buildActionButton(
icon: 'calling_icon',
label: 'Accept',
onPressed: () async {
// Just dismiss the dialog - acceptCall() will handle navigation
Navigator.of(context).pop();
await chatProvider.acceptCall();
},
),
],
);
}
Widget _buildActionButton({
required String icon,
required String label,
required VoidCallback onPressed,
}) {
return Column(
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: icon == 'end_call' ? AppColor.red30 : Colors.green,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
alignment: Alignment.center,
child: icon.toSvgAsset(height: 32, width: 32, color: AppColor.white10),
).onPress(onPressed),
8.height,
Text(
label,
style: AppTextStyles.heading5.copyWith(
color: AppColor.neutral100,
fontWeight: FontWeight.w400,
),
),
],
);
}
}

@ -0,0 +1,386 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_callkit_incoming/entities/entities.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:uuid/uuid.dart';
/// Service to handle CallKit (iOS) and ConnectionService (Android) integration
/// Provides native call UI experience on both platforms
class CallKitService {
static final CallKitService _instance = CallKitService._internal();
factory CallKitService() => _instance;
CallKitService._internal();
// Callbacks for call events
Function(String callId)? onCallAccepted;
Function(String callId)? onCallDeclined;
Function(String callId)? onCallEnded;
Function(String callId)? onCallTimeout;
// Stream subscription for CallKit events
StreamSubscription<CallEvent?>? _eventSubscription;
// Current active call UUID
String? _currentCallId;
/// Initialize CallKit service and listen for events
Future<void> initialize() async {
if (kDebugMode) {
log('📞 [CallKit] Initializing CallKit service', name: 'CallKitService');
}
// Listen for CallKit events
_eventSubscription = FlutterCallkitIncoming.onEvent.listen(_handleCallKitEvent);
if (kDebugMode) {
log('✅ [CallKit] Service initialized', name: 'CallKitService');
}
}
/// Handle CallKit events from native side
void _handleCallKitEvent(CallEvent? event) {
if (event == null) return;
if (kDebugMode) {
log('📞 [CallKit] Event received: ${event.toString()}', name: 'CallKitService');
log('📞 [CallKit] Event type: ${event.eventName}', name: 'CallKitService');
}
// Handle different event types using pattern matching
switch (event) {
case CallEventActionCallAccept(:final callKitParams):
// User accepted the call via CallKit UI
final callId = callKitParams.id;
if (kDebugMode) {
log('✅ [CallKit] Call ACCEPTED via native UI - calling callback', name: 'CallKitService');
log(' Call ID: $callId', name: 'CallKitService');
}
onCallAccepted?.call(callId);
break;
case CallEventActionCallDecline(:final callKitParams):
// User declined the call via CallKit UI
final callId = callKitParams.id;
if (kDebugMode) {
log('❌ [CallKit] Call DECLINED via native UI - calling callback', name: 'CallKitService');
log(' Call ID: $callId', name: 'CallKitService');
}
onCallDeclined?.call(callId);
break;
case CallEventActionCallEnded(:final callKitParams):
// User ended the call via CallKit UI
final callId = callKitParams.id;
if (kDebugMode) {
log('🔴 [CallKit] Call ENDED via native UI - calling callback', name: 'CallKitService');
log(' Call ID: $callId', name: 'CallKitService');
}
onCallEnded?.call(callId);
break;
case CallEventActionCallTimeout(:final id):
// Call timed out (no answer)
if (kDebugMode) {
log('⏱️ [CallKit] Call TIMEOUT - calling callback', name: 'CallKitService');
log(' Call ID: $id', name: 'CallKitService');
}
onCallTimeout?.call(id);
break;
case CallEventActionCallToggleMute(:final id, :final isMuted):
// User toggled mute via CallKit UI
if (kDebugMode) {
log('🔇 [CallKit] Mute toggled: $isMuted for call $id', name: 'CallKitService');
}
break;
case CallEventActionCallToggleHold(:final id, :final isOnHold):
// User toggled hold via CallKit UI
if (kDebugMode) {
log('⏸️ [CallKit] Hold toggled: $isOnHold for call $id', name: 'CallKitService');
}
break;
case CallEventActionCallIncoming(:final callKitParams):
// Incoming call notification shown
if (kDebugMode) {
log('📲 [CallKit] Incoming call notification for: ${callKitParams.id}', name: 'CallKitService');
}
break;
case CallEventActionCallStart(:final callKitParams):
// Call started
if (kDebugMode) {
log('📞 [CallKit] Call started: ${callKitParams.id}', name: 'CallKitService');
}
break;
case CallEventActionCallCallback(:final id):
// Callback event
if (kDebugMode) {
log('📞 [CallKit] Callback event for call: $id', name: 'CallKitService');
}
break;
case CallEventActionCallConnected(:final id):
// Call connected
if (kDebugMode) {
log('✅ [CallKit] Call connected: $id', name: 'CallKitService');
}
break;
case CallEventActionDidUpdateDevicePushTokenVoip():
// Push token updated
if (kDebugMode) {
log('🔔 [CallKit] Device push token updated', name: 'CallKitService');
}
break;
case CallEventActionCallToggleDmtf(:final id, :final digits, :final type):
// DTMF toggled
if (kDebugMode) {
log('🔢 [CallKit] DTMF toggled: $digits for call $id', name: 'CallKitService');
}
break;
case CallEventActionCallToggleGroup(:final id, :final callUUIDToGroupWith):
// Call group toggled
if (kDebugMode) {
log('👥 [CallKit] Group toggled for call $id with $callUUIDToGroupWith', name: 'CallKitService');
}
break;
case CallEventActionCallToggleAudioSession(:final isActive):
// Audio session toggled
if (kDebugMode) {
log('🔊 [CallKit] Audio session toggled: $isActive', name: 'CallKitService');
}
break;
case CallEventActionCallCustom(:final body):
// Custom event
if (kDebugMode) {
log('🔧 [CallKit] Custom event: $body', name: 'CallKitService');
}
break;
default:
if (kDebugMode) {
log(' [CallKit] Unhandled event type: ${event.eventName}', name: 'CallKitService');
}
}
}
/// Show incoming call UI (CallKit on iOS, ConnectionService on Android)
Future<void> showIncomingCall({
required String callId,
required String callerName,
required String callerNumber,
String? callerAvatar,
required bool isVideo,
Map<String, dynamic>? extra,
}) async {
try {
if (kDebugMode) {
log('📞 [CallKit] Showing incoming call UI', name: 'CallKitService');
log(' Caller: $callerName ($callerNumber)', name: 'CallKitService');
log(' Video: $isVideo', name: 'CallKitService');
log(' Call ID: $callId', name: 'CallKitService');
}
_currentCallId = callId;
// Configure call parameters
final params = CallKitParams(
id: callId,
nameCaller: callerName,
appName: 'Atoms SA',
avatar: callerAvatar,
handle: callerNumber,
type: isVideo ? 1 : 0, // 0 = audio, 1 = video
duration: 30000, // 30 seconds timeout
extra: extra ?? {},
headers: <String, dynamic>{'platform': 'flutter'},
android: AndroidParams(
isCustomNotification: true,
isShowLogo: false,
ringtonePath: 'system_ringtone_default',
backgroundColor: '#0955fa',
backgroundUrl: callerAvatar ?? '',
actionColor: '#4CAF50',
textColor: '#ffffff',
incomingCallNotificationChannelName: 'Incoming Calls',
missedCallNotificationChannelName: 'Missed Calls',
),
ios: const IOSParams(
iconName: 'CallKitLogo',
handleType: 'generic',
supportsVideo: true,
maximumCallGroups: 2,
maximumCallsPerCallGroup: 1,
audioSessionMode: 'videoChat',
audioSessionActive: true,
audioSessionPreferredSampleRate: 44100.0,
audioSessionPreferredIOBufferDuration: 0.005,
supportsDTMF: true,
supportsHolding: true,
supportsGrouping: false,
supportsUngrouping: false,
ringtonePath: 'system_ringtone_default',
),
);
// Show the incoming call UI
await FlutterCallkitIncoming.showCallkitIncoming(params);
if (kDebugMode) {
log('✅ [CallKit] Incoming call UI displayed', name: 'CallKitService');
}
} catch (e, stackTrace) {
log('❌ [CallKit] Error showing incoming call: $e',
name: 'CallKitService', error: e, stackTrace: stackTrace);
rethrow;
}
}
/// Start an outgoing call (show native UI)
Future<void> startOutgoingCall({
required String callId,
required String callerName,
required String callerNumber,
String? callerAvatar,
required bool isVideo,
}) async {
try {
if (kDebugMode) {
log('📞 [CallKit] Starting outgoing call', name: 'CallKitService');
}
_currentCallId = callId;
final params = CallKitParams(
id: callId,
nameCaller: callerName,
appName: 'Atoms SA',
avatar: callerAvatar,
handle: callerNumber,
type: isVideo ? 1 : 0,
extra: <String, dynamic>{'outgoing': true},
ios: const IOSParams(
handleType: 'generic',
supportsVideo: true,
audioSessionMode: 'videoChat',
audioSessionActive: true,
),
);
await FlutterCallkitIncoming.startCall(params);
if (kDebugMode) {
log('✅ [CallKit] Outgoing call started', name: 'CallKitService');
}
} catch (e) {
log('❌ [CallKit] Error starting outgoing call: $e', name: 'CallKitService');
}
}
/// Mark call as connected (when peer accepts)
Future<void> setCallConnected(String callId) async {
try {
if (kDebugMode) {
log('✅ [CallKit] Marking call as connected: $callId', name: 'CallKitService');
}
// Update call to connected state - this will update the native UI
await FlutterCallkitIncoming.setCallConnected(callId);
if (kDebugMode) {
log('✅ [CallKit] Call marked as connected', name: 'CallKitService');
}
} catch (e) {
log('❌ [CallKit] Error setting call connected: $e', name: 'CallKitService');
}
}
/// End the current call
Future<void> endCall(String callId) async {
try {
if (kDebugMode) {
log('🔴 [CallKit] Ending call: $callId', name: 'CallKitService');
}
await FlutterCallkitIncoming.endCall(callId);
if (_currentCallId == callId) {
_currentCallId = null;
}
if (kDebugMode) {
log('✅ [CallKit] Call ended', name: 'CallKitService');
}
} catch (e) {
log('❌ [CallKit] Error ending call: $e', name: 'CallKitService');
}
}
/// End all active calls
Future<void> endAllCalls() async {
try {
if (kDebugMode) {
log('🔴 [CallKit] Ending all calls', name: 'CallKitService');
}
await FlutterCallkitIncoming.endAllCalls();
_currentCallId = null;
if (kDebugMode) {
log('✅ [CallKit] All calls ended', name: 'CallKitService');
}
} catch (e) {
log('❌ [CallKit] Error ending all calls: $e', name: 'CallKitService');
}
}
/// Get all active calls
Future<List<dynamic>> getActiveCalls() async {
try {
final calls = await FlutterCallkitIncoming.activeCalls();
if (kDebugMode) {
log('📋 [CallKit] Active calls: ${calls.length}', name: 'CallKitService');
}
return calls;
} catch (e) {
log('❌ [CallKit] Error getting active calls: $e', name: 'CallKitService');
return [];
}
}
/// Dispose and cleanup
Future<void> dispose() async {
try {
if (kDebugMode) {
log('🧹 [CallKit] Disposing service', name: 'CallKitService');
}
// Cancel event subscription
await _eventSubscription?.cancel();
_eventSubscription = null;
// End all active calls
await endAllCalls();
// Clear callbacks
onCallAccepted = null;
onCallDeclined = null;
onCallEnded = null;
onCallTimeout = null;
if (kDebugMode) {
log('✅ [CallKit] Service disposed', name: 'CallKitService');
}
} catch (e) {
log('❌ [CallKit] Error disposing service: $e', name: 'CallKitService');
}
}
}

@ -0,0 +1,617 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
/// WebRTC service for handling peer-to-peer audio/video connections
class WebRTCService {
// Peer connection
RTCPeerConnection? _peerConnection;
// Media streams
MediaStream? _localStream;
MediaStream? _remoteStream;
// Video renderers (for future video support)
RTCVideoRenderer? localRenderer;
RTCVideoRenderer? remoteRenderer;
// Callbacks
Function(MediaStream stream)? onLocalStream;
Function(MediaStream stream)? onRemoteStream;
Function(RTCIceCandidate candidate)? onIceCandidate;
Function(RTCIceConnectionState state)? onIceConnectionStateChange;
Function()? onCallEnded;
// ICE candidate queue (for candidates received before remote description)
final List<RTCIceCandidate> _iceCandidateQueue = [];
bool _remoteDescriptionSet = false;
// Track if current call is video call
bool _isVideoCall = false;
// Track if we're using test mode (Google STUN only)
static bool _useTestMode = false;
/// Enable test mode (use only Google STUN servers for debugging)
static void enableTestMode() {
_useTestMode = true;
if (kDebugMode) {
log('⚙️ [WebRTC] Test mode enabled', name: 'WebRTCService');
}
}
/// Disable test mode (use backend TURN servers)
static void disableTestMode() {
_useTestMode = false;
if (kDebugMode) {
log('⚙️ [WebRTC] Test mode disabled', name: 'WebRTCService');
}
}
// Configuration
static const Map<String, dynamic> _mediaConstraints = {
'audio': true,
'video': false,
};
// Video constraints for video calls
static const Map<String, dynamic> _videoConstraints = {
'audio': true,
'video': {
'facingMode': 'user',
'width': {'ideal': 1280},
'height': {'ideal': 720},
},
};
// ICE servers configuration for TEST MODE (Google STUN only)
static const Map<String, dynamic> _testConfiguration = {
'iceServers': [
{
'urls': [
'stun:stun.l.google.com:19302',
'stun:stun1.l.google.com:19302',
'stun:stun2.l.google.com:19302',
]
},
],
'sdpSemantics': 'unified-plan',
'iceTransportPolicy': 'all',
'iceCandidatePoolSize': 10,
};
// ICE servers configuration (from backend)
final Map<String, dynamic> _configuration = {
'iceServers': [
// Primary STUN servers (Google's public STUN - highly reliable)
{
'urls': [
'stun:stun.l.google.com:19302',
'stun:stun1.l.google.com:19302',
]
},
// Backend TURN server (PRIMARY)
{
'urls': [
'turn:15.185.116.59:3479',
'turn:15.185.116.59:3479?transport=tcp',
],
'username': 'admin',
'credential': 'admin'
},
// FALLBACK: Public TURN servers
{
'urls': 'turn:a.relay.metered.ca:80',
'username': 'e14d93f87f9ff517f1bee797',
'credential': 'PGfCGmR6CR2aM9OY',
},
{
'urls': 'turn:a.relay.metered.ca:443',
'username': 'e14d93f87f9ff517f1bee797',
'credential': 'PGfCGmR6CR2aM9OY',
},
],
'sdpSemantics': 'unified-plan',
'iceTransportPolicy': 'all',
'iceCandidatePoolSize': 10,
'bundlePolicy': 'max-bundle',
'rtcpMuxPolicy': 'require',
};
/// Initialize WebRTC for audio call
Future<void> initializeForAudioCall() async {
try {
if (kDebugMode) {
log('🔧 [WebRTC] Initializing audio call', name: 'WebRTCService');
}
_isVideoCall = false;
// Get local audio stream
try {
_localStream = await navigator.mediaDevices.getUserMedia(_mediaConstraints);
} catch (e) {
log('❌ [WebRTC] Failed to get audio stream: $e', name: 'WebRTCService', error: e);
throw Exception('Microphone access denied or unavailable');
}
// Create peer connection
_peerConnection = await createPeerConnection(_configuration);
if (_peerConnection == null) {
throw Exception('Failed to create peer connection');
}
// Add local stream tracks to peer connection
_localStream!.getTracks().forEach((track) {
_peerConnection!.addTrack(track, _localStream!);
});
// Setup peer connection event handlers
_setupPeerConnectionListeners();
if (kDebugMode) {
log('✅ [WebRTC] Audio call initialized', name: 'WebRTCService');
}
} catch (e, stackTrace) {
log('❌ [WebRTC] Initialization error: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace);
// Clean up any partial initialization
await dispose();
rethrow;
}
}
/// Initialize WebRTC for video call
Future<void> initializeForVideoCall() async {
try {
if (kDebugMode) {
log('🔧 [WebRTC] Initializing video call', name: 'WebRTCService');
}
_isVideoCall = true;
// Initialize video renderers
localRenderer = RTCVideoRenderer();
remoteRenderer = RTCVideoRenderer();
try {
await localRenderer!.initialize();
await remoteRenderer!.initialize();
} catch (e) {
log('❌ [WebRTC] Failed to initialize renderers: $e', name: 'WebRTCService', error: e);
throw Exception('Failed to initialize video renderers');
}
// Create peer connection FIRST (before getting media)
final config = _useTestMode ? _testConfiguration : _configuration;
_peerConnection = await createPeerConnection(config);
if (_peerConnection == null) {
throw Exception('Failed to create peer connection');
}
// Setup peer connection listeners immediately
_setupPeerConnectionListeners();
// Get local audio + video stream
try {
_localStream = await navigator.mediaDevices.getUserMedia(_videoConstraints);
} catch (e) {
log('❌ [WebRTC] Failed to get video stream: $e', name: 'WebRTCService', error: e);
throw Exception('Camera or microphone access denied or unavailable');
}
// Set local stream to renderer
if (localRenderer != null) {
localRenderer!.srcObject = _localStream;
}
// Add local stream tracks to peer connection
_localStream!.getTracks().forEach((track) {
_peerConnection!.addTrack(track, _localStream!);
});
if (kDebugMode) {
log('✅ [WebRTC] Video call initialized', name: 'WebRTCService');
}
} catch (e, stackTrace) {
log('❌ [WebRTC] Video initialization error: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace);
// Clean up any partial initialization
await dispose();
rethrow;
}
}
/// Setup peer connection event listeners
void _setupPeerConnectionListeners() {
// Handle ICE candidates
_peerConnection!.onIceCandidate = (RTCIceCandidate candidate) {
if (onIceCandidate != null) {
onIceCandidate!(candidate);
}
};
// Handle ICE gathering state changes
_peerConnection!.onIceGatheringState = (RTCIceGatheringState state) {
if (kDebugMode && state == RTCIceGatheringState.RTCIceGatheringStateComplete) {
log('✅ [WebRTC] ICE gathering completed', name: 'WebRTCService');
}
};
// Handle ICE connection state changes
_peerConnection!.onIceConnectionState = (RTCIceConnectionState state) {
if (kDebugMode) {
log('🔗 [WebRTC] ICE state: ${state.toString()}', name: 'WebRTCService');
}
// Log critical failures
if (state == RTCIceConnectionState.RTCIceConnectionStateFailed) {
log('❌ [WebRTC] ICE connection failed', name: 'WebRTCService');
}
if (onIceConnectionStateChange != null) {
onIceConnectionStateChange!(state);
}
};
// Handle remote stream
_peerConnection!.onTrack = (RTCTrackEvent event) {
if (event.streams.isNotEmpty) {
final stream = event.streams[0];
// If this is the first remote stream or a different stream, update it
if (_remoteStream == null || _remoteStream!.id != stream.id) {
_remoteStream = stream;
// For video calls, assign stream to renderer
if (remoteRenderer != null && _isVideoCall) {
try {
remoteRenderer!.srcObject = _remoteStream;
// Retry assignment after delays to ensure it sticks
Future.delayed(const Duration(milliseconds: 100), () {
if (remoteRenderer != null && remoteRenderer!.srcObject == null && _remoteStream != null) {
remoteRenderer!.srcObject = _remoteStream;
}
});
Future.delayed(const Duration(milliseconds: 300), () {
if (remoteRenderer != null && remoteRenderer!.srcObject == null && _remoteStream != null) {
remoteRenderer!.srcObject = _remoteStream;
}
});
} catch (e) {
log('⚠️ [WebRTC] Failed to assign remote stream: $e', name: 'WebRTCService');
}
}
// Notify callback
if (onRemoteStream != null) {
onRemoteStream!(_remoteStream!);
}
if (kDebugMode) {
log('📡 [WebRTC] Remote stream received', name: 'WebRTCService');
}
} else {
// Additional track on existing stream
if (remoteRenderer != null && _isVideoCall && remoteRenderer!.srcObject == null && _remoteStream != null) {
try {
remoteRenderer!.srcObject = _remoteStream;
} catch (e) {
log('⚠️ [WebRTC] Failed to assign stream (additional track): $e', name: 'WebRTCService');
}
}
if (onRemoteStream != null) {
onRemoteStream!(_remoteStream!);
}
}
}
};
// Handle connection state changes
_peerConnection!.onConnectionState = (RTCPeerConnectionState state) {
if (kDebugMode || state == RTCPeerConnectionState.RTCPeerConnectionStateFailed) {
log('🔌 [WebRTC] Connection state: ${state.toString()}', name: 'WebRTCService');
}
};
}
/// Create SDP offer (caller side)
Future<RTCSessionDescription> createOffer() async {
try {
if (_peerConnection == null) {
throw Exception('Peer connection not initialized');
}
final offer = await _peerConnection!.createOffer({
'offerToReceiveAudio': true,
'offerToReceiveVideo': _isVideoCall,
'iceRestart': false,
});
await _peerConnection!.setLocalDescription(offer);
if (kDebugMode) {
log('✅ [WebRTC] SDP offer created', name: 'WebRTCService');
}
return offer;
} catch (e, stackTrace) {
log('❌ [WebRTC] Error creating offer: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace);
rethrow;
}
}
/// Create SDP answer (callee side)
Future<RTCSessionDescription> createAnswer(String offerSdp) async {
try {
if (_peerConnection == null) {
throw Exception('Peer connection not initialized');
}
// Set remote description (offer from caller)
final offer = RTCSessionDescription(offerSdp, 'offer');
await _peerConnection!.setRemoteDescription(offer);
_remoteDescriptionSet = true;
// Flush queued ICE candidates
await _flushIceCandidateQueue();
// Create answer
final answer = await _peerConnection!.createAnswer({
'offerToReceiveAudio': true,
'offerToReceiveVideo': _isVideoCall,
});
await _peerConnection!.setLocalDescription(answer);
if (kDebugMode) {
log('✅ [WebRTC] SDP answer created', name: 'WebRTCService');
}
return answer;
} catch (e, stackTrace) {
log('❌ [WebRTC] Error creating answer: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace);
rethrow;
}
}
/// Set remote answer (caller side)
Future<void> setRemoteAnswer(String answerSdp) async {
try {
if (_peerConnection == null) {
throw Exception('Peer connection not initialized');
}
final answer = RTCSessionDescription(answerSdp, 'answer');
await _peerConnection!.setRemoteDescription(answer);
_remoteDescriptionSet = true;
// Flush queued ICE candidates
await _flushIceCandidateQueue();
if (kDebugMode) {
log('✅ [WebRTC] Remote answer set', name: 'WebRTCService');
}
} catch (e, stackTrace) {
log('❌ [WebRTC] Error setting remote answer: $e', name: 'WebRTCService', error: e, stackTrace: stackTrace);
rethrow;
}
}
/// Send SDP answer (convenience method for callee)
Future<RTCSessionDescription> sendSDPAnswer(String? offerSdp) async {
if (offerSdp == null) {
throw Exception('Offer SDP is null');
}
return await createAnswer(offerSdp);
}
/// Add remote ICE candidate
Future<void> addIceCandidate(RTCIceCandidate candidate) async {
try {
// If peer connection not created yet, queue the candidate
if (_peerConnection == null) {
_iceCandidateQueue.add(candidate);
return;
}
// If remote description not set yet, queue the candidate
if (!_remoteDescriptionSet) {
_iceCandidateQueue.add(candidate);
return;
}
await _peerConnection!.addCandidate(candidate);
} catch (e) {
log('⚠️ [WebRTC] Error adding ICE candidate: $e', name: 'WebRTCService');
// Don't rethrow - ICE candidate errors shouldn't break the call
}
}
/// Flush queued ICE candidates
Future<void> _flushIceCandidateQueue() async {
if (_iceCandidateQueue.isEmpty) {
return;
}
if (kDebugMode) {
log('🔄 [WebRTC] Flushing ${_iceCandidateQueue.length} ICE candidates', name: 'WebRTCService');
}
for (final candidate in _iceCandidateQueue) {
try {
await _peerConnection!.addCandidate(candidate);
} catch (e) {
log('⚠️ [WebRTC] Error adding queued candidate: $e', name: 'WebRTCService');
}
}
_iceCandidateQueue.clear();
}
/// Toggle microphone mute
void setMicrophoneMuted(bool muted) {
if (_localStream != null) {
final audioTracks = _localStream!.getAudioTracks();
for (final track in audioTracks) {
track.enabled = !muted;
}
}
}
/// Toggle camera on/off for video calls
void setCameraEnabled(bool enabled) {
if (_localStream != null) {
final videoTracks = _localStream!.getVideoTracks();
for (final track in videoTracks) {
track.enabled = enabled;
}
}
}
/// Switch between front and rear camera
Future<void> switchCamera() async {
if (_localStream != null) {
final videoTracks = _localStream!.getVideoTracks();
if (videoTracks.isNotEmpty) {
try {
await Helper.switchCamera(videoTracks.first);
if (kDebugMode) {
log('✅ [WebRTC] Camera switched', name: 'WebRTCService');
}
} catch (e) {
log('❌ [WebRTC] Error switching camera: $e', name: 'WebRTCService');
}
}
}
}
/// Enable/disable speakerphone
Future<void> setSpeakerphoneEnabled(bool enabled) async {
try {
await Helper.setSpeakerphoneOn(enabled);
} catch (e) {
log('❌ [WebRTC] Error setting speakerphone: $e', name: 'WebRTCService');
}
}
/// Get local stream
MediaStream? get localStream => _localStream;
/// Get remote stream
MediaStream? get remoteStream => _remoteStream;
/// Check if remote stream has video tracks
bool get hasRemoteVideo {
if (_remoteStream == null) return false;
return _remoteStream!.getVideoTracks().isNotEmpty;
}
/// Force refresh remote renderer
void refreshRemoteRenderer() {
if (_remoteStream != null && remoteRenderer != null && _isVideoCall) {
if (remoteRenderer!.srcObject == null) {
remoteRenderer!.srcObject = _remoteStream;
if (kDebugMode) {
log('✅ [WebRTC] Remote renderer refreshed', name: 'WebRTCService');
}
}
}
}
/// Restart ICE negotiation (used when connection fails)
Future<RTCSessionDescription?> restartIce() async {
try {
if (_peerConnection == null) {
return null;
}
final offer = await _peerConnection!.createOffer({
'offerToReceiveAudio': true,
'offerToReceiveVideo': _isVideoCall,
'iceRestart': true,
});
await _peerConnection!.setLocalDescription(offer);
if (kDebugMode) {
log('✅ [WebRTC] ICE restart initiated', name: 'WebRTCService');
}
return offer;
} catch (e) {
log('❌ [WebRTC] ICE restart failed: $e', name: 'WebRTCService');
return null;
}
}
/// Get peer connection state
RTCPeerConnectionState? getPeerConnectionState() {
return _peerConnection?.connectionState;
}
/// Get ICE connection state
RTCIceConnectionState? getIceConnectionState() {
return _peerConnection?.iceConnectionState;
}
/// Check if peer connection is initialized
bool get isPeerConnectionInitialized => _peerConnection != null;
/// Dispose and cleanup
Future<void> dispose() async {
try {
// Stop local stream tracks
if (_localStream != null) {
_localStream!.getTracks().forEach((track) {
track.stop();
});
await _localStream!.dispose();
_localStream = null;
}
// Stop remote stream tracks
if (_remoteStream != null) {
_remoteStream!.getTracks().forEach((track) {
track.stop();
});
await _remoteStream!.dispose();
_remoteStream = null;
}
// Dispose renderers
if (localRenderer != null) {
await localRenderer!.dispose();
localRenderer = null;
}
if (remoteRenderer != null) {
await remoteRenderer!.dispose();
remoteRenderer = null;
}
// Close peer connection
if (_peerConnection != null) {
await _peerConnection!.close();
await _peerConnection!.dispose();
_peerConnection = null;
}
// Clear ICE candidate queue
_iceCandidateQueue.clear();
_remoteDescriptionSet = false;
if (kDebugMode) {
log('✅ [WebRTC] Service disposed', name: 'WebRTCService');
}
} catch (e) {
log('⚠️ [WebRTC] Error during dispose: $e', name: 'WebRTCService');
}
}
}

@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
// import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:test_sa/extensions/context_extension.dart'; import 'package:test_sa/extensions/context_extension.dart';
@ -22,74 +24,328 @@ class VideoCallPage extends StatefulWidget {
} }
class _VideoCallPageState extends State<VideoCallPage> { class _VideoCallPageState extends State<VideoCallPage> {
Timer? _streamCheckTimer;
bool _isLocalVideoFullscreen = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (kDebugMode) {
log('🎬 [VIDEO CALL PAGE] Page initialized', name: 'VideoCallPage');
}
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// Initialize video call final chatProvider = context.read<ChatProvider>();
// Add listener to detect when call ends
chatProvider.addListener(_checkCallStatus);
// Start periodic check to ensure remote stream gets assigned
_startStreamCheckTimer();
});
}
void _startStreamCheckTimer() {
// Check every 500ms if remote stream needs to be assigned
_streamCheckTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) {
final chatProvider = context.read<ChatProvider>();
final webrtc = chatProvider.webrtcService;
if (webrtc == null || !mounted) {
timer.cancel();
return;
}
// If we have remote stream but renderer doesn't have it, assign it
if (webrtc.remoteStream != null &&
webrtc.remoteRenderer != null &&
webrtc.remoteRenderer!.srcObject == null) {
try {
webrtc.remoteRenderer!.srcObject = webrtc.remoteStream;
// Force UI rebuild
if (mounted) {
setState(() {});
}
} catch (e) {
if (kDebugMode) {
log('⚠️ [VIDEO CALL PAGE] Stream assignment failed: $e', name: 'VideoCallPage');
}
}
}
// If call is connected and remote stream is assigned, slow down checks
if (chatProvider.callStatus == CallStatus.connected &&
webrtc.remoteRenderer?.srcObject != null) {
timer.cancel();
// Switch to slower monitoring (every 5 seconds)
Timer.periodic(const Duration(seconds: 5), (slowTimer) {
if (!mounted) {
slowTimer.cancel();
}
});
}
}); });
} }
void _checkCallStatus() {
final chatProvider = context.read<ChatProvider>();
// If call ended, navigate back
if (chatProvider.callStatus == CallStatus.idle && mounted) {
chatProvider.removeListener(_checkCallStatus);
Navigator.of(context).pop();
}
}
@override
void dispose() {
// Cancel stream check timer
_streamCheckTimer?.cancel();
_streamCheckTimer = null;
// Clean up listener
try {
context.read<ChatProvider>().removeListener(_checkCallStatus);
} catch (e) {
// Provider might already be disposed
if (kDebugMode) {
log('⚠️ [VIDEO CALL PAGE] Error removing listener: $e', name: 'VideoCallPage');
}
}
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppColor.backgroundTabBarDark, backgroundColor: AppColor.backgroundTabBarDark,
body: Selector<ChatProvider, CallSession?>( body: SafeArea(
selector: (_, provider) => provider.currentCall, top: false,
builder: (context, session, _) { child: Selector<ChatProvider, CallSession?>(
if (session == null) { selector: (_, provider) => provider.currentCall,
return const Center( builder: (context, session, _) {
child: Text( if (session == null) {
'No active call', return const Center(
style: TextStyle(color: Colors.white), child: Text(
), 'No active call',
); style: TextStyle(color: Colors.white),
} ),
);
}
return Stack( return Stack(
children: [ children: [
_buildRemoteVideo(context), // Background video (fullscreen)
_buildLocalVideo(context), _isLocalVideoFullscreen ? _buildFullscreenLocalVideo(context) : _buildRemoteVideo(context),
_buildTopBar(context, session),
_buildBottomControls(context, session), // Small preview video (top-right corner)
], _isLocalVideoFullscreen ? _buildSmallRemoteVideo(context) : _buildLocalVideo(context),
);
}, _buildTopBar(context, session),
_buildBottomControls(context, session),
],
);
},
),
), ),
); );
} }
Widget _buildRemoteVideo(BuildContext context) { // Fullscreen local video (when tapped)
Widget _buildFullscreenLocalVideo(BuildContext context) {
final chatProvider = context.read<ChatProvider>(); final chatProvider = context.read<ChatProvider>();
return Positioned.fill( return Positioned.fill(
child: Selector<ChatProvider, bool>( child: GestureDetector(
selector: (_, provider) => provider.isPeerCameraOn, onTap: () {
builder: (context, isPeerCameraOn, _) { setState(() {
if (!isPeerCameraOn) { _isLocalVideoFullscreen = false; // Switch back to remote fullscreen
// Show avatar when peer camera is off });
return _buildRemoteAvatarPlaceholder(context); },
} child: Consumer<ChatProvider>(
builder: (context, chatProvider, _) {
if (!chatProvider.isCameraOn) {
// Show avatar when camera is off
return Container(
color: Colors.grey[800],
child: const Center(
child: Icon(
Icons.videocam_off,
color: Colors.white,
size: 64,
),
),
);
}
// TODO: Replace with actual RTCVideoView when WebRTC is wired if (chatProvider.webrtcService?.localRenderer != null) {
return Container( return RTCVideoView(
color: Colors.black, chatProvider.webrtcService!.localRenderer!,
child: const Center( objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
child: Text( mirror: true,
'Remote Video', );
style: TextStyle(color: Colors.white54), }
return Container(
color: Colors.black,
child: const Center(
child: CircularProgressIndicator(color: Colors.white54),
), ),
), );
); },
),
),
);
}
/* Will be replaced with: // Small remote video (when local is fullscreen)
return RTCVideoView( Widget _buildSmallRemoteVideo(BuildContext context) {
chatProvider.webrtcService.remoteRenderer, return Positioned(
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, top: MediaQuery.of(context).padding.top + 80,
mirror: false, right: 16,
); child: GestureDetector(
*/ onTap: () {
setState(() {
_isLocalVideoFullscreen = false; // Switch back to remote fullscreen
});
},
child: Consumer<ChatProvider>(
builder: (context, chatProvider, _) {
final remoteStream = chatProvider.webrtcService?.remoteStream;
final remoteRenderer = chatProvider.webrtcService?.remoteRenderer;
if (!chatProvider.isPeerCameraOn) {
// Show avatar placeholder
return Container(
width: 120,
height: 160,
decoration: BoxDecoration(
color: AppColor.backgroundTabBarDark,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white, width: 2),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.videocam_off, color: Colors.white, size: 32),
8.height,
Text(
chatProvider.currentCall?.peerName.split(' ').first ?? '',
style: AppTextStyles.bodyText2.copyWith(
color: Colors.white,
fontSize: 12,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
if (remoteRenderer != null && remoteStream != null && remoteRenderer.srcObject != null) {
return Container(
width: 120,
height: 160,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white, width: 2),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: RTCVideoView(
remoteRenderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
mirror: false,
),
),
);
}
return 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: CircularProgressIndicator(color: Colors.white54, strokeWidth: 2),
),
);
},
),
),
);
}
Widget _buildRemoteVideo(BuildContext context) {
return Positioned.fill(
child: GestureDetector(
onTap: () {
setState(() {
_isLocalVideoFullscreen = true; // Switch to local fullscreen
});
}, },
child: Consumer<ChatProvider>(
builder: (context, chatProvider, _) {
final remoteRenderer = chatProvider.webrtcService?.remoteRenderer;
final remoteStream = chatProvider.webrtcService?.remoteStream;
if (remoteRenderer != null) {
if (remoteRenderer.srcObject == null && remoteStream != null) {
// Renderer exists but srcObject is null, assign it
WidgetsBinding.instance.addPostFrameCallback((_) {
final renderer = chatProvider.webrtcService?.remoteRenderer;
final stream = chatProvider.webrtcService?.remoteStream;
if (renderer != null && stream != null && renderer.srcObject == null) {
renderer.srcObject = stream;
// Force UI rebuild
if (mounted) {
setState(() {});
}
}
});
}
// If renderer has stream OR we just assigned it, show the video view
final hasRendererStream = remoteRenderer.srcObject != null;
if (hasRendererStream) {
return RTCVideoView(
remoteRenderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
mirror: false,
);
}
}
// Fallback while waiting for remote stream
return Container(
color: Colors.black,
child: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: Colors.white54),
SizedBox(height: 16),
Text(
'Waiting for video...',
style: TextStyle(color: Colors.white54),
),
],
),
),
);
},
),
), ),
); );
} }
@ -101,143 +357,294 @@ class _VideoCallPageState extends State<VideoCallPage> {
if (session == null) return const SizedBox.shrink(); if (session == null) return const SizedBox.shrink();
return Container( return Container(
color: AppColor.backgroundTabBarDark, color: AppColor.backgroundTabBarDark,
child: Center( child: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Container( Stack(
padding: const EdgeInsets.all(14), alignment: Alignment.center,
decoration: BoxDecoration( children: [
shape: BoxShape.circle, Container(
color: AppColor.whiteF8d, padding: const EdgeInsets.all(14),
border: Border.all(color: AppColor.white10.withAlpha(51), width: 1), decoration: BoxDecoration(
), shape: BoxShape.circle,
child: ClipOval( color: AppColor.whiteF8d,
child: session.peerAvatar != null border: Border.all(color: AppColor.white10.withAlpha(51), width: 1),
? CachedNetworkImage( ),
imageUrl: session.peerAvatar!, child: ClipOval(
fit: BoxFit.cover, child: session.peerAvatar != null
placeholder: (context, url) => Center( ? CachedNetworkImage(
child: CircularProgressIndicator( imageUrl: session.peerAvatar!,
color: Colors.white.withAlpha(128), 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),
),
),
// Show mute indicator badge when peer is muted
Selector<ChatProvider, bool>(
selector: (_, provider) => provider.isPeerMuted,
builder: (context, isPeerMuted, _) {
if (!isPeerMuted) return const SizedBox.shrink();
return Positioned(
bottom: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColor.red30,
shape: BoxShape.circle,
border: Border.all(color: AppColor.white10, width: 2),
),
child: 'mic_disable'.toSvgAsset(
width: 16,
height: 16,
color: AppColor.white10,
), ),
), ),
errorWidget: (context, url, error) => 'call_user_avatar'.toSvgAsset(height: 48, width: 48), );
) },
: 'call_user_avatar'.toSvgAsset(height: 48, width: 48), ),
],
), ),
), 24.height,
24.height, Text(
Text( session.peerName,
session.peerName, style: AppTextStyles.heading2.copyWith(
style: AppTextStyles.heading2.copyWith( color: Colors.white,
color: Colors.white, fontWeight: FontWeight.w600,
fontWeight: FontWeight.w600, ),
textAlign: TextAlign.center,
), ),
textAlign: TextAlign.center, 8.height,
), Text(
8.height, 'Camera is off',
Text( style: AppTextStyles.heading5.copyWith(
'Camera is off', color: AppColor.neutral100,
style: AppTextStyles.heading5.copyWith( fontWeight: FontWeight.w400,
color: AppColor.neutral100, ),
fontWeight: FontWeight.w400,
), ),
), // Show mute status when peer is muted
], Selector<ChatProvider, bool>(
), selector: (_, provider) => provider.isPeerMuted,
), builder: (context, isPeerMuted, _) {
); if (!isPeerMuted) return const SizedBox.shrink();
return Column(
children: [
8.height,
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColor.red30.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
'mic_disable'.toSvgAsset(
width: 14,
height: 14,
color: AppColor.white10,
),
6.width,
Text(
'Microphone is off',
style: AppTextStyles.bodyText2.copyWith(
color: AppColor.white10,
fontSize: 12,
),
),
],
),
),
],
);
},
),
],
),
));
}, },
); );
} }
Widget _buildLocalVideo(BuildContext context) { Widget _buildLocalVideo(BuildContext context) {
return Selector<ChatProvider, bool>( final chatProvider = context.read<ChatProvider>();
selector: (_, provider) => provider.isCameraOn,
builder: (context, isCameraOn, _) { return Positioned(
if (!isCameraOn) { top: MediaQuery.of(context).padding.top + 80,
// Show avatar when local camera is off right: 16,
return DraggableLocalPreview( child: GestureDetector(
child: Container( onTap: () {
setState(() {
_isLocalVideoFullscreen = true; // Make local video fullscreen
});
},
child: Selector<ChatProvider, bool>(
selector: (_, provider) => provider.isCameraOn,
builder: (context, isCameraOn, _) {
if (!isCameraOn) {
return 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,
),
);
}
if (chatProvider.webrtcService?.localRenderer != null) {
return Container(
width: 120,
height: 160,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white, width: 2),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: RTCVideoView(
chatProvider.webrtcService!.localRenderer!,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
mirror: true,
),
),
);
}
return Container(
width: 120, width: 120,
height: 160, height: 160,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[800], color: Colors.black,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white, width: 2), border: Border.all(color: Colors.white, width: 2),
), ),
child: const Icon( child: const Center(
Icons.videocam_off, child: CircularProgressIndicator(color: Colors.white54, strokeWidth: 2),
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<ChatProvider>();
return DraggableLocalPreview(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: RTCVideoView(
chatProvider.webrtcService.localRenderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
mirror: true,
),
),
);
*/
},
); );
} }
Widget _buildTopBar(BuildContext context, CallSession session) { Widget _buildTopBar(BuildContext context, CallSession session) {
return Align( return Positioned(
alignment: AlignmentGeometry.topLeft, top: 0,
child: Padding( left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.7),
Colors.transparent,
],
),
),
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top, top: MediaQuery.of(context).padding.top + 8,
left: 16,
right: 16,
bottom: 24,
), ),
child: IconButton( child: Row(
icon: const Icon( children: [
Icons.arrow_back_ios, IconButton(
color: AppColor.white10, icon: const Icon(Icons.arrow_back, color: Colors.white),
), onPressed: () => Navigator.pop(context),
onPressed: () => Navigator.pop(context), ),
Expanded(
child: Column(
children: [
Text(
session.peerName,
style: AppTextStyles.bodyText2.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
4.height,
_buildCallStatusAndDuration(),
],
),
),
const SizedBox(width: 48),
],
), ),
), ),
); );
} }
Widget _buildCallStatusAndDuration() {
return Selector<ChatProvider, Duration>(
selector: (_, provider) => provider.callDuration,
builder: (context, duration, _) {
if (duration.inSeconds == 0) {
return Selector<ChatProvider, CallStatus>(
selector: (_, provider) => provider.callStatus,
builder: (context, status, _) {
String statusText;
switch (status) {
case CallStatus.outgoingRinging:
statusText = 'Calling...';
break;
case CallStatus.connecting:
statusText = 'Connecting...';
break;
default:
statusText = '';
}
return Text(
statusText,
style: AppTextStyles.bodyText.copyWith(
color: Colors.white70,
),
);
},
);
}
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.bodyText.copyWith(
color: Colors.white,
fontFeatures: [const FontFeature.tabularFigures()],
),
);
},
);
}
Widget _buildBottomControls(BuildContext context, CallSession session) { Widget _buildBottomControls(BuildContext context, CallSession session) {
final chatProvider = context.read<ChatProvider>(); final chatProvider = context.read<ChatProvider>();
return Positioned( return Positioned(
bottom: 24, bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
//for now use these colors, later we can change to use the theme colors
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( gradient: const LinearGradient(
@ -257,13 +664,57 @@ class _VideoCallPageState extends State<VideoCallPage> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( // Name with mute indicator on the right
session.peerName, Row(
style: AppTextStyles.heading2.copyWith( mainAxisAlignment: MainAxisAlignment.center,
color: Colors.white, children: [
fontWeight: FontWeight.w600, Text(
), session.peerName,
textAlign: TextAlign.center, style: AppTextStyles.heading2.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
// Mute indicator badge (top-right of name)
Selector<ChatProvider, bool>(
selector: (_, provider) => provider.isPeerMuted,
builder: (context, isPeerMuted, _) {
if (!isPeerMuted) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(left: 8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColor.red30,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.white10.withOpacity(0.3), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
'mic_disable'.toSvgAsset(
width: 14,
height: 14,
color: AppColor.white10,
),
4.width,
Text(
'Muted',
style: AppTextStyles.bodyText2.copyWith(
color: AppColor.white10,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
},
),
],
), ),
16.height, 16.height,
_buildCallStatusOrDuration(context), _buildCallStatusOrDuration(context),
@ -318,9 +769,7 @@ class _VideoCallPageState extends State<VideoCallPage> {
showBorder: false, showBorder: false,
onPressed: () { onPressed: () {
chatProvider.hangUp(); chatProvider.hangUp();
if (mounted) { // Removed Navigator.pop() - automatic listener will handle navigation
Navigator.pop(context);
}
}, },
), ),
], ],

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save