From 575c70dd16e724a3902e14c7ac926578c9b0f0fb Mon Sep 17 00:00:00 2001 From: Sikander Saleem Date: Wed, 21 Oct 2026 12:27:30 +0300 Subject: [PATCH] improvements --- .../copilot/controller/copilot_provider.dart | 114 ++++++++++++++++-- .../screens/copilot_push_to_talk_page.dart | 84 ++++++++----- 2 files changed, 160 insertions(+), 38 deletions(-) diff --git a/lib/modules/copilot/controller/copilot_provider.dart b/lib/modules/copilot/controller/copilot_provider.dart index 4e7fc52f..8582f899 100644 --- a/lib/modules/copilot/controller/copilot_provider.dart +++ b/lib/modules/copilot/controller/copilot_provider.dart @@ -13,6 +13,7 @@ import 'package:test_sa/controllers/api_routes/api_manager.dart'; import 'package:test_sa/controllers/api_routes/urls.dart'; import '../model/copilot_message_model.dart'; +import '../utils/resilient_signalr_http_client.dart'; enum PttStatus { idle, connecting, recording, thinking, playingAudio, error } @@ -369,14 +370,45 @@ class CopilotProvider extends ChangeNotifier { final token = ApiManager.instance.user?.token ?? ''; final url = "${URLs.copilotHubUrl}?access_token=$token"; - _pttLog('connectPushToTalkHub: building connection url=$url'); + _pttLog('connectPushToTalkHub: building connection url=${URLs.copilotHubUrl}'); final httpOp = HttpConnectionOptions( + // Server only accepts LongPolling. transport: HttpTransportType.LongPolling, skipNegotiation: false, - logMessageContent: true, + // Never log message content: it contains base64 audio and is very heavy. + logMessageContent: false, headers: MessageHeaders()..setHeaderValue('Authorization', 'Bearer $token'), + // Keeps the long-poll alive across poll timeouts / network blips instead + // of closing the whole connection (signalr_netcore bug workaround). + httpClient: ResilientSignalRHttpClient(), ); - _pttHub = HubConnectionBuilder().withUrl(url, options: httpOp).withAutomaticReconnect(retryDelays: [2000, 5000, 10000, 20000]).build(); + final hub = HubConnectionBuilder().withUrl(url, options: httpOp).withAutomaticReconnect(retryDelays: [0, 2000, 5000, 10000, 20000]).build(); + // More tolerant timeouts for mobile networks (defaults are 30s / 15s). + hub.serverTimeoutInMilliseconds = 60000; + hub.keepAliveIntervalInMilliseconds = 15000; + _pttHub = hub; + + hub.onreconnecting(({Exception? error}) { + if (!identical(hub, _pttHub)) return; + _pttLog('hub reconnecting error=$error'); + _handleConnectionInterrupted("Connection lost. Reconnecting…"); + }); + hub.onreconnected(({String? connectionId}) { + if (!identical(hub, _pttHub)) return; + _pttLog('hub reconnected connectionId=$connectionId'); + // Server-side recording state is bound to the old connection id, so the + // user must start a new turn. + if (pttStatus == PttStatus.error || pttStatus == PttStatus.connecting) { + pttStatus = PttStatus.idle; + } + pttErrorMessage = null; + notifyListeners(); + }); + hub.onclose(({Exception? error}) { + if (!identical(hub, _pttHub)) return; // intentional stop of an old hub + _pttLog('hub closed error=$error'); + _handleConnectionInterrupted("Connection lost. Hold the mic to reconnect."); + }); _pttHub!.on("RecordingStarted", _onRecordingStarted); _pttHub!.on("RecordingStopped", _onRecordingStopped); @@ -420,20 +452,69 @@ class CopilotProvider extends ChangeNotifier { /// Stops mic streaming and tears down the hub. Does NOT touch [_pttOpQueue] /// so it is safe to call from inside queued operations. Future _stopHubAndCleanup() async { + _cancelResponseWatchdog(); + // Detach first so callbacks (onclose) from this hub are ignored. + final hub = _pttHub; + _pttHub = null; try { await _abortMicStreaming(); - if (_pttHub != null) { - await _pttHub!.stop().timeout(const Duration(seconds: 10), onTimeout: () { + if (hub != null) { + await hub.stop().timeout(const Duration(seconds: 10), onTimeout: () { _pttLog('_stopHubAndCleanup: stop() timed out, forcing cleanup'); }); } } catch (e) { _pttLog('_stopHubAndCleanup: error=$e'); - } finally { - _pttHub = null; } } + /// Called when the hub drops (reconnecting/closed). Stops the mic and frees + /// the UI so it never stays stuck in recording/thinking forever. + void _handleConnectionInterrupted(String message) { + _cancelResponseWatchdog(); + _pttButtonHeld = false; + liveSessionActive = false; + _abortMicStreaming().catchError((_) {}); + if (pttStatus != PttStatus.idle) { + pttStatus = PttStatus.error; + pttErrorMessage = message; + } + notifyListeners(); + } + + /// Re-establishes the connection if it was lost (e.g. after the app was + /// in background). Safe to call any time. + Future ensurePushToTalkConnected() async { + final state = _pttHub?.state; + if (state == HubConnectionState.Connected || state == HubConnectionState.Connecting || state == HubConnectionState.Reconnecting) return; + await connectPushToTalkHub(); + if (_pttHub?.state == HubConnectionState.Connected && pttStatus == PttStatus.error) { + pttStatus = PttStatus.idle; + pttErrorMessage = null; + notifyListeners(); + } + } + + Timer? _pttResponseWatchdog; + static const Duration _responseTimeout = Duration(seconds: 60); + + void _startResponseWatchdog() { + _cancelResponseWatchdog(); + _pttResponseWatchdog = Timer(_responseTimeout, () { + if (pttStatus != PttStatus.thinking) return; + _pttLog('response watchdog fired: no response from server'); + pttStatus = PttStatus.error; + pttErrorMessage = "No response from server. Please try again."; + liveSessionActive = false; + notifyListeners(); + }); + } + + void _cancelResponseWatchdog() { + _pttResponseWatchdog?.cancel(); + _pttResponseWatchdog = null; + } + void startNewPttConversation() { pttSessionId = null; pttMessages = []; @@ -458,6 +539,10 @@ class CopilotProvider extends ChangeNotifier { if (_pttHub == null || _pttHub!.state != HubConnectionState.Connected) { await connectPushToTalkHub(); } + final hub = _pttHub; + if (hub == null || hub.state != HubConnectionState.Connected) { + throw StateError('Push-to-talk hub is not connected'); + } final args = [ { "sessionId": pttSessionId ?? '', @@ -514,6 +599,9 @@ class CopilotProvider extends ChangeNotifier { if (_pttHub != null && _pttHub!.state == HubConnectionState.Connected) { _pttLog('endPushToTalk: invoking StopRecording'); await _pttHub!.invoke("StopRecording"); + _startResponseWatchdog(); + } else { + throw StateError('Connection lost before StopRecording'); } } catch (e) { _pttLog('endPushToTalk: failed error=$e'); @@ -526,6 +614,7 @@ class CopilotProvider extends ChangeNotifier { Future cancelPushToTalk() async { _pttButtonHeld = false; liveSessionActive = false; + _cancelResponseWatchdog(); try { await _abortMicStreaming(); if (_pttHub != null && _pttHub!.state == HubConnectionState.Connected) { @@ -658,8 +747,10 @@ class CopilotProvider extends ChangeNotifier { } Future _sendAudioChunk(Uint8List chunk) async { + final hub = _pttHub; + if (hub == null || hub.state != HubConnectionState.Connected) return; try { - await _pttHub?.send("SendAudioChunk", args: [base64Encode(chunk)]); + await hub.send("SendAudioChunk", args: [base64Encode(chunk)]); } catch (e) { _pttLog('_sendAudioChunk: failed error=$e'); } @@ -707,6 +798,7 @@ class CopilotProvider extends ChangeNotifier { void _onResponseReceived(List? params) { _pttLog('event ResponseReceived params=$params'); + _cancelResponseWatchdog(); final data = _firstArgAsMap(params); final text = data?['voiceAnswer']?.toString() ?? ''; _pttCurrentAssistantMsg = CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.assistant, content: text, timestamp: DateTime.now()); @@ -736,6 +828,7 @@ class CopilotProvider extends ChangeNotifier { void _onRecordingError(List? params) { final msg = (params != null && params.isNotEmpty) ? params.first?.toString() : null; _pttLog('event RecordingError message=$msg'); + _cancelResponseWatchdog(); pttErrorMessage = msg ?? "Recording error occurred"; pttStatus = PttStatus.error; liveSessionActive = false; @@ -757,11 +850,14 @@ class CopilotProvider extends ChangeNotifier { @override void dispose() { _streamSub?.cancel(); + _cancelResponseWatchdog(); _abortMicStreaming(); if (_pttRecorderOpen) { _pttRecorder.closeRecorder(); } - _pttHub?.stop(); + final hub = _pttHub; + _pttHub = null; + hub?.stop(); super.dispose(); } } diff --git a/lib/modules/copilot/screens/copilot_push_to_talk_page.dart b/lib/modules/copilot/screens/copilot_push_to_talk_page.dart index a05de68d..ec02189b 100644 --- a/lib/modules/copilot/screens/copilot_push_to_talk_page.dart +++ b/lib/modules/copilot/screens/copilot_push_to_talk_page.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:test_sa/border_beam/src/beam_style.dart'; +import 'package:test_sa/border_beam/src/border_beam.dart'; import 'package:test_sa/controllers/providers/api/user_provider.dart'; import 'package:test_sa/extensions/context_extension.dart'; import 'package:test_sa/extensions/int_extensions.dart'; @@ -19,7 +21,7 @@ class CopilotPushToTalkPage extends StatefulWidget { State createState() => _CopilotPushToTalkPageState(); } -class _CopilotPushToTalkPageState extends State { +class _CopilotPushToTalkPageState extends State with WidgetsBindingObserver { final ScrollController _scrollController = ScrollController(); CopilotProvider? _provider; @@ -32,6 +34,7 @@ class _CopilotPushToTalkPageState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { final provider = context.read(); provider.startNewPttConversation(); @@ -39,8 +42,19 @@ class _CopilotPushToTalkPageState extends State { }); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + // OS suspends sockets/mic in background; stop any in-progress turn. + _provider?.cancelPushToTalk().catchError((_) {}); + } else if (state == AppLifecycleState.resumed) { + _provider?.ensurePushToTalkConnected().catchError((_) {}); + } + } + @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _scrollController.dispose(); _provider?.cancelPushToTalk().catchError((_) {}); _provider?.disconnectPushToTalkHub().catchError((_) {}); @@ -147,36 +161,48 @@ class _CopilotPushToTalkPageState extends State { style: AppTextStyles.bodyText2.copyWith(color: AppColor.lightTextColor(context)), ), 16.height, - GestureDetector( - onLongPressStart: isBusy - ? null - : (_) { - context.read().beginPushToTalk(context.settingProvider.language!).catchError((_) {}); + Container( + height: 100, + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.all(0), + decoration: BoxDecoration(color: AppColor.background(context), shape: BoxShape.circle, border: Border.all(color: Colors.transparent)), + child: BorderBeam( + size: BeamSize.pulseOutside, + colorVariant: BeamColorVariant.colorful, + reserveGlowSpace: false, + borderRadius: 50, + strength: 0.7, + child: GestureDetector( + onLongPressStart: isBusy + ? null + : (_) { + context.read().beginPushToTalk(context.settingProvider.language!).catchError((_) {}); + }, + onLongPressEnd: (_) { + context.read().endPushToTalk().catchError((_) {}); + }, + onLongPressCancel: () { + context.read().endPushToTalk().catchError((_) {}); }, - onLongPressEnd: (_) { - context.read().endPushToTalk().catchError((_) {}); - }, - onLongPressCancel: () { - context.read().endPushToTalk().catchError((_) {}); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - height: isRecording ? 96 : 80, - width: isRecording ? 96 : 80, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isRecording ? Colors.red : AppColor.primary10, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: isRecording ? 96 : 80, + width: isRecording ? 96 : 80, + child: isBusy + ? Padding( + padding: const EdgeInsets.all(12), + child: ThinkingOrb( + size: 48, + state: ThinkingOrbState.working, + theme: context.isDark ? ThinkingOrbTheme.dark : ThinkingOrbTheme.light, + ), + ) + : Icon(Icons.mic_rounded, color: Colors.white, size: isRecording ? 44 : 36), + ), + ), + // "atoms_logo".toSvgAsset().paddingAll(16), ), - child: isBusy - ? Padding( - padding: const EdgeInsets.all(12), - child: ThinkingOrb( - size: 48, - state: ThinkingOrbState.working, - theme: !context.isDark ? ThinkingOrbTheme.dark : ThinkingOrbTheme.light, - ), - ) - : Icon(Icons.mic_rounded, color: Colors.white, size: isRecording ? 44 : 36), ), ), 24.height,