From dab3fe3790e754d17af73dfb627ddc611924d07d Mon Sep 17 00:00:00 2001 From: Florian Griffon Date: Wed, 30 Apr 2025 11:21:55 +0000 Subject: [PATCH] callPageV2 (#52) Update: Now has contact info when making a call Working on: Contact info when receiving a call Keep dialer open when in call Implement button actions Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/52 Co-authored-by: Florian Griffon Co-committed-by: Florian Griffon --- .../icing/dialer/activities/MainActivity.kt | 38 + .../icing/dialer/services/MyInCallService.kt | 52 +- dialer/lib/features/call/call_page.dart | 672 +++++++++++------- .../contacts/widgets/contact_modal.dart | 8 +- dialer/lib/features/history/history_page.dart | 7 +- dialer/lib/services/call_service.dart | 313 +++++++- 6 files changed, 797 insertions(+), 293 deletions(-) diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt index 5f5b29a..ee194a2 100644 --- a/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt @@ -153,6 +153,44 @@ class MainActivity : FlutterActivity() { } result.success(true) } + "getCallState" -> { + val stateStr = when (MyInCallService.currentCall?.state) { + android.telecom.Call.STATE_ACTIVE -> "active" + android.telecom.Call.STATE_RINGING -> "ringing" + android.telecom.Call.STATE_DIALING -> "dialing" + android.telecom.Call.STATE_DISCONNECTED -> "disconnected" + android.telecom.Call.STATE_DISCONNECTING -> "disconnecting" + else -> "unknown" + } + Log.d(TAG, "getCallState called, returning: $stateStr") + result.success(stateStr) + } + "muteCall" -> { + val mute = call.argument("mute") ?: false + val success = MyInCallService.currentCall?.let { + MyInCallService.toggleMute(mute) + } ?: false + if (success) { + Log.d(TAG, "Mute call set to $mute") + result.success(mapOf("status" to "success")) + } else { + Log.w(TAG, "No active call or failed to mute") + result.error("MUTE_FAILED", "No active call or failed to mute", null) + } + } + "speakerCall" -> { + val speaker = call.argument("speaker") ?: false + val success = MyInCallService.currentCall?.let { + MyInCallService.toggleSpeaker(speaker) + } ?: false + if (success) { + Log.d(TAG, "Speaker call set to $speaker") + result.success(mapOf("status" to "success")) + } else { + Log.w(TAG, "No active call or failed to set speaker") + result.error("SPEAKER_FAILED", "No active call or failed to set speaker", null) + } + } else -> result.notImplemented() } } diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt index b5a4c8a..5469c6d 100644 --- a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt @@ -6,9 +6,11 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.media.AudioManager import android.os.Build import android.telecom.Call import android.telecom.InCallService +import android.telecom.CallAudioState import android.util.Log import androidx.core.app.NotificationCompat import com.icing.dialer.activities.MainActivity @@ -22,6 +24,34 @@ class MyInCallService : InCallService() { private const val NOTIFICATION_CHANNEL_ID = "incoming_call_channel" private const val NOTIFICATION_ID = 1 var wasPhoneLocked: Boolean = false + private var instance: MyInCallService? = null + + fun toggleMute(mute: Boolean): Boolean { + return instance?.let { service -> + try { + service.setMuted(mute) + Log.d(TAG, "Requested to set call mute state to $mute") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to set mute state: $e") + false + } + } ?: false + } + + fun toggleSpeaker(speaker: Boolean): Boolean { + return instance?.let { service -> + try { + val route = if (speaker) CallAudioState.ROUTE_SPEAKER else CallAudioState.ROUTE_EARPIECE + service.setAudioRoute(route) + Log.d(TAG, "Requested to set audio route to $route") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to set audio route: $e") + false + } + } ?: false + } } private val callCallback = object : Call.Callback() { @@ -60,6 +90,7 @@ class MyInCallService : InCallService() { override fun onCallAdded(call: Call) { super.onCallAdded(call) + instance = this currentCall = call val stateStr = when (call.state) { Call.STATE_DIALING -> "dialing" @@ -79,6 +110,16 @@ class MyInCallService : InCallService() { showIncomingCallScreen(call.details.handle.toString().replace("tel:", "")) } call.registerCallback(callCallback) + if (callAudioState != null) { + val audioState = callAudioState + channel?.invokeMethod("audioStateChanged", mapOf( + "route" to audioState.route, + "muted" to audioState.isMuted, + "speaker" to (audioState.route == CallAudioState.ROUTE_SPEAKER) + )) + } else { + Log.w("MyInCallService", "callAudioState is null in onCallAdded") + } } override fun onCallRemoved(call: Call) { @@ -90,13 +131,18 @@ class MyInCallService : InCallService() { "wasPhoneLocked" to wasPhoneLocked )) currentCall = null + instance = null cancelNotification() } - override fun onCallAudioStateChanged(state: android.telecom.CallAudioState) { + override fun onCallAudioStateChanged(state: CallAudioState) { super.onCallAudioStateChanged(state) - Log.d(TAG, "Audio state changed: route=${state.route}") - channel?.invokeMethod("audioStateChanged", mapOf("route" to state.route)) + Log.d(TAG, "Audio state changed: route=${state.route}, muted=${state.isMuted}") + channel?.invokeMethod("audioStateChanged", mapOf( + "route" to state.route, + "muted" to state.isMuted, + "speaker" to (state.route == CallAudioState.ROUTE_SPEAKER) + )) } private fun showIncomingCallScreen(phoneNumber: String) { diff --git a/dialer/lib/features/call/call_page.dart b/dialer/lib/features/call/call_page.dart index 368ffde..0d7cf48 100644 --- a/dialer/lib/features/call/call_page.dart +++ b/dialer/lib/features/call/call_page.dart @@ -1,5 +1,7 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:dialer/services/call_service.dart'; import 'package:dialer/services/obfuscate_service.dart'; import 'package:dialer/widgets/username_color_generator.dart'; @@ -24,10 +26,103 @@ class _CallPageState extends State { final ObfuscateService _obfuscateService = ObfuscateService(); final CallService _callService = CallService(); bool isMuted = false; - bool isSpeakerOn = false; + bool isSpeaker = false; bool isKeypadVisible = false; bool icingProtocolOk = true; String _typedDigits = ""; + Timer? _callTimer; + int _callSeconds = 0; + String _callStatus = "Calling..."; + StreamSubscription? _callStateSubscription; + StreamSubscription>? _audioStateSubscription; + + bool get isNumberUnknown => widget.displayName == widget.phoneNumber; + + @override + void initState() { + super.initState(); + _checkInitialCallState(); + _listenToCallState(); + _listenToAudioState(); + _setInitialAudioState(); + } + + @override + void dispose() { + _callTimer?.cancel(); + _callStateSubscription?.cancel(); + _audioStateSubscription?.cancel(); + super.dispose(); + } + + void _setInitialAudioState() { + final initialAudioState = _callService.currentAudioState; + if (initialAudioState != null) { + setState(() { + isMuted = initialAudioState['muted'] ?? false; + isSpeaker = initialAudioState['speaker'] ?? false; + }); + } + } + + void _checkInitialCallState() async { + try { + final state = await _callService.getCallState(); + print('CallPage: Initial call state: $state'); + if (mounted && state == "active") { + setState(() { + _callStatus = "00:00"; + _startCallTimer(); + }); + } + } catch (e) { + print('CallPage: Error checking initial state: $e'); + } + } + + void _listenToCallState() { + _callStateSubscription = _callService.callStateStream.listen((state) { + print('CallPage: Call state changed to $state'); + if (mounted) { + setState(() { + if (state == "active") { + _callStatus = "00:00"; + _startCallTimer(); + } else if (state == "disconnected" || state == "disconnecting") { + _callTimer?.cancel(); + _callStatus = "Call Ended"; + } else { + _callStatus = "Calling..."; + } + }); + } + }); + } + + void _listenToAudioState() { + _audioStateSubscription = _callService.audioStateStream.listen((state) { + if (mounted) { + setState(() { + isMuted = state['muted'] ?? isMuted; + isSpeaker = state['speaker'] ?? isSpeaker; + }); + } + }); + } + + void _startCallTimer() { + _callTimer?.cancel(); + _callTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (mounted) { + setState(() { + _callSeconds++; + final minutes = (_callSeconds ~/ 60).toString().padLeft(2, '0'); + final seconds = (_callSeconds % 60).toString().padLeft(2, '0'); + _callStatus = '$minutes:$seconds'; + }); + } + }); + } void _addDigit(String digit) { setState(() { @@ -35,16 +130,48 @@ class _CallPageState extends State { }); } - void _toggleMute() { - setState(() { - isMuted = !isMuted; - }); + void _toggleMute() async { + try { + print('CallPage: Toggling mute, current state: $isMuted'); + final result = await _callService.muteCall(context, mute: !isMuted); + print('CallPage: Mute call result: $result'); + if (mounted && result['status'] != 'success') { + print('CallPage: Failed to toggle mute: ${result['message']}'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to toggle mute: ${result['message']}')), + ); + } + } catch (e) { + print('CallPage: Error toggling mute: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error toggling mute: $e')), + ); + } + } } - void _toggleSpeaker() { - setState(() { - isSpeakerOn = !isSpeakerOn; - }); + Future _toggleSpeaker() async { + try { + print('CallPage: Toggling speaker, current state: $isSpeaker'); + final result = + await _callService.speakerCall(context, speaker: !isSpeaker); + print('CallPage: Speaker call result: $result'); + if (result['status'] != 'success') { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to toggle speaker: ${result['message']}')), + ); + } + } catch (e) { + print('CallPage: Error toggling speaker: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error toggling speaker: $e')), + ); + } + } } void _toggleKeypad() { @@ -61,15 +188,32 @@ class _CallPageState extends State { void _hangUp() async { try { + print('CallPage: Initiating hangUp'); final result = await _callService.hangUpCall(context); print('CallPage: Hang up result: $result'); - if (result["status"] == "ended" && mounted && Navigator.canPop(context)) { - Navigator.pop(context); - } } catch (e) { - print("CallPage: Error hanging up: $e"); + print('CallPage: Error hanging up: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Error hanging up: $e")), + ); + } + } + } + + void _addContact() async { + if (await FlutterContacts.requestPermission()) { + final newContact = Contact()..phones = [Phone(widget.phoneNumber)]; + final updatedContact = + await FlutterContacts.openExternalInsert(newContact); + if (updatedContact != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Contact added successfully!')), + ); + } + } else { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Error hanging up: $e")), + SnackBar(content: Text('Permission denied for contacts')), ); } } @@ -80,269 +224,301 @@ class _CallPageState extends State { final double nameFontSize = isKeypadVisible ? 24.0 : 24.0; final double statusFontSize = isKeypadVisible ? 16.0 : 16.0; - return Scaffold( - body: Container( - color: Colors.black, - child: SafeArea( - child: Column( - children: [ - Container( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(height: 35), - ObfuscatedAvatar( - imageBytes: widget.thumbnail, - radius: avatarRadius, - backgroundColor: - generateColorFromName(widget.displayName), - fallbackInitial: widget.displayName, - ), - const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.center, + print( + 'CallPage: Building UI, _callStatus: $_callStatus, route: ${ModalRoute.of(context)?.settings.name ?? "unknown"}'); + return PopScope( + canPop: + _callStatus == "Call Ended", // Allow navigation only if call ended + child: Scaffold( + body: Container( + color: Colors.black, + child: SafeArea( + child: Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Icon( - icingProtocolOk ? Icons.lock : Icons.lock_open, - color: icingProtocolOk ? Colors.green : Colors.red, - size: 16, + const SizedBox(height: 35), + ObfuscatedAvatar( + imageBytes: widget.thumbnail, + radius: avatarRadius, + backgroundColor: + generateColorFromName(widget.displayName), + fallbackInitial: widget.displayName, ), - const SizedBox(width: 4), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icingProtocolOk ? Icons.lock : Icons.lock_open, + color: + icingProtocolOk ? Colors.green : Colors.red, + size: 16, + ), + const SizedBox(width: 4), + Text( + 'Icing protocol: ${icingProtocolOk ? "ok" : "ko"}', + style: TextStyle( + color: + icingProtocolOk ? Colors.green : Colors.red, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 4), Text( - 'Icing protocol: ${icingProtocolOk ? "ok" : "ko"}', + _obfuscateService.obfuscateData(widget.displayName), style: TextStyle( - color: icingProtocolOk ? Colors.green : Colors.red, - fontSize: 12, + fontSize: nameFontSize, + color: Colors.white, fontWeight: FontWeight.bold, ), ), + Text( + widget.phoneNumber, + style: TextStyle( + fontSize: statusFontSize, color: Colors.white70), + ), + Text( + _callStatus, + style: TextStyle( + fontSize: statusFontSize, color: Colors.white70), + ), ], ), - const SizedBox(height: 4), - Text( - _obfuscateService.obfuscateData(widget.displayName), - style: TextStyle( - fontSize: nameFontSize, - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - Text( - widget.phoneNumber, - style: TextStyle( - fontSize: statusFontSize, color: Colors.white70), - ), - Text( - 'Calling...', - style: TextStyle( - fontSize: statusFontSize, color: Colors.white70), - ), - ], - ), - ), - Expanded( - child: Column( - children: [ - if (isKeypadVisible) ...[ - const Spacer(flex: 2), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - _typedDigits, - maxLines: 1, - textAlign: TextAlign.right, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 24, - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - IconButton( - padding: EdgeInsets.zero, - onPressed: _toggleKeypad, - icon: - const Icon(Icons.close, color: Colors.white), - ), - ], - ), - ), - Container( - height: MediaQuery.of(context).size.height * 0.35, - margin: const EdgeInsets.symmetric(horizontal: 20), - child: GridView.count( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - crossAxisCount: 3, - childAspectRatio: 1.3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - children: List.generate(12, (index) { - String label; - if (index < 9) { - label = '${index + 1}'; - } else if (index == 9) { - label = '*'; - } else if (index == 10) { - label = '0'; - } else { - label = '#'; - } - return GestureDetector( - onTap: () => _addDigit(label), - child: Container( - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.transparent, - ), - child: Center( + ), + Expanded( + child: Column( + children: [ + if (isKeypadVisible) ...[ + const Spacer(flex: 2), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 20.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( child: Text( - label, + _typedDigits, + maxLines: 1, + textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 32, color: Colors.white), + fontSize: 24, + color: Colors.white, + fontWeight: FontWeight.bold, + ), ), ), - ), - ); - }), - ), - ), - const Spacer(flex: 1), - ] else ...[ - const Spacer(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + IconButton( + padding: EdgeInsets.zero, + onPressed: _toggleKeypad, + icon: const Icon(Icons.close, + color: Colors.white), + ), + ], + ), + ), + Container( + height: MediaQuery.of(context).size.height * 0.35, + margin: const EdgeInsets.symmetric(horizontal: 20), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + childAspectRatio: 1.3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + children: List.generate(12, (index) { + String label; + if (index < 9) { + label = '${index + 1}'; + } else if (index == 9) { + label = '*'; + } else if (index == 10) { + label = '0'; + } else { + label = '#'; + } + return GestureDetector( + onTap: () => _addDigit(label), + child: Container( + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.transparent, + ), + child: Center( + child: Text( + label, + style: const TextStyle( + fontSize: 32, color: Colors.white), + ), + ), + ), + ); + }), + ), + ), + const Spacer(flex: 1), + ] else ...[ + const Spacer(), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 32.0), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Column( - mainAxisSize: MainAxisSize.min, + Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, children: [ - IconButton( - onPressed: _toggleMute, - icon: Icon( - isMuted ? Icons.mic_off : Icons.mic, - color: Colors.white, - size: 32, - ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _toggleMute, + icon: Icon( + isMuted ? Icons.mic_off : Icons.mic, + color: isMuted + ? Colors.amber + : Colors.white, + size: 32, + ), + ), + Text( + isMuted ? 'Unmute' : 'Mute', + style: const TextStyle( + color: Colors.white, + fontSize: 14), + ), + ], ), - Text( - isMuted ? 'Unmute' : 'Mute', - style: const TextStyle( - color: Colors.white, fontSize: 14), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _toggleKeypad, + icon: const Icon( + Icons.dialpad, + color: Colors.white, + size: 32, + ), + ), + const Text( + 'Keypad', + style: TextStyle( + color: Colors.white, + fontSize: 14), + ), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _toggleSpeaker, + icon: Icon( + isSpeaker + ? Icons.volume_up + : Icons.volume_off, + color: isSpeaker + ? Colors.amber + : Colors.white, + size: 32, + ), + ), + const Text( + 'Speaker', + style: TextStyle( + color: Colors.white, + fontSize: 14), + ), + ], ), ], ), - Column( - mainAxisSize: MainAxisSize.min, + const SizedBox(height: 20), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, children: [ - IconButton( - onPressed: _toggleKeypad, - icon: const Icon(Icons.dialpad, - color: Colors.white, size: 32), - ), - const Text( - 'Keypad', - style: TextStyle( - color: Colors.white, fontSize: 14), - ), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: _toggleSpeaker, - icon: Icon( - isSpeakerOn - ? Icons.volume_up - : Icons.volume_off, - color: isSpeakerOn - ? Colors.amber - : Colors.white, - size: 32, + if (isNumberUnknown) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _addContact, + icon: const Icon( + Icons.person_add, + color: Colors.white, + size: 32, + ), + ), + const Text( + 'Add Contact', + style: TextStyle( + color: Colors.white, + fontSize: 14), + ), + ], ), - ), - const Text( - 'Speaker', - style: TextStyle( - color: Colors.white, fontSize: 14), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () {}, + icon: const Icon( + Icons.sim_card, + color: Colors.white, + size: 32, + ), + ), + const Text( + 'Change SIM', + style: TextStyle( + color: Colors.white, + fontSize: 14), + ), + ], ), ], ), ], ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () {}, - icon: const Icon(Icons.person_add, - color: Colors.white, size: 32), - ), - const Text('Add Contact', - style: TextStyle( - color: Colors.white, fontSize: 14)), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () {}, - icon: const Icon(Icons.sim_card, - color: Colors.white, size: 32), - ), - const Text('Change SIM', - style: TextStyle( - color: Colors.white, fontSize: 14)), - ], - ), - ], - ), - ], - ), - ), - const Spacer(flex: 3), - ], - ], - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: GestureDetector( - onTap: _hangUp, - child: Container( - padding: const EdgeInsets.all(12), - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.call_end, - color: Colors.white, - size: 32, + ), + const Spacer(flex: 3), + ], + ], ), ), - ), + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: GestureDetector( + onTap: _hangUp, + child: Container( + padding: const EdgeInsets.all(12), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.call_end, + color: Colors.white, + size: 32, + ), + ), + ), + ), + ], ), - ], + ), ), - ), - ), - ); + )); } } diff --git a/dialer/lib/features/contacts/widgets/contact_modal.dart b/dialer/lib/features/contacts/widgets/contact_modal.dart index 198f614..a73f7e6 100644 --- a/dialer/lib/features/contacts/widgets/contact_modal.dart +++ b/dialer/lib/features/contacts/widgets/contact_modal.dart @@ -267,8 +267,12 @@ class _ContactModalState extends State { ), onTap: () async { if (widget.contact.phones.isNotEmpty) { - await _callService.makeGsmCall(context, - phoneNumber: phoneNumber); + await _callService.makeGsmCall( + context, + phoneNumber: phoneNumber, + displayName: widget.contact.displayName, + thumbnail: widget.contact.thumbnail, + ); } }, ), diff --git a/dialer/lib/features/history/history_page.dart b/dialer/lib/features/history/history_page.dart index 3c5b1f1..9906908 100644 --- a/dialer/lib/features/history/history_page.dart +++ b/dialer/lib/features/history/history_page.dart @@ -425,7 +425,12 @@ class _HistoryPageState extends State icon: const Icon(Icons.phone, color: Colors.green), onPressed: () async { if (contact.phones.isNotEmpty) { - _callService.makeGsmCall(context, phoneNumber: contact.phones.first.number); + await _callService.makeGsmCall( + context, + phoneNumber: contact.phones.first.number, + displayName: contact.displayName, + thumbnail: contact.thumbnail, + ); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( diff --git a/dialer/lib/services/call_service.dart b/dialer/lib/services/call_service.dart index e03c652..d14073e 100644 --- a/dialer/lib/services/call_service.dart +++ b/dialer/lib/services/call_service.dart @@ -1,46 +1,102 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../features/call/call_page.dart'; import '../features/call/incoming_call_page.dart'; +import '../services/contact_service.dart'; class CallService { static const MethodChannel _channel = MethodChannel('call_service'); static String? currentPhoneNumber; + static String? currentDisplayName; + static Uint8List? currentThumbnail; static bool _isCallPageVisible = false; static Map? _pendingCall; static bool wasPhoneLocked = false; + static String? _activeCallNumber; + static bool _isNavigating = false; + final ContactService _contactService = ContactService(); + final _callStateController = StreamController.broadcast(); + final _audioStateController = StreamController>.broadcast(); + Map? _currentAudioState; static final GlobalKey navigatorKey = GlobalKey(); + + Stream get callStateStream => _callStateController.stream; + Stream> get audioStateStream => _audioStateController.stream; + Map? get currentAudioState => _currentAudioState; CallService() { _channel.setMethodCallHandler((call) async { - print('CallService: Handling method call: ${call.method}'); + print('CallService: Handling method call: ${call.method}, with args: ${call.arguments}'); switch (call.method) { case "callAdded": - final phoneNumber = call.arguments["callId"] as String; - final state = call.arguments["state"] as String; - currentPhoneNumber = phoneNumber.replaceFirst('tel:', ''); - print('CallService: Call added, number: $currentPhoneNumber, state: $state'); + final phoneNumber = call.arguments["callId"] as String?; + final state = call.arguments["state"] as String?; + if (phoneNumber == null || state == null) { + print('CallService: Invalid callAdded args: $call.arguments'); + return; + } + final decodedPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', '')); + print('CallService: Decoded phone number: $decodedPhoneNumber'); + if (_activeCallNumber != decodedPhoneNumber) { + currentPhoneNumber = decodedPhoneNumber; + if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + await _fetchContactInfo(decodedPhoneNumber); + } + } + print('CallService: Call added, number: $currentPhoneNumber, displayName: $currentDisplayName, state: $state'); + _callStateController.add(state); if (state == "ringing") { - _handleIncomingCall(phoneNumber); + _handleIncomingCall(decodedPhoneNumber); } else { _navigateToCallPage(); } break; case "callStateChanged": - final state = call.arguments["state"] as String; + final state = call.arguments["state"] as String?; wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; + if (state == null) { + print('CallService: Invalid callStateChanged args: $call.arguments'); + return; + } print('CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked'); + _callStateController.add(state); if (state == "disconnected" || state == "disconnecting") { _closeCallPage(); if (wasPhoneLocked) { - _channel.invokeMethod("callEndedFromFlutter"); + await _channel.invokeMethod("callEndedFromFlutter"); } + _activeCallNumber = null; } else if (state == "active" || state == "dialing") { + final phoneNumber = call.arguments["callId"] as String?; + if (phoneNumber != null && _activeCallNumber != Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''))) { + currentPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', '')); + if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + await _fetchContactInfo(currentPhoneNumber!); + } + } else if (currentPhoneNumber != null && _activeCallNumber != currentPhoneNumber) { + if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + await _fetchContactInfo(currentPhoneNumber!); + } + } else { + print('CallService: Skipping fetch, active call: $_activeCallNumber, current: $currentPhoneNumber, displayName: $currentDisplayName'); + } _navigateToCallPage(); } else if (state == "ringing") { - final phoneNumber = call.arguments["callId"] as String; - _handleIncomingCall(phoneNumber.replaceFirst('tel:', '')); + final phoneNumber = call.arguments["callId"] as String?; + if (phoneNumber == null) { + print('CallService: Invalid ringing callId: $call.arguments'); + return; + } + final decodedPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', '')); + if (_activeCallNumber != decodedPhoneNumber) { + currentPhoneNumber = decodedPhoneNumber; + if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + await _fetchContactInfo(decodedPhoneNumber); + } + } + _handleIncomingCall(decodedPhoneNumber); } break; case "callEnded": @@ -49,22 +105,139 @@ class CallService { print('CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked'); _closeCallPage(); if (wasPhoneLocked) { - _channel.invokeMethod("callEndedFromFlutter"); + await _channel.invokeMethod("callEndedFromFlutter"); } currentPhoneNumber = null; + currentDisplayName = null; + currentThumbnail = null; + _activeCallNumber = null; break; case "incomingCallFromNotification": - final phoneNumber = call.arguments["phoneNumber"] as String; + final phoneNumber = call.arguments["phoneNumber"] as String?; wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; - currentPhoneNumber = phoneNumber; - print('CallService: Incoming call from notification: $phoneNumber, wasPhoneLocked: $wasPhoneLocked'); - _handleIncomingCall(phoneNumber); + if (phoneNumber == null) { + print('CallService: Invalid incomingCallFromNotification args: $call.arguments'); + return; + } + final decodedPhoneNumber = Uri.decodeComponent(phoneNumber); + if (_activeCallNumber != decodedPhoneNumber) { + currentPhoneNumber = decodedPhoneNumber; + if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + await _fetchContactInfo(decodedPhoneNumber); + } + } + print('CallService: Incoming call from notification: $decodedPhoneNumber, displayName: $currentDisplayName, wasPhoneLocked: $wasPhoneLocked'); + _handleIncomingCall(decodedPhoneNumber); + break; + case "audioStateChanged": + final route = call.arguments["route"] as int?; + final muted = call.arguments["muted"] as bool?; + final speaker = call.arguments["speaker"] as bool?; + print('CallService: Audio state changed, route: $route, muted: $muted, speaker: $speaker'); + final audioState = { + "route": route, + "muted": muted, + "speaker": speaker, + }; + _currentAudioState = audioState; + _audioStateController.add(audioState); break; } }); } + Future getCallState() async { + try { + final state = await _channel.invokeMethod('getCallState'); + print('CallService: getCallState returned: $state'); + return state as String?; + } catch (e) { + print('CallService: Error getting call state: $e'); + return null; + } + } + + Future> muteCall(BuildContext context, {required bool mute}) async { + try { + print('CallService: Toggling mute to $mute'); + final result = await _channel.invokeMethod('muteCall', {'mute': mute}); + print('CallService: muteCall result: $result'); + final resultMap = Map.from(result as Map); + if (resultMap['status'] != 'success') { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to toggle mute')), + ); + } + return resultMap; + } catch (e) { + print('CallService: Error toggling mute: $e'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error toggling mute: $e')), + ); + return {'status': 'error', 'message': e.toString()}; + } + } + + Future> speakerCall(BuildContext context, {required bool speaker}) async { + try { + print('CallService: Toggling speaker to $speaker'); + final result = await _channel.invokeMethod('speakerCall', {'speaker': speaker}); + print('CallService: speakerCall result: $result'); + return Map.from(result); + } catch (e) { + print('CallService: Error toggling speaker: $e'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to toggle speaker: $e')), + ); + return {'status': 'error', 'message': e.toString()}; + } + } + + void dispose() { + _callStateController.close(); + _audioStateController.close(); + } + + Future _fetchContactInfo(String phoneNumber) async { + try { + print('CallService: Fetching contact info for $phoneNumber'); + final contacts = await _contactService.fetchContacts(); + print('CallService: Retrieved ${contacts.length} contacts'); + final normalizedPhoneNumber = _normalizePhoneNumber(phoneNumber); + print('CallService: Normalized phone number: $normalizedPhoneNumber'); + for (var contact in contacts) { + for (var phone in contact.phones) { + final normalizedContactNumber = _normalizePhoneNumber(phone.number); + print('CallService: Comparing $normalizedPhoneNumber with contact number $normalizedContactNumber'); + if (normalizedContactNumber == normalizedPhoneNumber) { + currentDisplayName = contact.displayName; + currentThumbnail = contact.thumbnail; + print('CallService: Found contact - displayName: $currentDisplayName, thumbnail: ${currentThumbnail != null ? "present" : "null"}'); + return; + } + } + } + currentDisplayName = phoneNumber; + currentThumbnail = null; + print('CallService: No contact match, using phoneNumber as displayName: $currentDisplayName'); + } catch (e) { + print('CallService: Error fetching contact info: $e'); + currentDisplayName = phoneNumber; + currentThumbnail = null; + } + } + + String _normalizePhoneNumber(String number) { + return number.replaceAll(RegExp(r'[\s\-\(\)]'), '').replaceFirst(RegExp(r'^\+'), ''); + } + void _handleIncomingCall(String phoneNumber) { + if (_activeCallNumber == phoneNumber && _isCallPageVisible) { + print('CallService: Incoming call for $phoneNumber already active, skipping'); + return; + } + _activeCallNumber = phoneNumber; + final context = navigatorKey.currentContext; if (context == null) { print('CallService: Context is null, queuing incoming call: $phoneNumber'); @@ -75,67 +248,119 @@ class CallService { } } - void _checkPendingCall() { - if (_pendingCall != null) { - final context = navigatorKey.currentContext; - if (context != null) { - print('CallService: Processing queued call: ${_pendingCall!["phoneNumber"]}'); - currentPhoneNumber = _pendingCall!["phoneNumber"]; - _navigateToIncomingCallPage(context); - _pendingCall = null; - } else { - print('CallService: Context still null, retrying...'); - Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall()); - } + Future _checkPendingCall() async { + if (_pendingCall == null) { + print('CallService: No pending call to process'); + return; + } + + final phoneNumber = _pendingCall!["phoneNumber"]; + if (_activeCallNumber == phoneNumber && _isCallPageVisible) { + print('CallService: Pending call for $phoneNumber already active, clearing'); + _pendingCall = null; + return; + } + + final context = navigatorKey.currentContext; + if (context != null) { + print('CallService: Processing queued call: $phoneNumber'); + currentPhoneNumber = phoneNumber; + _activeCallNumber = phoneNumber; + await _fetchContactInfo(phoneNumber); + _navigateToIncomingCallPage(context); + _pendingCall = null; + } else { + print('CallService: Context still null, retrying...'); + Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall()); } } void _navigateToCallPage() { + if (_isNavigating) { + print('CallService: Navigation already in progress, skipping'); + return; + } + _isNavigating = true; + final context = navigatorKey.currentContext; if (context == null) { print('CallService: Cannot navigate to CallPage, context is null'); + _isNavigating = false; return; } - if (_isCallPageVisible) { - print('CallService: CallPage already visible, skipping navigation'); + final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown'; + print('CallService: Navigating to CallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName'); + if (_isCallPageVisible && currentRoute == '/call' && _activeCallNumber == currentPhoneNumber) { + print('CallService: CallPage already visible for $_activeCallNumber, skipping navigation'); + _isNavigating = false; return; } - print('CallService: Navigating to CallPage'); - Navigator.push( + if (_isCallPageVisible && currentRoute == '/incoming_call' && _activeCallNumber == currentPhoneNumber) { + print('CallService: Popping IncomingCallPage before navigating to CallPage'); + Navigator.pop(context); + _isCallPageVisible = false; + } + if (currentPhoneNumber == null) { + print('CallService: Cannot navigate to CallPage, currentPhoneNumber is null'); + _isNavigating = false; + return; + } + _activeCallNumber = currentPhoneNumber; + Navigator.pushReplacement( context, MaterialPageRoute( settings: const RouteSettings(name: '/call'), builder: (context) => CallPage( - displayName: currentPhoneNumber!, + displayName: currentDisplayName ?? currentPhoneNumber!, phoneNumber: currentPhoneNumber!, - thumbnail: null, + thumbnail: currentThumbnail, ), ), ).then((_) { _isCallPageVisible = false; + _isNavigating = false; print('CallService: CallPage popped, _isCallPageVisible set to false'); }); _isCallPageVisible = true; } void _navigateToIncomingCallPage(BuildContext context) { - if (_isCallPageVisible) { - print('CallService: IncomingCallPage already visible, skipping navigation'); + if (_isNavigating) { + print('CallService: Navigation already in progress, skipping'); + return; + } + _isNavigating = true; + + final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown'; + print('CallService: Navigating to IncomingCallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName'); + if (_isCallPageVisible && currentRoute == '/incoming_call' && _activeCallNumber == currentPhoneNumber) { + print('CallService: IncomingCallPage already visible for $_activeCallNumber, skipping navigation'); + _isNavigating = false; + return; + } + if (_isCallPageVisible && currentRoute == '/call') { + print('CallService: CallPage visible, not showing IncomingCallPage'); + _isNavigating = false; + return; + } + if (currentPhoneNumber == null) { + print('CallService: Cannot navigate to IncomingCallPage, currentPhoneNumber is null'); + _isNavigating = false; return; } - print('CallService: Navigating to IncomingCallPage'); Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: '/incoming_call'), builder: (context) => IncomingCallPage( - displayName: currentPhoneNumber!, + displayName: currentDisplayName ?? currentPhoneNumber!, phoneNumber: currentPhoneNumber!, - thumbnail: null, + thumbnail: currentThumbnail, ), ), ).then((_) { _isCallPageVisible = false; + _isNavigating = false; print('CallService: IncomingCallPage popped, _isCallPageVisible set to false'); }); _isCallPageVisible = true; @@ -155,6 +380,7 @@ class CallService { } else { print('CallService: No page to pop'); } + _activeCallNumber = null; } Future> makeGsmCall( @@ -164,8 +390,17 @@ class CallService { Uint8List? thumbnail, }) async { try { + if (_activeCallNumber == phoneNumber && _isCallPageVisible) { + print('CallService: Call already active for $phoneNumber, skipping'); + return {"status": "already_active", "message": "Call already in progress"}; + } currentPhoneNumber = phoneNumber; - print('CallService: Making GSM call to $phoneNumber'); + currentDisplayName = displayName ?? phoneNumber; + currentThumbnail = thumbnail; + if (displayName == null || thumbnail == null) { + await _fetchContactInfo(phoneNumber); + } + print('CallService: Making GSM call to $phoneNumber, displayName: $currentDisplayName'); final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber}); print('CallService: makeGsmCall result: $result'); final resultMap = Map.from(result as Map);