feat: DTMF dialpad #55

Merged
stcb merged 4 commits from DTMF into dev 2025-05-14 09:58:50 +00:00
3 changed files with 350 additions and 285 deletions
Showing only changes of commit 5c8373f575 - Show all commits

View File

@ -199,6 +199,15 @@ class MainActivity : FlutterActivity() {
checkAndRequestDefaultDialer() checkAndRequestDefaultDialer()
result.success(true) result.success(true)
} }
"sendDtmfTone" -> {
val digit = call.argument<String>("digit")
if (digit != null) {
val success = MyInCallService.sendDtmfTone(digit)
result.success(success)
} else {
result.error("INVALID_ARGUMENT", "Digit is null", null)
}
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} }

View File

@ -52,6 +52,22 @@ class MyInCallService : InCallService() {
} }
} ?: false } ?: false
} }
fun sendDtmfTone(digit: String): Boolean {
return instance?.let { service ->
try {
currentCall?.let { call ->
call.playDtmfTone(digit[0])
call.stopDtmfTone()
Log.d(TAG, "Sent DTMF tone: $digit")
true
} ?: false
} catch (e: Exception) {
Log.e(TAG, "Failed to send DTMF tone: $e")
false
}
} ?: false
}
} }
private val callCallback = object : Call.Callback() { private val callCallback = object : Call.Callback() {
@ -111,7 +127,7 @@ class MyInCallService : InCallService() {
} }
call.registerCallback(callCallback) call.registerCallback(callCallback)
if (callAudioState != null) { if (callAudioState != null) {
val audioState = callAudioState val audioState = callAudioState
channel?.invokeMethod("audioStateChanged", mapOf( channel?.invokeMethod("audioStateChanged", mapOf(
"route" to audioState.route, "route" to audioState.route,
"muted" to audioState.isMuted, "muted" to audioState.isMuted,

View File

@ -1,10 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.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';
import 'package:flutter/services.dart';
class CallPage extends StatefulWidget { class CallPage extends StatefulWidget {
final String displayName; final String displayName;
@ -124,10 +124,32 @@ class _CallPageState extends State<CallPage> {
}); });
} }
void _addDigit(String digit) { void _addDigit(String digit) async {
print('CallPage: Tapped digit: $digit');
setState(() { setState(() {
_typedDigits += digit; _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 { void _toggleMute() async {
@ -195,7 +217,7 @@ class _CallPageState extends State<CallPage> {
print('CallPage: Error hanging up: $e'); print('CallPage: Error hanging up: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error hanging up: $e")), SnackBar(content: Text('Error hanging up: $e')),
); );
} }
} }
@ -227,298 +249,316 @@ class _CallPageState extends State<CallPage> {
print( print(
'CallPage: Building UI, _callStatus: $_callStatus, route: ${ModalRoute.of(context)?.settings.name ?? "unknown"}'); 'CallPage: Building UI, _callStatus: $_callStatus, route: ${ModalRoute.of(context)?.settings.name ?? "unknown"}');
return PopScope( return PopScope(
canPop: canPop: _callStatus == "Call Ended",
_callStatus == "Call Ended", // Allow navigation only if call ended onPopInvoked: (didPop) {
child: Scaffold( if (!didPop) {
body: Container( ScaffoldMessenger.of(context).showSnackBar(
color: Colors.black, SnackBar(content: Text('Cannot leave during an active call')),
child: SafeArea( );
child: Column( }
children: [ },
Container( child: Scaffold(
padding: const EdgeInsets.symmetric(vertical: 8.0), body: Container(
child: Column( color: Colors.black,
mainAxisSize: MainAxisSize.min, child: SafeArea(
children: [ child: Column(
const SizedBox(height: 35), children: [
ObfuscatedAvatar( Container(
imageBytes: widget.thumbnail, padding: const EdgeInsets.symmetric(vertical: 8.0),
radius: avatarRadius, child: Column(
backgroundColor: mainAxisSize: MainAxisSize.min,
generateColorFromName(widget.displayName), children: [
fallbackInitial: widget.displayName, const SizedBox(height: 35),
), ObfuscatedAvatar(
const SizedBox(height: 4), imageBytes: widget.thumbnail,
Row( radius: avatarRadius,
mainAxisAlignment: MainAxisAlignment.center, backgroundColor:
children: [ generateColorFromName(widget.displayName),
Icon( fallbackInitial: widget.displayName,
icingProtocolOk ? Icons.lock : Icons.lock_open, ),
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: color:
icingProtocolOk ? Colors.green : Colors.red, icingProtocolOk ? Colors.green : Colors.red,
size: 16, fontSize: 12,
), fontWeight: FontWeight.bold,
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),
),
],
),
),
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(
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: () {},
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),
], ],
], ),
), const SizedBox(height: 4),
), Text(
Padding( _obfuscateService.obfuscateData(widget.displayName),
padding: const EdgeInsets.only(bottom: 16.0), style: TextStyle(
child: GestureDetector( fontSize: nameFontSize,
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, color: Colors.white,
size: 32, fontWeight: FontWeight.bold,
), ),
), ),
Text(
widget.phoneNumber,
style: TextStyle(
fontSize: statusFontSize,
color: Colors.white70,
),
),
Text(
_callStatus,
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.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: () {},
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,
),
), ),
), ),
], ),
), ],
), ),
), ),
)); ),
),
);
} }
} }