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:
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
314
mobile/lib/screens/phone_screen.dart
Normal file
314
mobile/lib/screens/phone_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user