Co-authored-by: AlexisDanlos <91090088+AlexisDanlos@users.noreply.github.com> Co-authored-by: stcb <21@stcb.cc> Reviewed-on: #60 Co-authored-by: AlexisDanlos <alexis.danlos@epitech.eu> Co-committed-by: AlexisDanlos <alexis.danlos@epitech.eu>
728 lines
27 KiB
Dart
728 lines
27 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
|
import 'package:dialer/domain/services/call_service.dart';
|
|
import 'package:dialer/domain/services/obfuscate_service.dart';
|
|
import 'package:dialer/presentation/common/widgets/username_color_generator.dart';
|
|
import 'package:dialer/presentation/common/widgets/sim_selection_dialog.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:sim_data_new/sim_data.dart';
|
|
|
|
class CallPage extends StatefulWidget {
|
|
final String displayName;
|
|
final String phoneNumber;
|
|
final Uint8List? thumbnail;
|
|
|
|
const CallPage({
|
|
super.key,
|
|
required this.displayName,
|
|
required this.phoneNumber,
|
|
this.thumbnail,
|
|
});
|
|
|
|
@override
|
|
_CallPageState createState() => _CallPageState();
|
|
}
|
|
|
|
class _CallPageState extends State<CallPage> {
|
|
final ObfuscateService _obfuscateService = ObfuscateService();
|
|
final CallService _callService = CallService();
|
|
bool isMuted = false;
|
|
bool isSpeaker = false;
|
|
bool isKeypadVisible = false;
|
|
bool icingProtocolOk = true;
|
|
String _typedDigits = "";
|
|
Timer? _callTimer;
|
|
int _callSeconds = 0;
|
|
String _callStatus = "Calling...";
|
|
StreamSubscription<String>? _callStateSubscription;
|
|
StreamSubscription<Map<String, dynamic>>? _audioStateSubscription;
|
|
StreamSubscription<int?>? _simStateSubscription;
|
|
bool _isCallActive = true; // Track if call is still active
|
|
String? _simName; // Human-readable SIM card name
|
|
|
|
bool get isNumberUnknown => widget.displayName == widget.phoneNumber;
|
|
|
|
// Fetch and update human-readable SIM name based on slot
|
|
Future<void> _updateSimName(int? simSlot) async {
|
|
if (!mounted) return;
|
|
if (simSlot != null) {
|
|
try {
|
|
final simData = await SimDataPlugin.getSimData();
|
|
// Find the SIM card matching the slot index, if any
|
|
dynamic card;
|
|
for (var c in simData.cards) {
|
|
if (c.slotIndex == simSlot) {
|
|
card = c;
|
|
break;
|
|
}
|
|
}
|
|
String name;
|
|
if (card != null && card.displayName.isNotEmpty) {
|
|
name = card.displayName;
|
|
} else if (card != null && card.carrierName.isNotEmpty) {
|
|
name = card.carrierName;
|
|
} else {
|
|
name = 'SIM ${simSlot + 1}';
|
|
}
|
|
setState(() {
|
|
_simName = name;
|
|
});
|
|
} catch (e) {
|
|
setState(() {
|
|
_simName = 'SIM ${simSlot + 1}';
|
|
});
|
|
}
|
|
} else {
|
|
setState(() {
|
|
_simName = null;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_checkInitialCallState();
|
|
_listenToCallState();
|
|
_listenToAudioState();
|
|
_listenToSimState();
|
|
_updateSimName(CallService.getCurrentSimSlot); // Initial SIM name
|
|
_setInitialAudioState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_callTimer?.cancel();
|
|
_callStateSubscription?.cancel();
|
|
_audioStateSubscription?.cancel();
|
|
_simStateSubscription?.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) {
|
|
setState(() {
|
|
if (state == "active") {
|
|
_callStatus = "00:00";
|
|
_isCallActive = true;
|
|
_startCallTimer();
|
|
} else if (state == "disconnected" || state == "disconnecting") {
|
|
_callStatus = "Call Ended";
|
|
_isCallActive = false;
|
|
} else {
|
|
_callStatus = "Calling...";
|
|
_isCallActive = true;
|
|
}
|
|
});
|
|
}
|
|
} 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";
|
|
_isCallActive = true;
|
|
_startCallTimer();
|
|
} else if (state == "disconnected" || state == "disconnecting") {
|
|
_callTimer?.cancel();
|
|
_callStatus = "Call Ended";
|
|
_isCallActive = false;
|
|
// Let CallService handle navigation - don't navigate from here
|
|
} else {
|
|
_callStatus = "Calling...";
|
|
_isCallActive = true;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
void _listenToAudioState() {
|
|
_audioStateSubscription = _callService.audioStateStream.listen((state) {
|
|
if (mounted) {
|
|
setState(() {
|
|
isMuted = state['muted'] ?? isMuted;
|
|
isSpeaker = state['speaker'] ?? isSpeaker;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
void _listenToSimState() {
|
|
_simStateSubscription = _callService.simStateStream.listen((simSlot) {
|
|
_updateSimName(simSlot);
|
|
});
|
|
}
|
|
|
|
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) async {
|
|
print('CallPage: Tapped digit: $digit');
|
|
setState(() {
|
|
_typedDigits += digit;
|
|
});
|
|
// Send DTMF tone
|
|
const channel = MethodChannel('call_service');
|
|
try {
|
|
final success =
|
|
await channel.invokeMethod<bool>('sendDtmfTone', {'digit': digit});
|
|
if (success != true) {
|
|
print('CallPage: Failed to send DTMF tone for $digit');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Failed to send DTMF tone')),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('CallPage: Error sending DTMF tone: $e');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Error sending DTMF tone: $e')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _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 (mounted && 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() {
|
|
setState(() {
|
|
isKeypadVisible = !isKeypadVisible;
|
|
});
|
|
}
|
|
|
|
void _showSimSelectionDialog() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return SimSelectionDialog(
|
|
phoneNumber: widget.phoneNumber,
|
|
displayName: widget.displayName,
|
|
onSimSelected: _switchToNewSim,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _switchToNewSim(int simSlot) async {
|
|
try {
|
|
print(
|
|
'CallPage: Initiating SIM switch to slot $simSlot for ${widget.phoneNumber}');
|
|
|
|
// Use the CallService to handle the SIM switch logic
|
|
await _callService.switchSimAndRedial(
|
|
phoneNumber: widget.phoneNumber,
|
|
displayName: widget.displayName,
|
|
simSlot: simSlot,
|
|
thumbnail: widget.thumbnail,
|
|
);
|
|
|
|
print('CallPage: SIM switch initiated successfully');
|
|
} catch (e) {
|
|
print('CallPage: Error initiating SIM switch: $e');
|
|
|
|
// Show error feedback if widget is still mounted
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Error switching SIM: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _hangUp() async {
|
|
// Don't try to hang up if call is already ended
|
|
if (!_isCallActive) {
|
|
print('CallPage: Ignoring hangup - call already ended');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
print(
|
|
'CallPage: Initiating manual hangUp - canceling any pending SIM switch');
|
|
|
|
// Immediately mark call as inactive to prevent further interactions
|
|
setState(() {
|
|
_isCallActive = false;
|
|
_callStatus = "Ending Call...";
|
|
});
|
|
|
|
// Cancel any pending SIM switch since user is manually hanging up
|
|
_callService.cancelPendingSimSwitch();
|
|
|
|
final result = await _callService.hangUpCall(context);
|
|
print('CallPage: Hang up result: $result');
|
|
|
|
// If the page is still visible after hangup, try to close it
|
|
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
|
|
print('CallPage: Still visible after hangup, navigating back');
|
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
}
|
|
} catch (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 (mounted && updatedContact != null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Contact added successfully!')),
|
|
);
|
|
}
|
|
} else {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Permission denied for contacts')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final double avatarRadius = isKeypadVisible ? 45.0 : 45.0;
|
|
final double nameFontSize = isKeypadVisible ? 24.0 : 24.0;
|
|
final double statusFontSize = isKeypadVisible ? 16.0 : 16.0;
|
|
|
|
print(
|
|
'CallPage: Building UI, _callStatus: $_callStatus, route: ${ModalRoute.of(context)?.settings.name ?? "unknown"}');
|
|
|
|
// If call is disconnected and we're not actively navigating, force navigation
|
|
if ((_callStatus == "Call Ended" || !_isCallActive) && mounted) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
|
|
print('CallPage: Call ended, forcing navigation back to home');
|
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
}
|
|
});
|
|
}
|
|
|
|
return PopScope(
|
|
canPop:
|
|
true, // Always allow popping - CallService manages when it's appropriate
|
|
onPopInvoked: (didPop) {
|
|
print(
|
|
'CallPage: PopScope onPopInvoked - didPop: $didPop, _isCallActive: $_isCallActive, _callStatus: $_callStatus');
|
|
// No longer prevent popping during active calls - CallService handles this
|
|
},
|
|
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: [
|
|
const SizedBox(height: 35),
|
|
ObfuscatedAvatar(
|
|
imageBytes: widget.thumbnail,
|
|
radius: avatarRadius,
|
|
backgroundColor:
|
|
generateColorFromName(widget.displayName),
|
|
fallbackInitial: widget.displayName,
|
|
),
|
|
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(
|
|
_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(
|
|
_callStatus,
|
|
style: TextStyle(
|
|
fontSize: statusFontSize,
|
|
color: Colors.white70,
|
|
),
|
|
),
|
|
// Show SIM information if a SIM slot has been set
|
|
if (_simName != null)
|
|
Container(
|
|
margin: const EdgeInsets.only(top: 4.0),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8.0, vertical: 2.0),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: Colors.blue.withOpacity(0.5),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Text(
|
|
// Show human-readable SIM name plus slot number
|
|
'$_simName (SIM ${CallService.getCurrentSimSlot! + 1})',
|
|
style: TextStyle(
|
|
fontSize: statusFontSize - 2,
|
|
color: Colors.lightBlueAccent,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
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.4,
|
|
margin: const EdgeInsets.symmetric(horizontal: 20),
|
|
padding: const EdgeInsets.all(8),
|
|
child: GridView.count(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
crossAxisCount: 3,
|
|
childAspectRatio: 1.5,
|
|
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: [
|
|
Row(
|
|
mainAxisAlignment:
|
|
MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
mainAxisAlignment:
|
|
MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
onPressed: _isCallActive
|
|
? _showSimSelectionDialog
|
|
: null,
|
|
icon: Icon(
|
|
Icons.sim_card,
|
|
color: _isCallActive
|
|
? Colors.white
|
|
: Colors.grey,
|
|
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: _isCallActive ? _hangUp : null,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: _isCallActive ? Colors.red : Colors.grey,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
_isCallActive ? Icons.call_end : Icons.call_end,
|
|
color: Colors.white,
|
|
size: 32,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|