callPageV2 (#52)
All checks were successful
/ build (push) Successful in 7m17s
/ build-stealth (push) Successful in 6m20s
/ mirror (push) Successful in 5s

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: #52
Co-authored-by: Florian Griffon <florian.griffon@epitech.eu>
Co-committed-by: Florian Griffon <florian.griffon@epitech.eu>
This commit is contained in:
Florian Griffon 2025-04-30 11:21:55 +00:00 committed by stcb
parent ec1779bb15
commit dab3fe3790
6 changed files with 797 additions and 293 deletions

View File

@ -153,6 +153,44 @@ class MainActivity : FlutterActivity() {
} }
result.success(true) 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<Boolean>("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<Boolean>("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() else -> result.notImplemented()
} }
} }

View File

@ -6,9 +6,11 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.media.AudioManager
import android.os.Build import android.os.Build
import android.telecom.Call import android.telecom.Call
import android.telecom.InCallService import android.telecom.InCallService
import android.telecom.CallAudioState
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.icing.dialer.activities.MainActivity 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_CHANNEL_ID = "incoming_call_channel"
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
var wasPhoneLocked: Boolean = false 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() { private val callCallback = object : Call.Callback() {
@ -60,6 +90,7 @@ class MyInCallService : InCallService() {
override fun onCallAdded(call: Call) { override fun onCallAdded(call: Call) {
super.onCallAdded(call) super.onCallAdded(call)
instance = this
currentCall = call currentCall = call
val stateStr = when (call.state) { val stateStr = when (call.state) {
Call.STATE_DIALING -> "dialing" Call.STATE_DIALING -> "dialing"
@ -79,6 +110,16 @@ class MyInCallService : InCallService() {
showIncomingCallScreen(call.details.handle.toString().replace("tel:", "")) showIncomingCallScreen(call.details.handle.toString().replace("tel:", ""))
} }
call.registerCallback(callCallback) 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) { override fun onCallRemoved(call: Call) {
@ -90,13 +131,18 @@ class MyInCallService : InCallService() {
"wasPhoneLocked" to wasPhoneLocked "wasPhoneLocked" to wasPhoneLocked
)) ))
currentCall = null currentCall = null
instance = null
cancelNotification() cancelNotification()
} }
override fun onCallAudioStateChanged(state: android.telecom.CallAudioState) { override fun onCallAudioStateChanged(state: CallAudioState) {
super.onCallAudioStateChanged(state) super.onCallAudioStateChanged(state)
Log.d(TAG, "Audio state changed: route=${state.route}") Log.d(TAG, "Audio state changed: route=${state.route}, muted=${state.isMuted}")
channel?.invokeMethod("audioStateChanged", mapOf("route" to state.route)) channel?.invokeMethod("audioStateChanged", mapOf(
"route" to state.route,
"muted" to state.isMuted,
"speaker" to (state.route == CallAudioState.ROUTE_SPEAKER)
))
} }
private fun showIncomingCallScreen(phoneNumber: String) { private fun showIncomingCallScreen(phoneNumber: String) {

View File

@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:dialer/services/call_service.dart'; import 'package:dialer/services/call_service.dart';
import 'package:dialer/services/obfuscate_service.dart'; import 'package:dialer/services/obfuscate_service.dart';
import 'package:dialer/widgets/username_color_generator.dart'; import 'package:dialer/widgets/username_color_generator.dart';
@ -24,10 +26,103 @@ class _CallPageState extends State<CallPage> {
final ObfuscateService _obfuscateService = ObfuscateService(); final ObfuscateService _obfuscateService = ObfuscateService();
final CallService _callService = CallService(); final CallService _callService = CallService();
bool isMuted = false; bool isMuted = false;
bool isSpeakerOn = false; bool isSpeaker = false;
bool isKeypadVisible = false; bool isKeypadVisible = false;
bool icingProtocolOk = true; bool icingProtocolOk = true;
String _typedDigits = ""; String _typedDigits = "";
Timer? _callTimer;
int _callSeconds = 0;
String _callStatus = "Calling...";
StreamSubscription<String>? _callStateSubscription;
StreamSubscription<Map<String, dynamic>>? _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) { void _addDigit(String digit) {
setState(() { setState(() {
@ -35,16 +130,48 @@ class _CallPageState extends State<CallPage> {
}); });
} }
void _toggleMute() { void _toggleMute() async {
setState(() { try {
isMuted = !isMuted; 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() { Future<void> _toggleSpeaker() async {
setState(() { try {
isSpeakerOn = !isSpeakerOn; 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() { void _toggleKeypad() {
@ -61,15 +188,32 @@ class _CallPageState extends State<CallPage> {
void _hangUp() async { void _hangUp() async {
try { try {
print('CallPage: Initiating hangUp');
final result = await _callService.hangUpCall(context); final result = await _callService.hangUpCall(context);
print('CallPage: Hang up result: $result'); print('CallPage: Hang up result: $result');
if (result["status"] == "ended" && mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
} catch (e) { } 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( 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<CallPage> {
final double nameFontSize = isKeypadVisible ? 24.0 : 24.0; final double nameFontSize = isKeypadVisible ? 24.0 : 24.0;
final double statusFontSize = isKeypadVisible ? 16.0 : 16.0; final double statusFontSize = isKeypadVisible ? 16.0 : 16.0;
return Scaffold( print(
body: Container( 'CallPage: Building UI, _callStatus: $_callStatus, route: ${ModalRoute.of(context)?.settings.name ?? "unknown"}');
color: Colors.black, return PopScope(
child: SafeArea( canPop:
child: Column( _callStatus == "Call Ended", // Allow navigation only if call ended
children: [ child: Scaffold(
Container( body: Container(
padding: const EdgeInsets.symmetric(vertical: 8.0), color: Colors.black,
child: Column( child: SafeArea(
mainAxisSize: MainAxisSize.min, child: Column(
children: [ children: [
SizedBox(height: 35), Container(
ObfuscatedAvatar( padding: const EdgeInsets.symmetric(vertical: 8.0),
imageBytes: widget.thumbnail, child: Column(
radius: avatarRadius, mainAxisSize: MainAxisSize.min,
backgroundColor:
generateColorFromName(widget.displayName),
fallbackInitial: widget.displayName,
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( const SizedBox(height: 35),
icingProtocolOk ? Icons.lock : Icons.lock_open, ObfuscatedAvatar(
color: icingProtocolOk ? Colors.green : Colors.red, imageBytes: widget.thumbnail,
size: 16, 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( Text(
'Icing protocol: ${icingProtocolOk ? "ok" : "ko"}', _obfuscateService.obfuscateData(widget.displayName),
style: TextStyle( style: TextStyle(
color: icingProtocolOk ? Colors.green : Colors.red, fontSize: nameFontSize,
fontSize: 12, color: Colors.white,
fontWeight: FontWeight.bold, 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( Expanded(
_obfuscateService.obfuscateData(widget.displayName), child: Column(
style: TextStyle( children: [
fontSize: nameFontSize, if (isKeypadVisible) ...[
color: Colors.white, const Spacer(flex: 2),
fontWeight: FontWeight.bold, Padding(
), padding:
), const EdgeInsets.symmetric(horizontal: 20.0),
Text( child: Row(
widget.phoneNumber, mainAxisAlignment: MainAxisAlignment.spaceBetween,
style: TextStyle( children: [
fontSize: statusFontSize, color: Colors.white70), Expanded(
),
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(
child: Text( child: Text(
label, _typedDigits,
maxLines: 1,
textAlign: TextAlign.right,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
fontSize: 32, color: Colors.white), fontSize: 24,
color: Colors.white,
fontWeight: FontWeight.bold,
),
), ),
), ),
), IconButton(
); padding: EdgeInsets.zero,
}), onPressed: _toggleKeypad,
), icon: const Icon(Icons.close,
), color: Colors.white),
const Spacer(flex: 1), ),
] else ...[ ],
const Spacer(), ),
Padding( ),
padding: const EdgeInsets.symmetric(horizontal: 32.0), Container(
child: Column( height: MediaQuery.of(context).size.height * 0.35,
mainAxisSize: MainAxisSize.min, margin: const EdgeInsets.symmetric(horizontal: 20),
children: [ child: GridView.count(
Row( shrinkWrap: true,
mainAxisAlignment: MainAxisAlignment.spaceEvenly, 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: [ children: [
Column( Row(
mainAxisSize: MainAxisSize.min, mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [ children: [
IconButton( Column(
onPressed: _toggleMute, mainAxisSize: MainAxisSize.min,
icon: Icon( children: [
isMuted ? Icons.mic_off : Icons.mic, IconButton(
color: Colors.white, onPressed: _toggleMute,
size: 32, 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( Column(
isMuted ? 'Unmute' : 'Mute', mainAxisSize: MainAxisSize.min,
style: const TextStyle( children: [
color: Colors.white, fontSize: 14), 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( const SizedBox(height: 20),
mainAxisSize: MainAxisSize.min, Row(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [ children: [
IconButton( if (isNumberUnknown)
onPressed: _toggleKeypad, Column(
icon: const Icon(Icons.dialpad, mainAxisSize: MainAxisSize.min,
color: Colors.white, size: 32), children: [
), IconButton(
const Text( onPressed: _addContact,
'Keypad', icon: const Icon(
style: TextStyle( Icons.person_add,
color: Colors.white, fontSize: 14), color: Colors.white,
), size: 32,
], ),
), ),
Column( const Text(
mainAxisSize: MainAxisSize.min, 'Add Contact',
children: [ style: TextStyle(
IconButton( color: Colors.white,
onPressed: _toggleSpeaker, fontSize: 14),
icon: Icon( ),
isSpeakerOn ],
? Icons.volume_up
: Icons.volume_off,
color: isSpeakerOn
? Colors.amber
: Colors.white,
size: 32,
), ),
), Column(
const Text( mainAxisSize: MainAxisSize.min,
'Speaker', children: [
style: TextStyle( IconButton(
color: Colors.white, fontSize: 14), 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( const Spacer(flex: 3),
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,
), ),
), ),
), 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,
),
),
),
),
],
), ),
], ),
), ),
), ));
),
);
} }
} }

View File

@ -267,8 +267,12 @@ class _ContactModalState extends State<ContactModal> {
), ),
onTap: () async { onTap: () async {
if (widget.contact.phones.isNotEmpty) { if (widget.contact.phones.isNotEmpty) {
await _callService.makeGsmCall(context, await _callService.makeGsmCall(
phoneNumber: phoneNumber); context,
phoneNumber: phoneNumber,
displayName: widget.contact.displayName,
thumbnail: widget.contact.thumbnail,
);
} }
}, },
), ),

View File

@ -425,7 +425,12 @@ class _HistoryPageState extends State<HistoryPage>
icon: const Icon(Icons.phone, color: Colors.green), icon: const Icon(Icons.phone, color: Colors.green),
onPressed: () async { onPressed: () async {
if (contact.phones.isNotEmpty) { 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 { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(

View File

@ -1,46 +1,102 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../features/call/call_page.dart'; import '../features/call/call_page.dart';
import '../features/call/incoming_call_page.dart'; import '../features/call/incoming_call_page.dart';
import '../services/contact_service.dart';
class CallService { class CallService {
static const MethodChannel _channel = MethodChannel('call_service'); static const MethodChannel _channel = MethodChannel('call_service');
static String? currentPhoneNumber; static String? currentPhoneNumber;
static String? currentDisplayName;
static Uint8List? currentThumbnail;
static bool _isCallPageVisible = false; static bool _isCallPageVisible = false;
static Map<String, dynamic>? _pendingCall; static Map<String, dynamic>? _pendingCall;
static bool wasPhoneLocked = false; static bool wasPhoneLocked = false;
static String? _activeCallNumber;
static bool _isNavigating = false;
final ContactService _contactService = ContactService();
final _callStateController = StreamController<String>.broadcast();
final _audioStateController = StreamController<Map<String, dynamic>>.broadcast();
Map<String, dynamic>? _currentAudioState;
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
Stream<String> get callStateStream => _callStateController.stream;
Stream<Map<String, dynamic>> get audioStateStream => _audioStateController.stream;
Map<String, dynamic>? get currentAudioState => _currentAudioState;
CallService() { CallService() {
_channel.setMethodCallHandler((call) async { _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) { switch (call.method) {
case "callAdded": case "callAdded":
final phoneNumber = call.arguments["callId"] as String; final phoneNumber = call.arguments["callId"] as String?;
final state = call.arguments["state"] as String; final state = call.arguments["state"] as String?;
currentPhoneNumber = phoneNumber.replaceFirst('tel:', ''); if (phoneNumber == null || state == null) {
print('CallService: Call added, number: $currentPhoneNumber, state: $state'); 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") { if (state == "ringing") {
_handleIncomingCall(phoneNumber); _handleIncomingCall(decodedPhoneNumber);
} else { } else {
_navigateToCallPage(); _navigateToCallPage();
} }
break; break;
case "callStateChanged": case "callStateChanged":
final state = call.arguments["state"] as String; final state = call.arguments["state"] as String?;
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; 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'); print('CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked');
_callStateController.add(state);
if (state == "disconnected" || state == "disconnecting") { if (state == "disconnected" || state == "disconnecting") {
_closeCallPage(); _closeCallPage();
if (wasPhoneLocked) { if (wasPhoneLocked) {
_channel.invokeMethod("callEndedFromFlutter"); await _channel.invokeMethod("callEndedFromFlutter");
} }
_activeCallNumber = null;
} else if (state == "active" || state == "dialing") { } 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(); _navigateToCallPage();
} else if (state == "ringing") { } else if (state == "ringing") {
final phoneNumber = call.arguments["callId"] as String; final phoneNumber = call.arguments["callId"] as String?;
_handleIncomingCall(phoneNumber.replaceFirst('tel:', '')); 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; break;
case "callEnded": case "callEnded":
@ -49,22 +105,139 @@ class CallService {
print('CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked'); print('CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked');
_closeCallPage(); _closeCallPage();
if (wasPhoneLocked) { if (wasPhoneLocked) {
_channel.invokeMethod("callEndedFromFlutter"); await _channel.invokeMethod("callEndedFromFlutter");
} }
currentPhoneNumber = null; currentPhoneNumber = null;
currentDisplayName = null;
currentThumbnail = null;
_activeCallNumber = null;
break; break;
case "incomingCallFromNotification": case "incomingCallFromNotification":
final phoneNumber = call.arguments["phoneNumber"] as String; final phoneNumber = call.arguments["phoneNumber"] as String?;
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
currentPhoneNumber = phoneNumber; if (phoneNumber == null) {
print('CallService: Incoming call from notification: $phoneNumber, wasPhoneLocked: $wasPhoneLocked'); print('CallService: Invalid incomingCallFromNotification args: $call.arguments');
_handleIncomingCall(phoneNumber); 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; break;
} }
}); });
} }
Future<String?> 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<Map<String, dynamic>> 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<String, dynamic>.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<Map<String, dynamic>> 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<String, dynamic>.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<void> _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) { void _handleIncomingCall(String phoneNumber) {
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
print('CallService: Incoming call for $phoneNumber already active, skipping');
return;
}
_activeCallNumber = phoneNumber;
final context = navigatorKey.currentContext; final context = navigatorKey.currentContext;
if (context == null) { if (context == null) {
print('CallService: Context is null, queuing incoming call: $phoneNumber'); print('CallService: Context is null, queuing incoming call: $phoneNumber');
@ -75,67 +248,119 @@ class CallService {
} }
} }
void _checkPendingCall() { Future<void> _checkPendingCall() async {
if (_pendingCall != null) { if (_pendingCall == null) {
final context = navigatorKey.currentContext; print('CallService: No pending call to process');
if (context != null) { return;
print('CallService: Processing queued call: ${_pendingCall!["phoneNumber"]}'); }
currentPhoneNumber = _pendingCall!["phoneNumber"];
_navigateToIncomingCallPage(context); final phoneNumber = _pendingCall!["phoneNumber"];
_pendingCall = null; if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
} else { print('CallService: Pending call for $phoneNumber already active, clearing');
print('CallService: Context still null, retrying...'); _pendingCall = null;
Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall()); 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() { void _navigateToCallPage() {
if (_isNavigating) {
print('CallService: Navigation already in progress, skipping');
return;
}
_isNavigating = true;
final context = navigatorKey.currentContext; final context = navigatorKey.currentContext;
if (context == null) { if (context == null) {
print('CallService: Cannot navigate to CallPage, context is null'); print('CallService: Cannot navigate to CallPage, context is null');
_isNavigating = false;
return; return;
} }
if (_isCallPageVisible) { final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown';
print('CallService: CallPage already visible, skipping navigation'); 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; return;
} }
print('CallService: Navigating to CallPage'); if (_isCallPageVisible && currentRoute == '/incoming_call' && _activeCallNumber == currentPhoneNumber) {
Navigator.push( 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, context,
MaterialPageRoute( MaterialPageRoute(
settings: const RouteSettings(name: '/call'), settings: const RouteSettings(name: '/call'),
builder: (context) => CallPage( builder: (context) => CallPage(
displayName: currentPhoneNumber!, displayName: currentDisplayName ?? currentPhoneNumber!,
phoneNumber: currentPhoneNumber!, phoneNumber: currentPhoneNumber!,
thumbnail: null, thumbnail: currentThumbnail,
), ),
), ),
).then((_) { ).then((_) {
_isCallPageVisible = false; _isCallPageVisible = false;
_isNavigating = false;
print('CallService: CallPage popped, _isCallPageVisible set to false'); print('CallService: CallPage popped, _isCallPageVisible set to false');
}); });
_isCallPageVisible = true; _isCallPageVisible = true;
} }
void _navigateToIncomingCallPage(BuildContext context) { void _navigateToIncomingCallPage(BuildContext context) {
if (_isCallPageVisible) { if (_isNavigating) {
print('CallService: IncomingCallPage already visible, skipping navigation'); 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; return;
} }
print('CallService: Navigating to IncomingCallPage');
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: const RouteSettings(name: '/incoming_call'), settings: const RouteSettings(name: '/incoming_call'),
builder: (context) => IncomingCallPage( builder: (context) => IncomingCallPage(
displayName: currentPhoneNumber!, displayName: currentDisplayName ?? currentPhoneNumber!,
phoneNumber: currentPhoneNumber!, phoneNumber: currentPhoneNumber!,
thumbnail: null, thumbnail: currentThumbnail,
), ),
), ),
).then((_) { ).then((_) {
_isCallPageVisible = false; _isCallPageVisible = false;
_isNavigating = false;
print('CallService: IncomingCallPage popped, _isCallPageVisible set to false'); print('CallService: IncomingCallPage popped, _isCallPageVisible set to false');
}); });
_isCallPageVisible = true; _isCallPageVisible = true;
@ -155,6 +380,7 @@ class CallService {
} else { } else {
print('CallService: No page to pop'); print('CallService: No page to pop');
} }
_activeCallNumber = null;
} }
Future<Map<String, dynamic>> makeGsmCall( Future<Map<String, dynamic>> makeGsmCall(
@ -164,8 +390,17 @@ class CallService {
Uint8List? thumbnail, Uint8List? thumbnail,
}) async { }) async {
try { try {
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
print('CallService: Call already active for $phoneNumber, skipping');
return {"status": "already_active", "message": "Call already in progress"};
}
currentPhoneNumber = phoneNumber; 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}); final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber});
print('CallService: makeGsmCall result: $result'); print('CallService: makeGsmCall result: $result');
final resultMap = Map<String, dynamic>.from(result as Map); final resultMap = Map<String, dynamic>.from(result as Map);