Add FCM push notifications, queue alerts, caller ID fixes, and auto-revert agent status
All checks were successful
Create Release / build (push) Successful in 6s

Server-side:
- Add push credential auto-creation for FCM incoming call notifications
- Add queue alert FCM notifications (data-only for background delivery)
- Add queue alert cancellation on call accept/disconnect
- Fix caller ID to show caller's number instead of Twilio number
- Fix FCM token storage when refresh_token is null
- Add pre_call_status tracking to revert agent status 30s after call ends
- Add SSE fallback polling for mobile app connectivity

Mobile app:
- Add Android telecom permissions and phone account registration
- Add VoiceFirebaseMessagingService for incoming call push handling
- Add insistent queue alert notifications with custom sound
- Fix caller number display on active call screen
- Add caller ID selection dropdown on dashboard
- Add phone numbers endpoint and provider support
- Add unit tests for CallInfo, QueueState, and CallProvider
- Remove local.properties from tracking, add .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-07 17:11:02 -08:00
parent 78e6c5a4ee
commit 4af4be94a4
26 changed files with 1829 additions and 189 deletions

View File

@@ -1,11 +1,16 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:provider/provider.dart';
import 'package:twilio_voice/twilio_voice.dart';
import '../models/queue_state.dart';
import '../providers/agent_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/call_provider.dart';
import '../widgets/agent_status_toggle.dart';
import '../widgets/dialpad.dart';
import '../widgets/queue_card.dart';
import 'active_call_screen.dart';
import 'settings_screen.dart';
class DashboardScreen extends StatefulWidget {
@@ -16,17 +21,74 @@ class DashboardScreen extends StatefulWidget {
}
class _DashboardScreenState extends State<DashboardScreen> {
bool _phoneAccountEnabled = true; // assume true until checked
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<AgentProvider>().refresh();
_checkPhoneAccount();
});
}
Future<void> _checkPhoneAccount() async {
if (!kIsWeb && Platform.isAndroid) {
final enabled = await TwilioVoice.instance.isPhoneAccountEnabled();
if (mounted && !enabled) {
setState(() => _phoneAccountEnabled = false);
_showPhoneAccountDialog();
} else if (mounted) {
setState(() => _phoneAccountEnabled = true);
}
}
}
void _showPhoneAccountDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('Enable Phone Account'),
content: const Text(
'TWP Softphone needs to be enabled as a calling account to make and receive calls.\n\n'
'Tap "Open Settings" below, then find "TWP Softphone" in the list and toggle it ON.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Later'),
),
FilledButton(
onPressed: () async {
Navigator.pop(ctx);
await TwilioVoice.instance.openPhoneAccountSettings();
// Poll until enabled or user comes back
for (int i = 0; i < 30; i++) {
await Future.delayed(const Duration(seconds: 1));
if (!mounted) return;
final enabled = await TwilioVoice.instance.isPhoneAccountEnabled();
if (enabled) {
setState(() => _phoneAccountEnabled = true);
return;
}
}
// Re-check one more time when coming back
_checkPhoneAccount();
},
child: const Text('Open Settings'),
),
],
),
);
}
void _showDialer(BuildContext context) {
final numberController = TextEditingController();
String? selectedCallerId;
final phoneNumbers = context.read<AgentProvider>().phoneNumbers;
// Auto-select first phone number as caller ID
String? selectedCallerId =
phoneNumbers.isNotEmpty ? phoneNumbers.first.phoneNumber : null;
showModalBottomSheet(
context: context,
@@ -35,7 +97,6 @@ class _DashboardScreenState extends State<DashboardScreen> {
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
final phoneNumbers = context.read<AgentProvider>().phoneNumbers;
return StatefulBuilder(
builder: (ctx, setSheetState) {
return Padding(
@@ -72,8 +133,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
),
),
),
// Caller ID selector
if (phoneNumbers.isNotEmpty) ...[
// Caller ID selector (only if multiple numbers)
if (phoneNumbers.length > 1) ...[
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: selectedCallerId,
@@ -82,22 +143,24 @@ class _DashboardScreenState extends State<DashboardScreen> {
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: [
const DropdownMenuItem<String>(
value: null,
child: Text('Default'),
),
...phoneNumbers.map((p) => DropdownMenuItem<String>(
value: p.phoneNumber,
child: Text('${p.friendlyName} (${p.phoneNumber})'),
)),
],
items: phoneNumbers.map((p) => DropdownMenuItem<String>(
value: p.phoneNumber,
child: Text('${p.friendlyName} (${p.phoneNumber})'),
)).toList(),
onChanged: (value) {
setSheetState(() {
selectedCallerId = value;
});
},
),
] else if (phoneNumbers.length == 1) ...[
const SizedBox(height: 8),
Text(
'Caller ID: ${phoneNumbers.first.phoneNumber}',
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
color: Theme.of(ctx).colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 16),
// Dialpad
@@ -125,10 +188,15 @@ class _DashboardScreenState extends State<DashboardScreen> {
label: const Text('Call'),
onPressed: () {
final number = numberController.text.trim();
if (number.isNotEmpty) {
context.read<CallProvider>().makeCall(number, callerId: selectedCallerId);
Navigator.pop(ctx);
if (number.isEmpty) return;
if (selectedCallerId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No caller ID available. Add a phone number first.')),
);
return;
}
context.read<CallProvider>().makeCall(number, callerId: selectedCallerId);
Navigator.pop(ctx);
},
),
const SizedBox(height: 16),
@@ -141,20 +209,99 @@ class _DashboardScreenState extends State<DashboardScreen> {
);
}
void _showQueueCalls(BuildContext context, QueueInfo queue) {
final voiceService = context.read<AuthProvider>().voiceService;
final callProvider = context.read<CallProvider>();
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
return FutureBuilder<List<Map<String, dynamic>>>(
future: voiceService.getQueueCalls(queue.id),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
);
}
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text('Error loading calls: ${snapshot.error}'),
),
);
}
final calls = (snapshot.data ?? [])
.map((c) => QueueCall.fromJson(c))
.toList();
if (calls.isEmpty) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: Text('No calls waiting')),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'${queue.name} - Waiting Calls',
style: Theme.of(context).textTheme.titleMedium,
),
),
const SizedBox(height: 8),
...calls.map((call) => ListTile(
leading: const CircleAvatar(
child: Icon(Icons.phone_in_talk),
),
title: Text(call.fromNumber),
subtitle: Text('Waiting ${_formatWaitTime(call.waitTime)}'),
trailing: FilledButton.icon(
icon: const Icon(Icons.call, size: 18),
label: const Text('Accept'),
onPressed: () {
Navigator.pop(ctx);
callProvider.acceptQueueCall(call.callSid);
// Cancel queue alert notification
FlutterLocalNotificationsPlugin().cancel(9001);
},
),
)),
],
),
);
},
);
},
);
}
String _formatWaitTime(int seconds) {
if (seconds < 60) return '${seconds}s';
final minutes = seconds ~/ 60;
final secs = seconds % 60;
return '${minutes}m ${secs}s';
}
@override
Widget build(BuildContext context) {
final agent = context.watch<AgentProvider>();
final call = context.watch<CallProvider>();
// Navigate to active call screen when a call comes in
if (call.callInfo.isActive) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const ActiveCallScreen()),
(route) => route.isFirst,
);
});
}
// Android Telecom framework handles the call UI via the native InCallUI,
// so we don't navigate to our own ActiveCallScreen.
return Scaffold(
appBar: AppBar(
@@ -185,6 +332,18 @@ class _DashboardScreenState extends State<DashboardScreen> {
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (!_phoneAccountEnabled)
Card(
color: Colors.orange.shade50,
child: ListTile(
leading: Icon(Icons.warning, color: Colors.orange.shade700),
title: const Text('Phone Account Not Enabled'),
subtitle: const Text('Tap to enable calling in settings'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showPhoneAccountDialog(),
),
),
if (!_phoneAccountEnabled) const SizedBox(height: 8),
const AgentStatusToggle(),
const SizedBox(height: 24),
Text('Queues',
@@ -200,7 +359,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
else
...agent.queues.map((q) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: QueueCard(queue: q),
child: QueueCard(
queue: q,
onTap: q.waitingCount > 0
? () => _showQueueCalls(context, q)
: null,
),
)),
],
),