onecxi_flutter_sdk 1.0.0+1
onecxi_flutter_sdk: ^1.0.0+1 copied to clipboard
A Flutter SDK for OneCxi calling functionality with WebSocket and VoIP support
example/lib/main.dart
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:onecxi_flutter_sdk/onecxi_flutter_sdk.dart';
import 'screens/splash_screen.dart';
import 'config/app_config.dart';
import 'services/registration_state_manager.dart';
import 'services/fcm_background_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() async {
// Initialize Flutter binding first
WidgetsFlutterBinding.ensureInitialized();
// Register background message handler after binding is initialized
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
// Initialize background service BEFORE runApp (required for flutter_background_service)
// This is Android-only and will be skipped on iOS
print('๐ง [Main] Initializing background service before app starts...');
try {
await CallBackgroundService().initialize();
print('โ
[Main] Background service pre-initialized successfully');
} catch (e) {
print(
'โ ๏ธ [Main] Background service pre-initialization failed (expected on iOS): $e');
}
runApp(const MyApp());
}
// Global navigation key for cross-screen navigation
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: AppConfig.appName,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
navigatorKey: navigatorKey, // Use global navigation key
home: const InitialScreen(),
);
}
}
class InitialScreen extends StatefulWidget {
const InitialScreen({super.key});
@override
State<InitialScreen> createState() => _InitialScreenState();
}
class _InitialScreenState extends State<InitialScreen>
implements CallProgressCallback {
final OneCxiSdk _sdk = OneCxiSdk();
@override
void initState() {
super.initState();
_checkForSkipSplash();
}
Future<void> _checkForSkipSplash() async {
try {
print('๐ [InitialScreen] Checking for skip_splash flag...');
// Check if there's a skip_splash flag (from Accept button)
final prefs = await SharedPreferences.getInstance();
final skipSplash = prefs.getBool('flutter.skip_splash') ?? false;
print(
'๐ [InitialScreen] SharedPreferences flutter.skip_splash value: $skipSplash');
// Also check Android SharedPreferences as backup
final androidPrefs = await SharedPreferences.getInstance();
final androidSkipSplash = androidPrefs.getBool('skip_splash') ?? false;
print(
'๐ [InitialScreen] Android SharedPreferences skip_splash value: $androidSkipSplash');
// Use either flag
final shouldSkipSplash = skipSplash || androidSkipSplash;
print(
'๐ [InitialScreen] Final decision - should skip splash: $shouldSkipSplash');
if (shouldSkipSplash) {
print(
'๐ [InitialScreen] Skip splash flag found - going directly to main screen');
// Clear both flags
await prefs.remove('flutter.skip_splash');
await androidPrefs.remove('skip_splash');
// Initialize SDK quickly and go to main screen
await _initializeSDKForCall();
// Navigate directly to main screen
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const MyHomePage()),
);
}
} else {
print(
'๐ [InitialScreen] No skip_splash flag - going to splash screen');
// Navigate to splash screen
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const SplashScreen()),
);
}
}
} catch (e) {
print('โ [InitialScreen] Error checking skip_splash: $e');
// Fallback to splash screen
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const SplashScreen()),
);
}
}
}
Future<void> _initializeSDKForCall() async {
try {
print('๐ง [InitialScreen] Initializing SDK for call...');
// Initialize the SDK
await _sdk.initialize(
accountName: AppConfig.demoAccountName,
apiKey: AppConfig.demoApiKey,
serverName: AppConfig.demoServerName,
);
print('โ
[InitialScreen] SDK initialized for call');
// Request microphone permissions for call
print('๐ง [InitialScreen] Requesting microphone permissions...');
final hasPermissions = await _sdk.requestPermissions();
print(
'๐ง [InitialScreen] Microphone permissions granted: $hasPermissions');
// Set up callback for call status updates
print('๐ง [InitialScreen] Setting up call progress callback...');
_sdk.setCallProgressCallback(this);
// Check for call accept action and handle it immediately
await _handleCallAcceptAction();
} catch (e) {
print('โ [InitialScreen] SDK initialization failed: $e');
}
}
Future<void> _handleCallAcceptAction() async {
try {
print('๐ [InitialScreen] Checking for call accept action...');
// Use the SDK method to check for accept action
final acceptAction = await _sdk.checkCallAcceptAction();
print('๐ [InitialScreen] SDK accept action result: $acceptAction');
if (acceptAction != null) {
if (acceptAction['action'] == 'accept') {
print('๐ [InitialScreen] Call ACCEPTED - auto-starting call');
// Get call data from the result
final callFrom = acceptAction['callFrom'] ?? '';
final callTo = acceptAction['callTo'] ?? '';
final callType = acceptAction['callType'] ?? 'OUTBOUND';
final registeredNumber = acceptAction['registeredNumber'] ?? '';
print(
'๐ [InitialScreen] Call data - From: $callFrom, To: $callTo, Type: $callType');
// Clear the pending call data first to prevent showing UI again
await _sdk.clearPendingIncomingCall();
// Clear the accept action
await _sdk.clearCallAcceptAction();
// Start the call directly without showing UI
await _startCallDirectly({
'callFrom': callFrom,
'callTo': callTo,
'callType': callType,
'registeredNumber': registeredNumber,
});
print('โ
[InitialScreen] Call auto-started successfully');
} else if (acceptAction['action'] == 'notification_tap') {
print(
'๐ฑ [InitialScreen] Notification tapped - opening app with call state');
// Clear the action
await _sdk.clearCallAcceptAction();
// The app will open normally and refreshCallStatus() will sync the call state
print('โ
[InitialScreen] App will open with proper call state');
}
} else {
print('๐ [InitialScreen] No call accept action found');
}
} catch (e) {
print('โ [InitialScreen] Error handling call accept action: $e');
// On iOS, this is expected to fail since it's Android-specific
}
}
Future<void> _startCallDirectly(Map<String, dynamic> callData) async {
try {
print('๐ [InitialScreen] Starting call directly without UI...');
// Extract call information
final callFrom = callData['callFrom'] ?? '';
final callTo = callData['callTo'] ?? '';
final callType = callData['callType'] ?? 'OUTBOUND';
final registeredNumber = callData['registeredNumber'] ?? '';
print(
'๐ [InitialScreen] Call details - From: $callFrom, To: $callTo, Type: $callType');
// Start the call directly using handleCallAnswered - SAME AS FOREGROUND CALL
print(
'๐ [InitialScreen] Starting call using handleCallAnswered (same as foreground)...');
print(
'๐ [InitialScreen] Call details - From: $callFrom, To: $callTo, Type: $callType, Registered: $registeredNumber');
// Use handleCallAnswered directly - this is what foreground calls use
await _sdk.handleCallAnswered({
'callId': 'kill_state_call_${DateTime.now().millisecondsSinceEpoch}',
'callFrom': callFrom,
'callTo': callTo,
'callType': callType,
'registeredNumber': registeredNumber,
});
print(
'โ
[InitialScreen] Call started directly and will continue in background');
} catch (e) {
print('โ [InitialScreen] Error starting call directly: $e');
}
}
// CallProgressCallback implementation
@override
void onCallStatusChanged(CallStatus status) {
print(
'๐ฑ [InitialScreen] onCallStatusChanged called - isCallOngoing: ${status.isCallOngoing}');
}
@override
void onCallStarted(String callType, String did, String registeredNumber) {
print(
'๐ฑ [InitialScreen] onCallStarted called - callType: $callType, did: $did');
}
@override
void onCallEnded(String callType, String did) {
print(
'๐ฑ [InitialScreen] onCallEnded called - callType: $callType, did: $did');
// Call ended - no specific action needed for initial screen
}
@override
void onCallError(String error, String? callType) {
print(
'๐ฑ [InitialScreen] onCallError called - error: $error, callType: $callType');
}
@override
void onWebSocketStatusChanged(bool isConnected) {
print(
'๐ฑ [InitialScreen] onWebSocketStatusChanged called - isConnected: $isConnected');
}
@override
void onCallDeclined(String callType, String callFrom) {
print(
'๐ฑ [InitialScreen] onCallDeclined called - callType: $callType, callFrom: $callFrom');
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage>
with WidgetsBindingObserver
implements CallProgressCallback {
final OneCxiSdk _sdk = OneCxiSdk();
bool _isInitialized = false;
bool _isCallActive = false;
bool _isMuted = false;
bool _isOnHold = false;
bool _isSpeakerOn = false;
// Call timer variables
Timer? _callTimer;
int _callDurationSeconds = 0;
String _formattedCallDuration = '00:00';
// Controllers for app-to-app calls only
final TextEditingController _appToAppNumberController =
TextEditingController();
final TextEditingController _registeredNumberController =
TextEditingController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_setupSDK();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_callTimer?.cancel();
_appToAppNumberController.dispose();
_registeredNumberController.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
print('๐ฑ [MyHomePage] App resumed - syncing call status...');
_syncCallStatus();
}
}
Future<void> _syncCallStatus() async {
try {
await _sdk.refreshCallStatus();
final currentStatus = _sdk.getCallStatus();
print(
'๐ฑ [MyHomePage] Synced call status: isCallOngoing=${currentStatus.isCallOngoing}');
setState(() {
_isCallActive = currentStatus.isCallOngoing;
_isMuted = currentStatus.isMuted;
_isOnHold = currentStatus.isOnHold;
_isSpeakerOn = currentStatus.isSpeakerOn;
});
// Start timer if call is active but timer is not running
if (_isCallActive && _callTimer == null) {
print(
'๐ฑ [MyHomePage] Call active but timer not running - starting timer');
_startCallTimer();
}
// Stop timer if call is not active but timer is running
if (!_isCallActive && _callTimer != null) {
print(
'๐ฑ [MyHomePage] Call not active but timer running - stopping timer');
_stopCallTimer();
}
} catch (e) {
print('โ ๏ธ [MyHomePage] Error syncing call status: $e');
}
}
Future<void> _setupSDK() async {
try {
print('๐ฑ [MyHomePage] Setting up SDK and callback...');
// SDK is already initialized from splash screen, just set callback and permissions
_sdk.setCallProgressCallback(this);
final hasPermissions = await _sdk.requestPermissions();
// Check if there's already an active call (from kill state flow)
print(
'๐ฑ [MyHomePage] Refreshing call status to sync with kill state call...');
await _sdk.refreshCallStatus();
// Get the current call status to update UI
final currentStatus = _sdk.getCallStatus();
print(
'๐ฑ [MyHomePage] Current call status: isCallOngoing=${currentStatus.isCallOngoing}');
setState(() {
_isInitialized = true;
_isCallActive = currentStatus.isCallOngoing;
_isMuted = currentStatus.isMuted;
_isOnHold = currentStatus.isOnHold;
_isSpeakerOn = currentStatus.isSpeakerOn;
});
// If there's an active call, start the timer
if (_isCallActive && _callTimer == null) {
print('๐ฑ [MyHomePage] Active call detected, starting timer...');
_startCallTimer();
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(hasPermissions
? AppConfig.successSdkReadyWithPermissions
: AppConfig.successSdkReadyWithoutPermissions)),
);
}
} catch (e) {
print('โ [MyHomePage] SDK setup error: $e');
// Don't show error to user if it's just Android-specific methods failing on iOS
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(content: Text('${AppConfig.errorSdkSetupFailure}: $e')));
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(AppConfig.appTitle),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(AppConfig.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildStatusCard(),
const SizedBox(height: AppConfig.defaultPadding),
_buildAppToAppInput(),
const SizedBox(height: AppConfig.defaultPadding),
_buildCallButtons(),
if (_isCallActive) ...[
const SizedBox(height: AppConfig.defaultPadding),
_buildCallControls(),
],
const SizedBox(height: AppConfig.defaultPadding),
_buildIncomingCallButton(),
],
),
),
);
}
Widget _buildStatusCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(AppConfig.defaultPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('Ozonetel',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.blue,
)),
const SizedBox(width: 8),
const _StatusIndicator(),
],
),
const SizedBox(height: 8),
Text('Ozonetel OneCXI App',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
)),
const SizedBox(height: AppConfig.defaultPadding),
_CallStatusText(isCallActive: _isCallActive),
const SizedBox(height: 8),
_CallDurationText(duration: _formattedCallDuration),
const SizedBox(height: 8),
_StatusInfoList(
isInitialized: _isInitialized,
isCallActive: _isCallActive,
isMuted: _isMuted,
isOnHold: _isOnHold,
isSpeakerOn: _isSpeakerOn,
),
],
),
),
);
}
Widget _buildAppToAppInput() {
return Column(
children: [
TextField(
controller: _appToAppNumberController,
decoration: const InputDecoration(
labelText: AppConfig.uiTextAppToAppNumber,
border: OutlineInputBorder(),
hintText: AppConfig.defaultAppToAppNumberPlaceholder,
),
),
const SizedBox(height: 8),
TextField(
controller: _registeredNumberController,
decoration: const InputDecoration(
labelText: AppConfig.uiTextRegisteredNumber,
border: OutlineInputBorder(),
hintText: AppConfig.defaultRegisteredNumberPlaceholder,
),
),
],
);
}
Widget _buildCallButtons() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Call Button
Column(
children: [
Container(
width: AppConfig.callButtonSize,
height: AppConfig.callButtonSize,
decoration: BoxDecoration(
color: _isCallActive ? Colors.grey : Colors.green,
borderRadius:
BorderRadius.circular(AppConfig.callButtonSize / 2),
boxShadow: [
BoxShadow(
color: (_isCallActive ? Colors.grey : Colors.green)
.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: IconButton(
onPressed: _isCallActive ? null : _startCall,
icon: const Icon(Icons.call,
color: Colors.white, size: AppConfig.callButtonIconSize),
iconSize: AppConfig.callButtonIconSize,
),
),
const SizedBox(height: 8),
Text(
AppConfig.uiTextCall,
style: TextStyle(
color: _isCallActive ? Colors.grey : Colors.green,
fontWeight: FontWeight.bold,
),
),
],
),
// HangUp Button
Column(
children: [
Container(
width: AppConfig.callButtonSize,
height: AppConfig.callButtonSize,
decoration: BoxDecoration(
color: _isCallActive ? Colors.red : Colors.grey,
borderRadius:
BorderRadius.circular(AppConfig.callButtonSize / 2),
boxShadow: [
BoxShadow(
color: (_isCallActive ? Colors.red : Colors.grey)
.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: IconButton(
onPressed: _isCallActive ? _endCall : null,
icon: const Icon(Icons.call_end,
color: Colors.white, size: AppConfig.callButtonIconSize),
iconSize: AppConfig.callButtonIconSize,
),
),
const SizedBox(height: 8),
Text(
AppConfig.uiTextHangup,
style: TextStyle(
color: _isCallActive ? Colors.red : Colors.grey,
fontWeight: FontWeight.bold,
),
),
],
),
],
);
}
Widget _buildCallControls() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildControlButton(
icon: Icons.volume_up,
label: AppConfig.uiTextSpeaker,
isActive: _isSpeakerOn,
onPressed: () => _toggleSpeaker(),
),
_buildControlButton(
icon: Icons.mic_off,
label: AppConfig.uiTextMute,
isActive: _isMuted,
onPressed: () => _toggleMute(),
),
_buildControlButton(
icon: Icons.pause,
label: AppConfig.uiTextHold,
isActive: _isOnHold,
onPressed: () => _toggleHold(),
),
_buildControlButton(
icon: Icons.dialpad,
label: AppConfig.uiTextDialPad,
isActive: false,
onPressed: () => _showDialPad(),
),
],
);
}
Widget _buildControlButton({
required IconData icon,
required String label,
required bool isActive,
required VoidCallback onPressed,
}) {
return Column(
children: [
Container(
width: AppConfig.controlButtonSize,
height: AppConfig.controlButtonSize,
decoration: BoxDecoration(
color: isActive ? Colors.blue : Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(AppConfig.defaultBorderRadius),
border: Border.all(
color:
isActive ? Colors.blue : Colors.grey.withValues(alpha: 0.3),
width: 1,
),
),
child: IconButton(
onPressed: onPressed,
icon: Icon(
icon,
color: isActive ? Colors.white : Colors.grey[600],
size: AppConfig.controlButtonIconSize,
),
iconSize: AppConfig.controlButtonIconSize,
),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
color: isActive ? Colors.blue : Colors.grey[600],
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
],
);
}
Widget _buildIncomingCallButton() {
return Container(
width: double.infinity,
height: 56,
margin: const EdgeInsets.symmetric(horizontal: AppConfig.defaultPadding),
child: ElevatedButton(
onPressed: _isCallActive ? null : _simulateIncomingCall,
style: ElevatedButton.styleFrom(
backgroundColor: _isCallActive ? Colors.grey : Colors.orange,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConfig.defaultBorderRadius),
),
elevation: 2,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_isCallActive ? Icons.call_end : Icons.call_received,
size: 20,
),
const SizedBox(width: 8),
Text(
_isCallActive
? AppConfig.uiTextCallActive
: AppConfig.uiTextSimulateIncomingCall,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
// Call Methods
Future<void> _startCall() async {
print('๐ [Main] _startCall called');
print('๐ [Main] Platform: ${Platform.isIOS ? "iOS" : "Android"}');
try {
// Check permissions before starting call
print('๐ [Main] Checking permissions...');
final hasPermissions = await _sdk.checkForPermissions();
print('๐ [Main] Permissions result: $hasPermissions');
if (!hasPermissions) {
_showError(AppConfig.errorMissingMicrophonePermission);
return;
}
final appToAppNumber = _appToAppNumberController.text.trim();
// Get the actual registered number from registration service
final registrationStateManager = RegistrationStateManager();
final actualRegisteredNumber =
await registrationStateManager.getUserNumber();
final registeredNumber =
actualRegisteredNumber ?? AppConfig.defaultRegisteredNumber;
print('๐ [Main] Using registered number: $registeredNumber');
if (appToAppNumber.isEmpty) {
// Empty field = Inbound call (like iOS reference)
print('๐ [Main] Starting inbound call with DID: ${AppConfig.demoDid}');
await _sdk.startInBoundCall(
did: AppConfig.demoDid,
registeredNumber: registeredNumber,
);
print('๐ [Main] Inbound call started successfully');
} else {
// Filled field = App-to-App call (like iOS reference)
// Prevent self-calling
if (appToAppNumber == registeredNumber) {
print(
'โ [Main] Cannot call yourself! Please enter a different number.');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Cannot call yourself! Please enter a different number.'),
backgroundColor: Colors.red,
),
);
return;
}
print('๐ [Main] Starting app-to-app call to: $appToAppNumber');
print('๐ [Main] From registered number: $registeredNumber');
print(
'๐ [Main] Validation check - appToAppNumber: "$appToAppNumber", registeredNumber: "$registeredNumber"');
print(
'๐ [Main] Are they equal? ${appToAppNumber == registeredNumber}');
await _sdk.startAppToAppInboundCall(
inputNumber: appToAppNumber,
registeredNumber: registeredNumber,
);
print('๐ [Main] App-to-app call started successfully');
}
} catch (e) {
print('๐ [Main] Call failed: $e');
_showError('Failed to start call: $e');
}
}
Future<void> _endCall() async {
try {
await _sdk.endCall();
} catch (e) {
_showError('Failed to end call: $e');
}
}
Future<void> _simulateIncomingCall() async {
try {
// Get the actual registered number from registration service (same as _startCall)
final registrationStateManager = RegistrationStateManager();
final actualRegisteredNumber =
await registrationStateManager.getUserNumber();
final registeredNumber =
actualRegisteredNumber ?? AppConfig.defaultRegisteredNumber;
print(
'๐งช [Main] Using registered number for simulation: $registeredNumber');
print('๐งช [Main] Controller text: "${_registeredNumberController.text}"');
print(
'๐งช [Main] Controller text isEmpty: ${_registeredNumberController.text.isEmpty}');
// Simulate a VoIP push notification payload
final pushPayload = {
'callFrom': AppConfig.demoDid,
'callTo': registeredNumber, // Always use actual registered number
'callType': 'Outbound',
'callerName': 'Test Caller',
'registeredNumber':
registeredNumber, // Always use actual registered number
'callId': 'call_${DateTime.now().millisecondsSinceEpoch}_test',
'environment': 'DEVELOPMENT',
'timestamp': DateTime.now().millisecondsSinceEpoch,
'platform': 'ios',
'appType': 'Flutter',
'event': 'initial',
};
print('๐งช [Main] Simulating incoming call with payload: $pushPayload');
// Use the new flutter_callkit_incoming integration
await _sdk.handleIncomingCallWithUI(pushPayload);
print('โ
[Main] Incoming call simulation completed');
} catch (e) {
print('โ [Main] Failed to simulate incoming call: $e');
_showError('Failed to simulate incoming call: $e');
}
}
void _toggleSpeaker() async {
// Optimistic UI update for better responsiveness
setState(() {
_isSpeakerOn = !_isSpeakerOn;
});
try {
await _sdk.setSpeaker(_isSpeakerOn);
} catch (e) {
// Revert on error
setState(() {
_isSpeakerOn = !_isSpeakerOn;
});
_showError('${AppConfig.errorSpeakerToggleFailure}: $e');
}
}
void _toggleMute() async {
// Optimistic UI update for better responsiveness
setState(() {
_isMuted = !_isMuted;
});
try {
await _sdk.muteCall(_isMuted);
} catch (e) {
// Revert on error
setState(() {
_isMuted = !_isMuted;
});
_showError('${AppConfig.errorMuteToggleFailure}: $e');
}
}
void _toggleHold() async {
// Optimistic UI update for better responsiveness
setState(() {
_isOnHold = !_isOnHold;
});
try {
await _sdk.holdCall(_isOnHold);
} catch (e) {
// Revert on error
setState(() {
_isOnHold = !_isOnHold;
});
_showError('${AppConfig.errorHoldToggleFailure}: $e');
}
}
void _showDialPad() {
// TODO: Implement dial pad
_showError(AppConfig.errorDialPadNotImplemented);
}
void _showError(String message) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
}
// CallProgressCallback implementation
@override
void onCallStatusChanged(CallStatus status) {
print(
'๐ฑ [Main] onCallStatusChanged called - isCallOngoing: ${status.isCallOngoing}');
// Stop timer if call becomes inactive
if (!status.isCallOngoing && _isCallActive) {
print('๐ฑ [Main] Call became inactive - stopping timer and resetting UI');
_stopCallTimer();
// Also reset all UI states immediately
setState(() {
_isCallActive = false;
_isMuted = false;
_isOnHold = false;
_isSpeakerOn = false;
});
print('๐ฑ [Main] UI reset - Call Active: false, Timer: 00:00');
return; // Exit early to avoid setting state twice
}
// Update state first
setState(() {
_isCallActive = status.isCallOngoing;
_isMuted = status.isMuted;
_isOnHold = status.isOnHold;
_isSpeakerOn = status.isSpeakerOn;
});
print(
'๐ฑ [Main] Flutter app state updated - _isCallActive: $_isCallActive');
// Start timer if call becomes active and timer is not running
if (status.isCallOngoing && _callTimer == null) {
print('๐ฑ [Main] Call became active - starting timer');
_startCallTimer();
}
}
@override
void onCallStarted(String callType, String did, String registeredNumber) {
_startCallTimer(); // Start the timer when call begins
}
@override
void onCallEnded(String callType, String did) {
print('๐ [Main] onCallEnded called - callType: $callType, did: $did');
// Stop the timer FIRST
_stopCallTimer();
print('๐ [Main] Timer stopped in onCallEnded');
// Update UI state to reflect call ended
setState(() {
_isCallActive = false;
_isMuted = false;
_isOnHold = false;
_isSpeakerOn = false;
});
print('๐ฑ [Main] Call ended - UI state reset');
}
@override
void onCallError(String error, String? callType) {
_stopCallTimer(); // Stop timer on error to reset the display
_showError('Call error: $error');
}
@override
void onWebSocketStatusChanged(bool isConnected) {
// WebSocket status changed
}
@override
void onCallDeclined(String callType, String callFrom) {
print('๐ [Main] Call declined: $callType from $callFrom');
// Handle call declined
}
// Call timer methods
void _startCallTimer() {
// Don't start timer if call is not active
if (!_isCallActive) {
print('๐ [MyHomePage] Not starting timer - call not active');
return;
}
// Don't start timer if one is already running
if (_callTimer != null) {
print('๐ [MyHomePage] Timer already running - not starting new one');
return;
}
print('๐ [MyHomePage] Starting new call timer');
_callDurationSeconds = 0;
_formattedCallDuration = AppConfig.defaultCallDurationFormat;
_callTimer = Timer.periodic(
const Duration(seconds: AppConfig.callTimerUpdateInterval),
(timer) async {
// Check if call is still active locally
if (!_isCallActive) {
print('๐ [MyHomePage] Call not active - stopping timer');
_stopCallTimer();
return;
}
// Periodically check native call status to sync UI
if (Platform.isAndroid) {
try {
await _sdk.refreshCallStatus();
final currentStatus = _sdk.getCallStatus();
// If native says call is not active but UI shows active, stop timer
if (!currentStatus.isCallOngoing && _isCallActive) {
print('๐ [MyHomePage] Native call ended - stopping timer');
_stopCallTimer();
return;
}
} catch (e) {
print('โ ๏ธ [MyHomePage] Error checking native status: $e');
}
}
// Increment timer
_callDurationSeconds++;
_formattedCallDuration = _formatDuration(_callDurationSeconds);
print('โฑ๏ธ [MyHomePage] Timer tick - Duration: $_formattedCallDuration');
setState(() {
// Trigger UI update
});
});
}
void _stopCallTimer() {
print('๐ [MyHomePage] _stopCallTimer called - timer: $_callTimer');
if (_callTimer != null) {
_callTimer!.cancel();
_callTimer = null;
print('๐ [MyHomePage] Timer cancelled and set to null');
} else {
print('๐ [MyHomePage] Timer was already null');
}
setState(() {
_callDurationSeconds = 0;
_formattedCallDuration = AppConfig.defaultCallDurationFormat;
});
print('๐ [MyHomePage] Timer stopped - duration reset to 00:00');
}
String _formatDuration(int seconds) {
final minutes = seconds ~/ 60;
final remainingSeconds = seconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
}
}
// Optimized stateless widgets to reduce rebuilds
class _StatusIndicator extends StatelessWidget {
const _StatusIndicator();
@override
Widget build(BuildContext context) {
return Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
);
}
}
class _CallStatusText extends StatelessWidget {
final bool isCallActive;
const _CallStatusText({required this.isCallActive});
@override
Widget build(BuildContext context) {
return Text(
isCallActive
? AppConfig.uiTextCallInProgress
: AppConfig.uiTextReadyForCall,
style: Theme.of(context).textTheme.bodyLarge,
);
}
}
class _CallDurationText extends StatelessWidget {
final String duration;
const _CallDurationText({required this.duration});
@override
Widget build(BuildContext context) {
return Text(
duration,
style: Theme.of(context).textTheme.headlineSmall,
);
}
}
class _StatusInfoList extends StatelessWidget {
final bool isInitialized;
final bool isCallActive;
final bool isMuted;
final bool isOnHold;
final bool isSpeakerOn;
const _StatusInfoList({
required this.isInitialized,
required this.isCallActive,
required this.isMuted,
required this.isOnHold,
required this.isSpeakerOn,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Initialized: $isInitialized'),
Text('Call Active: $isCallActive'),
Text('Muted: $isMuted'),
Text('On Hold: $isOnHold'),
Text('Speaker: $isSpeakerOn'),
],
);
}
}