All checks were successful
Create Release / build (push) Successful in 4s
Backend: Add /voice/token endpoint with AccessToken + VoiceGrant for mobile VoIP, implement unhold_call() with call leg detection, wire FCM push notifications into call queue and webhook missed call handlers, add data-only FCM message support for Android background wake, and add Twilio API Key / Push Credential settings fields. Flutter app: Full softphone with Twilio Voice SDK integration, JWT auth with auto-refresh, SSE real-time queue updates, FCM push notifications, Material 3 UI with dashboard, active call screen, dialpad, and call controls (mute/speaker/hold/transfer). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
138 lines
4.1 KiB
Dart
138 lines
4.1 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../providers/call_provider.dart';
|
|
import '../models/call_info.dart';
|
|
import '../widgets/call_controls.dart';
|
|
import '../widgets/dialpad.dart';
|
|
|
|
class ActiveCallScreen extends StatefulWidget {
|
|
const ActiveCallScreen({super.key});
|
|
|
|
@override
|
|
State<ActiveCallScreen> createState() => _ActiveCallScreenState();
|
|
}
|
|
|
|
class _ActiveCallScreenState extends State<ActiveCallScreen> {
|
|
bool _showDialpad = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final call = context.watch<CallProvider>();
|
|
final info = call.callInfo;
|
|
|
|
// Pop back when call ends
|
|
if (info.state == CallState.idle) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) Navigator.of(context).pop();
|
|
});
|
|
}
|
|
|
|
return Scaffold(
|
|
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
const Spacer(flex: 2),
|
|
// Caller info
|
|
Text(
|
|
info.callerNumber ?? 'Unknown',
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.headlineMedium
|
|
?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_stateLabel(info.state),
|
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
if (info.state == CallState.connected)
|
|
Text(
|
|
_formatDuration(info.duration),
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const Spacer(flex: 2),
|
|
// Dialpad overlay
|
|
if (_showDialpad)
|
|
Dialpad(
|
|
onDigit: (d) => call.sendDigits(d),
|
|
onClose: () => setState(() => _showDialpad = false),
|
|
),
|
|
// Controls
|
|
if (!_showDialpad)
|
|
CallControls(
|
|
callInfo: info,
|
|
onMute: () => call.toggleMute(),
|
|
onSpeaker: () => call.toggleSpeaker(),
|
|
onHold: () =>
|
|
info.isOnHold ? call.unholdCall() : call.holdCall(),
|
|
onDialpad: () => setState(() => _showDialpad = true),
|
|
onTransfer: () => _showTransferDialog(context, call),
|
|
onHangUp: () => call.hangUp(),
|
|
),
|
|
const Spacer(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _stateLabel(CallState state) {
|
|
switch (state) {
|
|
case CallState.ringing:
|
|
return 'Ringing...';
|
|
case CallState.connecting:
|
|
return 'Connecting...';
|
|
case CallState.connected:
|
|
return 'Connected';
|
|
case CallState.disconnected:
|
|
return 'Disconnected';
|
|
case CallState.idle:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
String _formatDuration(Duration d) {
|
|
final minutes = d.inMinutes.toString().padLeft(2, '0');
|
|
final seconds = (d.inSeconds % 60).toString().padLeft(2, '0');
|
|
return '$minutes:$seconds';
|
|
}
|
|
|
|
void _showTransferDialog(BuildContext context, CallProvider call) {
|
|
final controller = TextEditingController();
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('Transfer Call'),
|
|
content: TextField(
|
|
controller: controller,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Extension or Queue ID',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final target = controller.text.trim();
|
|
if (target.isNotEmpty) {
|
|
call.transferCall(target);
|
|
Navigator.pop(ctx);
|
|
}
|
|
},
|
|
child: const Text('Transfer'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|