Replace native Twilio Voice SDK with WebView-based softphone

Rewrites the mobile app from a native Twilio Voice SDK integration
(Android Telecom/ConnectionService) to a thin WebView shell that loads
a standalone browser phone page from WordPress. This eliminates the
buggy Android phone account registration, fixes frequent logouts by
using 7-day WP session cookies instead of JWT tokens, and maintains
all existing call features (dialpad, queues, hold, transfer, requeue,
recording, caller ID, agent status).

Server-side:
- Add class-twp-mobile-phone-page.php: standalone /twp-phone/ endpoint
  with mobile-optimized UI, dark mode, tab navigation, and Flutter
  WebView JS bridge
- Extend auth cookie to 7 days for phone agents
- Add WP AJAX handler for FCM token registration (cookie auth)

Flutter app (v2.0.0):
- Replace 18 native files with 5-file WebView shell
- Login via wp-login.php in WebView (auto-detect redirect on success)
- Full-screen WebView with auto microphone grant for WebRTC
- FCM push notifications preserved for queue alerts
- Remove: twilio_voice, dio, provider, JWT auth, SSE, native call UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-10 09:11:25 -07:00
parent 4af4be94a4
commit 621b0890a9
37 changed files with 2744 additions and 2663 deletions

View File

@@ -1,137 +0,0 @@
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'),
),
],
),
);
}
}

View File

@@ -1,374 +0,0 @@
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 'settings_screen.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
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();
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,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setSheetState) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
top: 16,
left: 16,
right: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Number display
TextField(
controller: numberController,
keyboardType: TextInputType.phone,
autofillHints: const [AutofillHints.telephoneNumber],
textAlign: TextAlign.center,
style: Theme.of(ctx).textTheme.headlineSmall,
decoration: InputDecoration(
hintText: 'Enter phone number',
suffixIcon: IconButton(
icon: const Icon(Icons.backspace_outlined),
onPressed: () {
final text = numberController.text;
if (text.isNotEmpty) {
numberController.text =
text.substring(0, text.length - 1);
numberController.selection = TextSelection.fromPosition(
TextPosition(offset: numberController.text.length),
);
}
},
),
),
),
// Caller ID selector (only if multiple numbers)
if (phoneNumbers.length > 1) ...[
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: selectedCallerId,
decoration: const InputDecoration(
labelText: 'Caller ID',
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
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
Dialpad(
onDigit: (digit) {
numberController.text += digit;
numberController.selection = TextSelection.fromPosition(
TextPosition(offset: numberController.text.length),
);
},
onClose: () => Navigator.pop(ctx),
),
const SizedBox(height: 8),
// Call button
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
icon: const Icon(Icons.call),
label: const Text('Call'),
onPressed: () {
final number = numberController.text.trim();
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),
],
),
);
},
);
},
);
}
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>();
// Android Telecom framework handles the call UI via the native InCallUI,
// so we don't navigate to our own ActiveCallScreen.
return Scaffold(
appBar: AppBar(
title: const Text('TWP Softphone'),
actions: [
// SSE connection indicator
Padding(
padding: const EdgeInsets.only(right: 8),
child: Icon(
Icons.circle,
size: 12,
color: agent.sseConnected ? Colors.green : Colors.red,
),
),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const SettingsScreen())),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showDialer(context),
child: const Icon(Icons.phone),
),
body: RefreshIndicator(
onRefresh: () => agent.refresh(),
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',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (agent.queues.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: Text('No queues assigned')),
),
)
else
...agent.queues.map((q) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: QueueCard(
queue: q,
onTap: q.waitingCount > 0
? () => _showQueueCalls(context, q)
: null,
),
)),
],
),
),
);
}
}

View File

@@ -1,22 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:webview_flutter/webview_flutter.dart';
/// Login screen that loads wp-login.php in a WebView.
///
/// When the user successfully logs in, WordPress redirects to /twp-phone/.
/// We detect that URL change and report login success to the parent.
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
final void Function(String serverUrl) onLoginSuccess;
const LoginScreen({super.key, required this.onLoginSuccess});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
static const _storage = FlutterSecureStorage();
final _formKey = GlobalKey<FormState>();
final _serverController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
bool _showWebView = false;
bool _webViewLoading = true;
String? _error;
late WebViewController _webViewController;
@override
void initState() {
@@ -25,40 +32,107 @@ class _LoginScreenState extends State<LoginScreen> {
}
Future<void> _loadSavedServer() async {
const storage = FlutterSecureStorage();
final saved = await storage.read(key: 'server_url');
final saved = await _storage.read(key: 'server_url');
if (saved != null && mounted) {
_serverController.text = saved;
}
}
void _submit() {
void _startLogin() {
if (!_formKey.currentState!.validate()) return;
var serverUrl = _serverController.text.trim();
if (!serverUrl.startsWith('http')) {
serverUrl = 'https://$serverUrl';
}
// Remove trailing slash
serverUrl = serverUrl.replaceAll(RegExp(r'/+$'), '');
TextInput.finishAutofillContext();
context.read<AuthProvider>().login(
serverUrl,
_usernameController.text.trim(),
_passwordController.text,
);
setState(() {
_showWebView = true;
_webViewLoading = true;
_error = null;
});
final loginUrl =
'$serverUrl/wp-login.php?redirect_to=${Uri.encodeComponent('$serverUrl/twp-phone/')}';
_webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (url) {
// Check if we've been redirected to the phone page (login success)
if (url.contains('/twp-phone/') || url.endsWith('/twp-phone')) {
_onLoginComplete(serverUrl);
}
},
onPageFinished: (url) {
if (mounted) {
setState(() => _webViewLoading = false);
}
// Also check on page finish in case redirect was instant
if (url.contains('/twp-phone/') || url.endsWith('/twp-phone')) {
_onLoginComplete(serverUrl);
}
},
onWebResourceError: (error) {
if (mounted) {
setState(() {
_showWebView = false;
_error =
'Could not connect to server: ${error.description}';
});
}
},
),
)
..setUserAgent('TWPMobile/2.0 (Android; WebView)')
..loadRequest(Uri.parse(loginUrl));
}
Future<void> _onLoginComplete(String serverUrl) async {
// Save server URL for next launch
await _storage.write(key: 'server_url', value: serverUrl);
if (mounted) {
widget.onLoginSuccess(serverUrl);
}
}
void _cancelLogin() {
setState(() {
_showWebView = false;
_error = null;
});
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
if (_showWebView) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _cancelLogin,
),
title: const Text('Sign In'),
),
body: Stack(
children: [
WebViewWidget(controller: _webViewController),
if (_webViewLoading)
const Center(child: CircularProgressIndicator()),
],
),
);
}
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: AutofillGroup(
child: Form(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -87,42 +161,10 @@ class _LoginScreenState extends State<LoginScreen> {
validator: (v) =>
v == null || v.trim().isEmpty ? 'Required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
autofillHints: const [AutofillHints.username],
validator: (v) =>
v == null || v.trim().isEmpty ? 'Required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_obscurePassword
? Icons.visibility_off
: Icons.visibility),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
),
obscureText: _obscurePassword,
autofillHints: const [AutofillHints.password],
validator: (v) =>
v == null || v.isEmpty ? 'Required' : null,
),
if (auth.error != null) ...[
if (_error != null) ...[
const SizedBox(height: 16),
Text(
auth.error!,
_error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error),
),
@@ -132,23 +174,13 @@ class _LoginScreenState extends State<LoginScreen> {
width: double.infinity,
height: 48,
child: FilledButton(
onPressed: auth.state == AuthState.authenticating
? null
: _submit,
child: auth.state == AuthState.authenticating
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Text('Connect'),
onPressed: _startLogin,
child: const Text('Connect'),
),
),
],
),
),
),
),
),
),
@@ -158,8 +190,6 @@ class _LoginScreenState extends State<LoginScreen> {
@override
void dispose() {
_serverController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import '../services/push_notification_service.dart';
/// Full-screen WebView that loads the TWP phone page.
///
/// Handles:
/// - Microphone permission grants for WebRTC
/// - JavaScript bridge (TwpMobile channel) for native communication
/// - Session expiry detection (redirect to wp-login.php)
/// - Back button confirmation to prevent accidental exit
/// - Network error retry UI
class PhoneScreen extends StatefulWidget {
final String serverUrl;
final VoidCallback onLogout;
final VoidCallback onSessionExpired;
const PhoneScreen({
super.key,
required this.serverUrl,
required this.onLogout,
required this.onSessionExpired,
});
@override
State<PhoneScreen> createState() => _PhoneScreenState();
}
class _PhoneScreenState extends State<PhoneScreen> with WidgetsBindingObserver {
late final WebViewController _controller;
late final PushNotificationService _pushService;
bool _loading = true;
bool _hasError = false;
String? _errorMessage;
bool _sessionExpired = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_pushService = PushNotificationService();
_initWebView();
_initPush();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
Future<void> _initPush() async {
await _pushService.initialize();
}
void _initWebView() {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setUserAgent('TWPMobile/2.0 (Android; WebView)')
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (url) {
if (mounted) {
setState(() {
_loading = true;
_hasError = false;
});
}
// Detect session expiry: if we get redirected to wp-login.php
if (url.contains('/wp-login.php')) {
_sessionExpired = true;
}
},
onPageFinished: (url) {
if (mounted) {
setState(() => _loading = false);
}
if (_sessionExpired && url.contains('/wp-login.php')) {
widget.onSessionExpired();
return;
}
_sessionExpired = false;
// Inject the FCM token into the page if available
_injectFcmToken();
},
onWebResourceError: (error) {
// Only handle main frame errors
if (error.isForMainFrame ?? true) {
if (mounted) {
setState(() {
_loading = false;
_hasError = true;
_errorMessage = error.description;
});
}
}
},
onNavigationRequest: (request) {
// Allow all navigation within our server
if (request.url.startsWith(widget.serverUrl)) {
return NavigationDecision.navigate;
}
// Allow blob: and data: URLs (for downloads, etc.)
if (request.url.startsWith('blob:') ||
request.url.startsWith('data:')) {
return NavigationDecision.navigate;
}
// Block external navigation
return NavigationDecision.prevent;
},
),
)
..addJavaScriptChannel(
'TwpMobile',
onMessageReceived: _handleJsMessage,
);
// Configure Android-specific settings
final androidController =
_controller.platform as AndroidWebViewController;
// Auto-grant microphone permission for WebRTC calls
androidController.setOnPlatformPermissionRequest(
(PlatformWebViewPermissionRequest request) {
request.grant();
},
);
// Allow media playback without user gesture (for ringtones)
androidController.setMediaPlaybackRequiresUserGesture(false);
// Load the phone page
final phoneUrl = '${widget.serverUrl}/twp-phone/';
_controller.loadRequest(Uri.parse(phoneUrl));
}
void _handleJsMessage(JavaScriptMessage message) {
final msg = message.message;
if (msg == 'onSessionExpired') {
widget.onSessionExpired();
} else if (msg == 'requestFcmToken') {
_injectFcmToken();
} else if (msg == 'onPageReady') {
// Phone page loaded successfully
_injectFcmToken();
}
}
Future<void> _injectFcmToken() async {
final token = _pushService.fcmToken;
if (token != null) {
// Send the FCM token to the web page via the TwpMobile bridge
await _controller.runJavaScript(
'if (window.TwpMobile && window.TwpMobile.setFcmToken) { window.TwpMobile.setFcmToken("$token"); }',
);
}
}
Future<void> _retry() async {
setState(() {
_hasError = false;
_loading = true;
});
final phoneUrl = '${widget.serverUrl}/twp-phone/';
await _controller.loadRequest(Uri.parse(phoneUrl));
}
Future<bool> _onWillPop() async {
// Check if WebView can go back
if (await _controller.canGoBack()) {
await _controller.goBack();
return false;
}
// Show confirmation dialog
if (!mounted) return true;
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Exit'),
content: const Text('Are you sure you want to exit the phone?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Exit'),
),
],
),
);
return result ?? false;
}
void _showMenu() {
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Reload'),
onTap: () {
Navigator.pop(context);
_controller.reload();
},
),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Logout'),
onTap: () {
Navigator.pop(context);
_confirmLogout();
},
),
],
),
),
);
}
void _confirmLogout() async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Logout'),
content: const Text(
'This will clear your session. You will need to sign in again.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Logout'),
),
],
),
);
if (result == true) {
// Clear WebView cookies
await WebViewCookieManager().clearCookies();
widget.onLogout();
}
}
@override
Widget build(BuildContext context) {
// ignore: deprecated_member_use
return WillPopScope(
onWillPop: _onWillPop,
child: Scaffold(
body: SafeArea(
child: Stack(
children: [
if (!_hasError) WebViewWidget(controller: _controller),
if (_hasError) _buildErrorView(),
if (_loading && !_hasError)
const Center(child: CircularProgressIndicator()),
],
),
),
floatingActionButton: (!_hasError && !_loading)
? FloatingActionButton.small(
onPressed: _showMenu,
child: const Icon(Icons.more_vert),
)
: null,
),
);
}
Widget _buildErrorView() {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off, size: 64, color: Colors.grey),
const SizedBox(height: 16),
const Text(
'Connection Error',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
_errorMessage ?? 'Could not load the phone page.',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _retry,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
const SizedBox(height: 12),
TextButton(
onPressed: widget.onLogout,
child: const Text('Change Server'),
),
],
),
),
);
}
}

View File

@@ -1,68 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../providers/auth_provider.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
String? _serverUrl;
@override
void initState() {
super.initState();
_loadServerUrl();
}
Future<void> _loadServerUrl() async {
const storage = FlutterSecureStorage();
final url = await storage.read(key: 'server_url');
if (mounted) setState(() => _serverUrl = url);
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: [
ListTile(
leading: const Icon(Icons.dns),
title: const Text('Server'),
subtitle: Text(_serverUrl ?? 'Not configured'),
),
if (auth.user != null) ...[
ListTile(
leading: const Icon(Icons.person),
title: const Text('User'),
subtitle: Text(auth.user!.displayName),
),
ListTile(
leading: const Icon(Icons.badge),
title: const Text('Login'),
subtitle: Text(auth.user!.login),
),
],
const Divider(),
ListTile(
leading: const Icon(Icons.logout, color: Colors.red),
title: const Text('Logout', style: TextStyle(color: Colors.red)),
onTap: () async {
await auth.logout();
if (context.mounted) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
},
),
],
),
);
}
}