monorepo/dialer/lib/presentation/features/call/call_page.dart
AlexisDanlos aa8d32f28c
All checks were successful
/ mirror (push) Successful in 5s
/ build (push) Successful in 11m27s
/ build-stealth (push) Successful in 11m34s
change-default-sim (#60)
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>
2025-07-08 07:31:38 +00:00

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,
),
),
),
),
],
),
),
),
),
);
}
}