From fc6c9648f9d394f7d5e6c5d05315d830365e3d6d Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Fri, 17 Apr 2026 08:04:03 -0400 Subject: [PATCH 1/3] fix(voice): eliminate dual-recorder conflict and defunct element crashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VadHandler is now the sole mic owner — removed separate AudioRecorder that caused audio focus contention on Android. Audio from onSpeechEnd is encoded as WAV for Whisper. Provider switched to autoDispose to match widget lifecycle, preventing defunct element assertions. Recording UI deferred until mic is open. Co-Authored-By: Claude Opus 4.6 --- lib/app.dart | 1 - lib/data/api/voice_api.dart | 6 +- lib/providers/voice_provider.dart | 259 +++++++++++------- lib/screens/briefing/briefing_screen.dart | 1 - lib/screens/chat/chat_screen.dart | 2 - linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 +- windows/flutter/generated_plugins.cmake | 1 + 8 files changed, 160 insertions(+), 115 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 63fa63a..763ac89 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -529,7 +529,6 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { @override void dispose() { _controller.dispose(); - ref.read(voiceProvider.notifier).exitVoiceMode(); super.dispose(); } diff --git a/lib/data/api/voice_api.dart b/lib/data/api/voice_api.dart index 6f57378..907ab01 100644 --- a/lib/data/api/voice_api.dart +++ b/lib/data/api/voice_api.dart @@ -40,7 +40,7 @@ class VoiceApi { } } - /// POST WebM/Opus audio bytes and return the transcript string. + /// POST audio bytes (WAV) and return the transcript string. /// [context] is optional recent conversation text passed as initial_prompt /// to Whisper, reducing mishearings of domain-specific words. /// Returns empty string on empty or error response. @@ -49,8 +49,8 @@ class VoiceApi { final fields = { 'audio': MultipartFile.fromBytes( audioBytes, - filename: 'audio.m4a', - contentType: DioMediaType('audio', 'mp4'), + filename: 'audio.wav', + contentType: DioMediaType('audio', 'wav'), ), if (context != null && context.isNotEmpty) 'context': context, }; diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 2cd07d0..0df349b 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -1,13 +1,13 @@ import 'dart:async'; import 'dart:collection'; import 'dart:io'; +import 'dart:math'; import 'dart:typed_data'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:just_audio/just_audio.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:record/record.dart'; import 'package:vad/vad.dart'; import 'api_client_provider.dart'; @@ -52,6 +52,53 @@ String stripMarkdownForTts(String text) { .trim(); } +/// Encode float PCM samples (-1..1) as a 16-bit mono WAV at 16 kHz. +Uint8List encodeWav(List samples, {int sampleRate = 16000}) { + final numSamples = samples.length; + final dataSize = numSamples * 2; + final fileSize = 44 + dataSize; + final buf = ByteData(fileSize); + + // RIFF header + buf.setUint8(0, 0x52); // R + buf.setUint8(1, 0x49); // I + buf.setUint8(2, 0x46); // F + buf.setUint8(3, 0x46); // F + buf.setUint32(4, fileSize - 8, Endian.little); + buf.setUint8(8, 0x57); // W + buf.setUint8(9, 0x41); // A + buf.setUint8(10, 0x56); // V + buf.setUint8(11, 0x45); // E + + // fmt chunk + buf.setUint8(12, 0x66); // f + buf.setUint8(13, 0x6D); // m + buf.setUint8(14, 0x74); // t + buf.setUint8(15, 0x20); // (space) + buf.setUint32(16, 16, Endian.little); // chunk size + buf.setUint16(20, 1, Endian.little); // PCM format + buf.setUint16(22, 1, Endian.little); // mono + buf.setUint32(24, sampleRate, Endian.little); + buf.setUint32(28, sampleRate * 2, Endian.little); // byte rate + buf.setUint16(32, 2, Endian.little); // block align + buf.setUint16(34, 16, Endian.little); // bits per sample + + // data chunk + buf.setUint8(36, 0x64); // d + buf.setUint8(37, 0x61); // a + buf.setUint8(38, 0x74); // t + buf.setUint8(39, 0x61); // a + buf.setUint32(40, dataSize, Endian.little); + + for (var i = 0; i < numSamples; i++) { + final clamped = samples[i].clamp(-1.0, 1.0); + final int16 = (clamped * 32767).round().clamp(-32768, 32767); + buf.setInt16(44 + i * 2, int16, Endian.little); + } + + return buf.buffer.asUint8List(); +} + // ── State ───────────────────────────────────────────────────────────────────── enum VoiceMode { idle, recording, transcribing, playing } @@ -60,9 +107,7 @@ class VoiceState { final VoiceMode mode; final bool voiceModeActive; final bool available; - /// Normalized mic amplitude 0.0–1.0 while recording. Drives the live - /// pulse on VoiceMicButton so the user has obvious feedback that audio - /// is actually being picked up. + /// Normalized mic amplitude 0.0–1.0 while recording. final double amplitude; const VoiceState({ @@ -89,23 +134,25 @@ class VoiceState { // ── Provider ────────────────────────────────────────────────────────────────── final voiceProvider = - NotifierProvider(VoiceNotifier.new); + NotifierProvider.autoDispose(VoiceNotifier.new); // ── Notifier ────────────────────────────────────────────────────────────────── class VoiceNotifier extends Notifier { - // Audio I/O - AudioRecorder? _recorder; + // Audio playback AudioPlayer? _player; - StreamSubscription? _amplitudeSubscription; - // VAD-based speech detection + // VAD — sole owner of the microphone VadHandler? _vadHandler; StreamSubscription? _vadSpeechStartSub; StreamSubscription>? _vadSpeechEndSub; + StreamSubscription<({double isSpeech, double notSpeech, List frame})>? + _vadFrameSub; + StreamSubscription? _vadErrorSub; bool _speechDetected = false; int _speechStartMs = 0; static const _vadGraceMs = 1500; + bool _disposed = false; // Voice mode callbacks Future Function(String transcript)? _onTranscript; @@ -117,11 +164,10 @@ class VoiceNotifier extends Notifier { int _lastSeenLength = 0; bool _streamComplete = false; - // Last complete assistant response — passed to Whisper as initial_prompt - // to reduce STT mishearings of domain-specific words. + // Whisper context hint String _lastAssistantContent = ''; - // Empty transcript counter — show feedback after consecutive blanks + // Empty transcript counter int _emptyTranscriptCount = 0; // TTS playback queue @@ -132,21 +178,21 @@ class VoiceNotifier extends Notifier { @override VoiceState build() { + _disposed = false; _player = AudioPlayer(); ref.onDispose(() { - _amplitudeSubscription?.cancel(); - _recorder?.dispose(); + _disposed = true; + _cancelSubscriptions(); + _vadHandler?.dispose(); + _vadHandler = null; _player?.dispose(); + _player = null; }); return const VoiceState(); } // ── Public API ────────────────────────────────────────────────────────────── - /// Enter voice mode. Checks server availability and mic permission first. - /// [onTranscript] is called with the transcript when a recording completes. - /// [enableTts] — if true, TTS plays when [feedContent] is called. - /// [onError] — called with a human-readable message on failure. Future enterVoiceMode({ required Future Function(String transcript) onTranscript, bool enableTts = false, @@ -160,27 +206,23 @@ class VoiceNotifier extends Notifier { return; } - // Check server availability — STT is required, TTS is optional. try { final status = await ref.read(voiceRepositoryProvider).checkStatus(); if (!status.enabled || !status.stt) { onError('Speech-to-text not available on this server'); return; } - // Downgrade to STT-only when TTS is unavailable if (!status.tts) enableTts = false; } catch (_) { onError('Could not reach voice service'); return; } - // Check microphone permission var permStatus = await Permission.microphone.request(); if (permStatus == PermissionStatus.permanentlyDenied) { onError('Microphone blocked — opening settings'); final opened = await openAppSettings(); if (!opened) return; - // Re-check after user returns from settings permStatus = await Permission.microphone.status; } if (!permStatus.isGranted) { @@ -198,27 +240,11 @@ class VoiceNotifier extends Notifier { await _startListening(); } - /// Exit voice mode, stop all recording and TTS. void exitVoiceMode() { - _amplitudeSubscription?.cancel(); - _amplitudeSubscription = null; - _recorder?.stop(); - _stopVad(); - _player?.stop(); - _ttsQueue.clear(); - _ttsPlaying = false; - _sentenceBuffer = ''; - _lastSeenLength = 0; - _streamComplete = false; - _speechDetected = false; - _onTranscript = null; - _onError = null; - state = const VoiceState(); + _cleanup(); + if (!_disposed) state = const VoiceState(); } - /// Feed streaming assistant content for TTS synthesis. - /// Call from screens with the full [fullContent] string on each update. - /// Set [isComplete] to true when the stream has finished. void feedContent(String fullContent, {required bool isComplete}) { if (!state.voiceModeActive || !_enableTts) return; @@ -237,112 +263,131 @@ class VoiceNotifier extends Notifier { } } + // ── Internal helpers ──────────────────────────────────────────────────────── + + void _cleanup() { + _cancelSubscriptions(); + final handler = _vadHandler; + _vadHandler = null; + handler?.dispose(); + _player?.stop(); + _ttsQueue.clear(); + _ttsPlaying = false; + _sentenceBuffer = ''; + _lastSeenLength = 0; + _streamComplete = false; + _speechDetected = false; + _onTranscript = null; + _onError = null; + } + + void _cancelSubscriptions() { + _vadSpeechStartSub?.cancel(); + _vadSpeechStartSub = null; + _vadSpeechEndSub?.cancel(); + _vadSpeechEndSub = null; + _vadFrameSub?.cancel(); + _vadFrameSub = null; + _vadErrorSub?.cancel(); + _vadErrorSub = null; + } + // ── Internal recording ────────────────────────────────────────────────────── Future _startListening() async { - if (!state.voiceModeActive) return; + if (_disposed || !state.voiceModeActive) return; _speechDetected = false; _speechStartMs = 0; - state = state.copyWith(mode: VoiceMode.recording); - - final dir = _tempDir ?? await getTemporaryDirectory(); - final path = - '${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a'; try { - _recorder?.dispose(); - _recorder = AudioRecorder(); - - if (!await _recorder!.hasPermission()) { - _onError?.call('Microphone permission was revoked'); - exitVoiceMode(); - return; - } - - await _recorder!.start( - const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000), - path: path, - ); - - _amplitudeSubscription?.cancel(); - _amplitudeSubscription = _recorder! - .onAmplitudeChanged(const Duration(milliseconds: 200)) - .listen(_onAmplitude); - - // VAD for speech detection — uses its own AudioRecorder internally await _stopVad(); _vadHandler = VadHandler.create(); + _vadSpeechStartSub = _vadHandler!.onSpeechStart.listen((_) { + if (_disposed || !state.voiceModeActive) return; if (!_speechDetected) { _speechDetected = true; _speechStartMs = DateTime.now().millisecondsSinceEpoch; } }); - _vadSpeechEndSub = _vadHandler!.onSpeechEnd.listen((_) { - if (!state.voiceModeActive) return; + + _vadSpeechEndSub = _vadHandler!.onSpeechEnd.listen((audioSamples) { + if (_disposed || !state.voiceModeActive) return; final now = DateTime.now().millisecondsSinceEpoch; final sinceStart = _speechStartMs > 0 ? now - _speechStartMs : 0; if (_speechDetected && sinceStart >= _vadGraceMs) { - _amplitudeSubscription?.cancel(); - _amplitudeSubscription = null; _stopVad(); - _handleSilence(); + _handleSpeechEnd(audioSamples); } }); + + _vadFrameSub = _vadHandler!.onFrameProcessed.listen((event) { + if (_disposed || !state.voiceModeActive) return; + final frame = event.frame; + if (frame.isEmpty) return; + double sumSq = 0; + for (final s in frame) { + sumSq += s * s; + } + final rms = sqrt(sumSq / frame.length); + final norm = (rms * 4.0).clamp(0.0, 1.0); + if ((norm - state.amplitude).abs() > 0.02) { + state = state.copyWith(amplitude: norm); + } + }); + + _vadErrorSub = _vadHandler!.onError.listen((msg) { + if (_disposed) return; + _onError?.call('VAD error: $msg'); + }); + await _vadHandler!.startListening(model: 'v5'); + + // Only show recording UI after the mic is actually open. + if (!_disposed && state.voiceModeActive) { + state = state.copyWith(mode: VoiceMode.recording, amplitude: 0.0); + } } catch (e) { _onError?.call('Microphone error: $e'); - exitVoiceMode(); + _cleanup(); + if (!_disposed) state = const VoiceState(); } } Future _stopVad() async { - await _vadSpeechStartSub?.cancel(); - _vadSpeechStartSub = null; - await _vadSpeechEndSub?.cancel(); - _vadSpeechEndSub = null; + _cancelSubscriptions(); if (_vadHandler != null) { - await _vadHandler!.dispose(); + final handler = _vadHandler!; _vadHandler = null; + await handler.dispose(); } } - void _onAmplitude(Amplitude event) { - if (!state.voiceModeActive) return; - final db = event.current; - if (db.isNaN || db.isInfinite) return; - final norm = ((db + 60.0) / 60.0).clamp(0.0, 1.0); - if ((norm - state.amplitude).abs() > 0.02) { - state = state.copyWith(amplitude: norm); - } - } - - Future _handleSilence() async { - if (!state.voiceModeActive) return; + Future _handleSpeechEnd(List audioSamples) async { + if (_disposed || !state.voiceModeActive) return; state = state.copyWith(mode: VoiceMode.transcribing); - final path = await _recorder!.stop(); - if (path == null || !state.voiceModeActive) return; - try { - final bytes = await File(path).readAsBytes(); - await File(path).delete().catchError((_) => File(path)); + final wavBytes = encodeWav(audioSamples); - if (!state.voiceModeActive) return; + if (_disposed || !state.voiceModeActive) return; final transcript = await ref.read(voiceRepositoryProvider).transcribe( - bytes, - context: _lastAssistantContent.isNotEmpty ? _lastAssistantContent : null, + wavBytes, + context: + _lastAssistantContent.isNotEmpty ? _lastAssistantContent : null, ); - if (!state.voiceModeActive) return; + if (_disposed || !state.voiceModeActive) return; if (transcript.isEmpty) { _emptyTranscriptCount++; if (_emptyTranscriptCount >= 3) { _onError?.call('No speech detected — tap the mic to try again'); - exitVoiceMode(); + _cleanup(); + if (!_disposed) state = const VoiceState(); + return; } await _startListening(); @@ -350,24 +395,25 @@ class VoiceNotifier extends Notifier { } _emptyTranscriptCount = 0; - // Reset TTS state for this new turn _sentenceBuffer = ''; _lastSeenLength = 0; _streamComplete = false; - if (_enableTts) { + if (_enableTts && !_disposed) { state = state.copyWith(mode: VoiceMode.playing); } await _onTranscript?.call(transcript); - // If TTS is not enabled, loop immediately - if (!_enableTts && state.voiceModeActive) { - await _startListening(); + // In STT-only mode (no TTS), return to idle after transcript is sent. + // The user taps the mic again to record another message. + if (!_enableTts && !_disposed && state.voiceModeActive) { + state = state.copyWith(mode: VoiceMode.idle); } } catch (e) { _onError?.call('Voice error: transcription failed'); - exitVoiceMode(); + _cleanup(); + if (!_disposed) state = const VoiceState(); } } @@ -395,7 +441,7 @@ class VoiceNotifier extends Notifier { try { final wavBytes = await ref.read(voiceRepositoryProvider).synthesise(text); - if (!state.voiceModeActive) return; + if (_disposed || !state.voiceModeActive) return; _ttsQueue.add(wavBytes); if (!_ttsPlaying) _drainTtsQueue(); } catch (_) { @@ -434,6 +480,7 @@ class VoiceNotifier extends Notifier { } void _checkRestartListening() { + if (_disposed) return; if (_streamComplete && _ttsQueue.isEmpty && !_ttsPlaying && diff --git a/lib/screens/briefing/briefing_screen.dart b/lib/screens/briefing/briefing_screen.dart index d2d3080..41678d0 100644 --- a/lib/screens/briefing/briefing_screen.dart +++ b/lib/screens/briefing/briefing_screen.dart @@ -76,7 +76,6 @@ class _BriefingScreenState extends ConsumerState WidgetsBinding.instance.removeObserver(this); _controller.dispose(); _scrollController.dispose(); - ref.read(voiceProvider.notifier).exitVoiceMode(); super.dispose(); } diff --git a/lib/screens/chat/chat_screen.dart b/lib/screens/chat/chat_screen.dart index 89347f9..af341f5 100644 --- a/lib/screens/chat/chat_screen.dart +++ b/lib/screens/chat/chat_screen.dart @@ -68,8 +68,6 @@ class _ChatScreenState extends ConsumerState WidgetsBinding.instance.removeObserver(this); _controller.dispose(); _scrollController.dispose(); - // Exit voice mode if the user navigates away mid-session. - ref.read(voiceProvider.notifier).exitVoiceMode(); super.dispose(); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 641253b..61405b3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + vad ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 27878f5..74ec380 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,7 +11,7 @@ import flutter_timezone import just_audio import open_file_mac import package_info_plus -import record_darwin +import record_macos import shared_preferences_foundation import url_launcher_macos @@ -22,7 +22,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - RecordPlugin.register(with: registry.registrar(forPlugin: "RecordPlugin")) + RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index f53b4aa..c57f7ba 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + vad ) set(PLUGIN_BUNDLED_LIBRARIES) From 70a327919266df1ca2c354dd2af7d26b7b10bae6 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Fri, 17 Apr 2026 12:38:48 -0400 Subject: [PATCH 2/3] feat: background update download, snackbar prompt, chat image rendering Update provider: auto-downloads APK in background after finding a newer version, prompts via snackbar only when ready, cleans up old APKs on startup. Replaces modal dialog with dismissible snackbar. Chat bubble: resolve relative image URLs (/api/images/{id}) against server base URL with auth cookies so search_images results render on the phone app. Co-Authored-By: Claude Opus 4.6 --- lib/app.dart | 77 +++----------- lib/providers/update_provider.dart | 118 ++++++++++++++++------ lib/screens/settings/settings_screen.dart | 8 +- lib/widgets/chat_message_bubble.dart | 85 +++++++++++++++- 4 files changed, 187 insertions(+), 101 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 763ac89..56f4010 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -205,6 +205,8 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { super.initState(); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { + // Clean up any leftover APKs from previous update cycles. + ref.read(updateProvider.notifier).cleanup(); // Silent update check — only if we haven't already checked this session. final repoUrl = ref.read(forgejoRepoUrlProvider); if (repoUrl != null && repoUrl.isNotEmpty) { @@ -318,66 +320,15 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { ); } - void _showUpdateDialog(UpdateState update) { - showDialog( - context: context, - builder: (dialogContext) => Consumer( - builder: (context, ref, _) { - final state = ref.watch(updateProvider); - final isDownloading = state.status == UpdateStatus.downloading; - return AlertDialog( - title: const Text('Update available'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Version ${state.latestVersion ?? '?'} is ready to install.'), - if (state.currentVersion != null) - Text( - 'Installed: v${state.currentVersion}', - style: Theme.of(context).textTheme.bodySmall, - ), - if (isDownloading) ...[ - const SizedBox(height: 16), - LinearProgressIndicator( - value: state.downloadProgress > 0 - ? state.downloadProgress - : null, - ), - const SizedBox(height: 4), - Text( - 'Downloading… ' - '${(state.downloadProgress * 100).toStringAsFixed(0)}%', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - if (state.status == UpdateStatus.error && - state.errorMessage != null) ...[ - const SizedBox(height: 12), - Text( - state.errorMessage!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), - ), - ], - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: const Text('Later'), - ), - if (!isDownloading && state.downloadUrl != null) - FilledButton( - onPressed: () => ref - .read(updateProvider.notifier) - .downloadAndInstall(), - child: const Text('Download & Install'), - ), - ], - ); - }, + void _showUpdateSnackbar(UpdateState update) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('v${update.latestVersion} ready to install'), + duration: const Duration(seconds: 6), + action: SnackBarAction( + label: 'Install', + onPressed: () => ref.read(updateProvider.notifier).install(), + ), ), ); } @@ -386,10 +337,10 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { Widget build(BuildContext context) { // Show update dialog once when a new version is detected. ref.listen(updateProvider, (prev, next) { - if (next.status == UpdateStatus.available && - prev?.status != UpdateStatus.available) { + if (next.status == UpdateStatus.readyToInstall && + prev?.status != UpdateStatus.readyToInstall) { WidgetsBinding.instance - .addPostFrameCallback((_) => _showUpdateDialog(next)); + .addPostFrameCallback((_) => _showUpdateSnackbar(next)); } }); final location = GoRouterState.of(context).matchedLocation; diff --git a/lib/providers/update_provider.dart b/lib/providers/update_provider.dart index b5aac9c..dcd6b04 100644 --- a/lib/providers/update_provider.dart +++ b/lib/providers/update_provider.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:open_file/open_file.dart'; @@ -5,7 +7,14 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; -enum UpdateStatus { idle, checking, available, downloading, upToDate, error } +enum UpdateStatus { + idle, + checking, + downloading, + readyToInstall, + upToDate, + error, +} class UpdateState { final UpdateStatus status; @@ -14,6 +23,7 @@ class UpdateState { final String? downloadUrl; final double downloadProgress; final String? errorMessage; + final String? apkPath; const UpdateState({ this.status = UpdateStatus.idle, @@ -22,6 +32,7 @@ class UpdateState { this.downloadUrl, this.downloadProgress = 0.0, this.errorMessage, + this.apkPath, }); UpdateState copyWith({ @@ -31,6 +42,7 @@ class UpdateState { String? downloadUrl, double? downloadProgress, String? errorMessage, + String? apkPath, }) => UpdateState( status: status ?? this.status, @@ -39,6 +51,7 @@ class UpdateState { downloadUrl: downloadUrl ?? this.downloadUrl, downloadProgress: downloadProgress ?? this.downloadProgress, errorMessage: errorMessage ?? this.errorMessage, + apkPath: apkPath ?? this.apkPath, ); } @@ -46,20 +59,15 @@ class UpdateNotifier extends Notifier { @override UpdateState build() => const UpdateState(); - /// [repoUrl] is the Forgejo repo page URL, e.g. - /// "https://git.example.com/user/fabled_app" Future check(String repoUrl) async { state = state.copyWith(status: UpdateStatus.checking); try { final packageInfo = await PackageInfo.fromPlatform(); - // Combine versionName + buildNumber to match the YY.MM.DD.N tag format. final currentVersion = '${packageInfo.version}.${packageInfo.buildNumber}'; - // Parse repo URL → Forgejo API endpoint final uri = Uri.parse(repoUrl); - final parts = - uri.pathSegments.where((s) => s.isNotEmpty).toList(); + final parts = uri.pathSegments.where((s) => s.isNotEmpty).toList(); if (parts.length < 2) throw 'Invalid repository URL (need /owner/repo)'; final apiUrl = '${uri.scheme}://${uri.authority}/api/v1/repos/${parts[0]}/${parts[1]}/releases/latest'; @@ -70,20 +78,20 @@ class UpdateNotifier extends Notifier { tagName.startsWith('v') ? tagName.substring(1) : tagName; if (_isNewer(latestVersion, currentVersion)) { - final assets = - (response.data['assets'] as List? ?? []) - .cast>(); + final assets = (response.data['assets'] as List? ?? []) + .cast>(); final apk = assets.firstWhere( (a) => (a['name'] as String? ?? '').endsWith('.apk'), orElse: () => {}, ); if (apk.isNotEmpty) { + final downloadUrl = apk['browser_download_url'] as String?; state = state.copyWith( - status: UpdateStatus.available, currentVersion: currentVersion, latestVersion: latestVersion, - downloadUrl: apk['browser_download_url'] as String?, + downloadUrl: downloadUrl, ); + await _downloadInBackground(); return; } } @@ -101,11 +109,52 @@ class UpdateNotifier extends Notifier { } } - Future downloadAndInstall() async { + Future _downloadInBackground() async { if (state.downloadUrl == null) return; + state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0); + + try { + final dir = await _apkDir(); + await _cleanupApks(dir); + + final path = '${dir.path}/fabled_${state.latestVersion}.apk'; + + await Dio().download( + state.downloadUrl!, + path, + onReceiveProgress: (received, total) { + if (total > 0) { + state = state.copyWith(downloadProgress: received / total); + } + }, + ); + + final file = File(path); + if (!await file.exists() || await file.length() == 0) { + state = state.copyWith( + status: UpdateStatus.error, + errorMessage: 'Download failed — file is empty', + ); + return; + } + + state = state.copyWith( + status: UpdateStatus.readyToInstall, + apkPath: path, + downloadProgress: 1.0, + ); + } catch (e) { + state = state.copyWith( + status: UpdateStatus.error, + errorMessage: e.toString(), + ); + } + } + + Future install() async { + final path = state.apkPath; + if (path == null) return; - // Android 8+ requires explicit per-app "Install unknown apps" approval - // beyond the manifest declaration. Check and redirect to Settings if needed. final installPermission = await Permission.requestInstallPackages.status; if (!installPermission.isGranted) { final result = await Permission.requestInstallPackages.request(); @@ -119,30 +168,13 @@ class UpdateNotifier extends Notifier { } } - state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0); - try { - final dir = await getExternalStorageDirectory() ?? - await getTemporaryDirectory(); - final path = '${dir.path}/fabled_update.apk'; - - await Dio().download( - state.downloadUrl!, - path, - onReceiveProgress: (received, total) { - if (total > 0) { - state = state.copyWith(downloadProgress: received / total); - } - }, - ); - final result = await OpenFile.open( path, type: 'application/vnd.android.package-archive', ); if (result.type == ResultType.done) { - // Installer launched — reset to idle so the dialog closes naturally. state = const UpdateState(); } else { state = state.copyWith( @@ -158,8 +190,30 @@ class UpdateNotifier extends Notifier { } } + /// Remove any previously cached APKs. + Future cleanup() async { + final dir = await _apkDir(); + await _cleanupApks(dir); + } + void dismiss() => state = const UpdateState(); + Future _apkDir() async { + return await getExternalStorageDirectory() ?? + await getTemporaryDirectory(); + } + + Future _cleanupApks(Directory dir) async { + try { + final entries = dir.listSync(); + for (final entry in entries) { + if (entry is File && entry.path.endsWith('.apk')) { + await entry.delete(); + } + } + } catch (_) {} + } + bool _isNewer(String latest, String current) { try { final l = latest.split('.').map(int.parse).toList(); diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 652886f..0dcf546 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -92,7 +92,7 @@ class SettingsScreen extends ConsumerWidget { child: const Text('Check'), ), ), - if (update.status == UpdateStatus.available || + if (update.status == UpdateStatus.readyToInstall || update.status == UpdateStatus.downloading) _UpdateTile(update: update), if (update.status == UpdateStatus.error) @@ -131,7 +131,7 @@ class SettingsScreen extends ConsumerWidget { if (update.status == UpdateStatus.upToDate) { return Text('v$current — up to date'); } - if (update.status == UpdateStatus.available || + if (update.status == UpdateStatus.readyToInstall || update.status == UpdateStatus.downloading) { return Text('v$current installed'); } @@ -202,12 +202,12 @@ class _UpdateTile extends ConsumerWidget { '${(update.downloadProgress * 100).toStringAsFixed(0)}%'), ], ) - : const Text('Tap to download and install'), + : const Text('Ready to install'), trailing: isDownloading ? null : FilledButton( onPressed: () => - ref.read(updateProvider.notifier).downloadAndInstall(), + ref.read(updateProvider.notifier).install(), child: const Text('Install'), ), ); diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index 64c6266..bf000b0 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -1,12 +1,17 @@ import 'dart:math' show min; +import 'dart:typed_data'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/message.dart'; +import '../providers/api_client_provider.dart'; +import '../providers/settings_provider.dart'; import 'tool_call_chip.dart'; -class ChatMessageBubble extends StatelessWidget { +class ChatMessageBubble extends ConsumerWidget { final Message message; final String streamingStatus; const ChatMessageBubble({ @@ -16,9 +21,11 @@ class ChatMessageBubble extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final isUser = message.role == MessageRole.user; final scheme = Theme.of(context).colorScheme; + final serverUrl = ref.watch(serverUrlProvider) ?? ''; + final dio = ref.watch(dioProvider); final isGenerating = message.status == 'generating'; final toolCalls = message.toolCalls ?? const []; @@ -116,6 +123,14 @@ class ChatMessageBubble extends StatelessWidget { if (message.content.isNotEmpty) MarkdownBody( data: message.content, + imageBuilder: (uri, title, alt) { + return _AuthImage( + uri: uri, + alt: alt, + serverUrl: serverUrl, + dio: dio, + ); + }, styleSheet: MarkdownStyleSheet( p: TextStyle( color: isUser @@ -161,3 +176,69 @@ class ChatMessageBubble extends StatelessWidget { ); } } + +class _AuthImage extends StatefulWidget { + final Uri uri; + final String? alt; + final String serverUrl; + final Dio dio; + + const _AuthImage({ + required this.uri, + this.alt, + required this.serverUrl, + required this.dio, + }); + + @override + State<_AuthImage> createState() => _AuthImageState(); +} + +class _AuthImageState extends State<_AuthImage> { + late Future _future; + + @override + void initState() { + super.initState(); + _future = _fetchImage(); + } + + Future _fetchImage() async { + var url = widget.uri.toString(); + if (url.startsWith('/')) { + url = '${widget.serverUrl}$url'; + } + final response = await widget.dio.get>( + url, + options: Options(responseType: ResponseType.bytes), + ); + return Uint8List.fromList(response.data!); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox( + height: 100, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + } + if (snapshot.hasError || !snapshot.hasData) { + return Text(widget.alt ?? 'Image failed to load'); + } + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory( + snapshot.data!, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => + Text(widget.alt ?? 'Image failed to load'), + ), + ); + }, + ); + } +} From aba0ca6256ec0bce574880cb5535deb99cff8719 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Fri, 17 Apr 2026 14:29:05 -0400 Subject: [PATCH 3/3] feat: gate News tab and briefing news cards on server rss_enabled setting Fetch server-side settings on app launch via new serverSettingsProvider. Hide News from bottom sheet and NavigationRail when RSS is disabled. Skip news card rendering in briefing messages when disabled. Refactor tab navigation to use dynamic tabs list and route-based refresh. Co-Authored-By: Claude Opus 4.6 --- lib/app.dart | 92 ++++++++++++----------- lib/data/api/settings_api.dart | 9 +++ lib/providers/settings_provider.dart | 31 ++++++++ lib/screens/briefing/briefing_screen.dart | 15 ++-- 4 files changed, 100 insertions(+), 47 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 56f4010..afda4f0 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -186,15 +186,19 @@ class _Shell extends ConsumerStatefulWidget { } class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { - static const _tabs = [ + static const _baseTabs = [ Routes.briefing, Routes.knowledge, Routes.conversations, Routes.projects, - Routes.news, - Routes.calendar, ]; + List _tabs(bool rssEnabled) => [ + ..._baseTabs, + if (rssEnabled) Routes.news, + Routes.calendar, + ]; + // Minimum gap between app-resume refreshes to avoid hammering the server. static const _resumeCooldown = Duration(seconds: 30); DateTime? _lastResumeRefresh; @@ -251,19 +255,18 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { ref.invalidate(briefingProvider); } - /// Refresh only the provider backing the given shell tab index. - void _refreshTab(int index) { - switch (index) { - case 0: - ref.invalidate(briefingProvider); - case 1: - ref.read(knowledgeProvider.notifier).refresh(); - case 2: - ref.read(conversationsProvider.notifier).refresh(); - case 4: - ref.read(newsProvider.notifier).refresh(); - case 5: - ref.read(calendarProvider.notifier).refresh(); + /// Refresh only the provider backing the given shell tab route. + void _refreshTab(String route) { + if (route == Routes.briefing) { + ref.invalidate(briefingProvider); + } else if (route == Routes.knowledge) { + ref.read(knowledgeProvider.notifier).refresh(); + } else if (route == Routes.conversations) { + ref.read(conversationsProvider.notifier).refresh(); + } else if (route == Routes.news) { + ref.read(newsProvider.notifier).refresh(); + } else if (route == Routes.calendar) { + ref.read(calendarProvider.notifier).refresh(); } } @@ -276,9 +279,9 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { } } - int _tabIndex(String location) { - for (var i = 0; i < _tabs.length; i++) { - if (location.startsWith(_tabs[i])) return i; + int _tabIndex(String location, List tabs) { + for (var i = 0; i < tabs.length; i++) { + if (location.startsWith(tabs[i])) return i; } return 0; } @@ -298,14 +301,15 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { context.push(Routes.projects); }, ), - ListTile( - leading: const Icon(Icons.newspaper_outlined), - title: const Text('News'), - onTap: () { - Navigator.pop(context); - context.push(Routes.news); - }, - ), + if (ref.read(rssEnabledProvider)) + ListTile( + leading: const Icon(Icons.newspaper_outlined), + title: const Text('News'), + onTap: () { + Navigator.pop(context); + context.push(Routes.news); + }, + ), ListTile( leading: const Icon(Icons.calendar_month_outlined), title: const Text('Calendar'), @@ -343,12 +347,15 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { .addPostFrameCallback((_) => _showUpdateSnackbar(next)); } }); + final rssEnabled = ref.watch(rssEnabledProvider); + final tabs = _tabs(rssEnabled); final location = GoRouterState.of(context).matchedLocation; - final index = _tabIndex(location); + final index = _tabIndex(location, tabs); // Refresh the incoming tab's data when switching between shell tabs. if (_prevTabIndex != null && _prevTabIndex != index) { - WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(index)); + final route = index < tabs.length ? tabs[index] : tabs[0]; + WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(route)); } _prevTabIndex = index; @@ -366,35 +373,36 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { children: [ NavigationRail( selectedIndex: index, - onDestinationSelected: (i) => context.go(_tabs[i]), + onDestinationSelected: (i) => context.go(tabs[i]), labelType: NavigationRailLabelType.all, - destinations: const [ - NavigationRailDestination( + destinations: [ + const NavigationRailDestination( icon: Icon(Icons.wb_sunny_outlined), selectedIcon: Icon(Icons.wb_sunny), label: Text('Briefing'), ), - NavigationRailDestination( + const NavigationRailDestination( icon: Icon(Icons.menu_book_outlined), selectedIcon: Icon(Icons.menu_book), label: Text('Knowledge'), ), - NavigationRailDestination( + const NavigationRailDestination( icon: Icon(Icons.chat_bubble_outline), selectedIcon: Icon(Icons.chat_bubble), label: Text('Chat'), ), - NavigationRailDestination( + const NavigationRailDestination( icon: Icon(Icons.folder_outlined), selectedIcon: Icon(Icons.folder), label: Text('Projects'), ), - NavigationRailDestination( - icon: Icon(Icons.newspaper_outlined), - selectedIcon: Icon(Icons.newspaper), - label: Text('News'), - ), - NavigationRailDestination( + if (ref.watch(rssEnabledProvider)) + const NavigationRailDestination( + icon: Icon(Icons.newspaper_outlined), + selectedIcon: Icon(Icons.newspaper), + label: Text('News'), + ), + const NavigationRailDestination( icon: Icon(Icons.calendar_month_outlined), selectedIcon: Icon(Icons.calendar_month), label: Text('Calendar'), @@ -431,7 +439,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { if (i == 3) { _showMoreSheet(context); } else { - context.go(_tabs[i]); + context.go(tabs[i]); } }, destinations: const [ diff --git a/lib/data/api/settings_api.dart b/lib/data/api/settings_api.dart index 8d30c5f..3042786 100644 --- a/lib/data/api/settings_api.dart +++ b/lib/data/api/settings_api.dart @@ -10,4 +10,13 @@ class SettingsApi { data: {'user_timezone': ianaTimezone}, ); } + + Future> getAll() async { + final response = await _dio.get>('/api/settings'); + return response.data ?? {}; + } + + Future update(Map updates) async { + await _dio.put('/api/settings', data: updates); + } } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 38478b4..62a6682 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'api_client_provider.dart'; + const _kServerUrl = 'server_url'; const _kThemeMode = 'theme_mode'; const _kForgejoRepoUrl = 'forgejo_repo_url'; @@ -82,3 +84,32 @@ class ServerUrlNotifier extends Notifier { state = null; } } + +final serverSettingsProvider = + AsyncNotifierProvider>( + ServerSettingsNotifier.new); + +class ServerSettingsNotifier extends AsyncNotifier> { + @override + Future> build() async { + try { + return await ref.read(settingsApiProvider).getAll(); + } catch (_) { + return {}; + } + } + + bool get rssEnabled { + final data = state.value ?? {}; + return data['rss_enabled']?.toString().toLowerCase() == 'true'; + } + + Future refresh() async { + state = AsyncData(await ref.read(settingsApiProvider).getAll()); + } +} + +final rssEnabledProvider = Provider((ref) { + final settings = ref.watch(serverSettingsProvider).value ?? {}; + return settings['rss_enabled']?.toString().toLowerCase() == 'true'; +}); diff --git a/lib/screens/briefing/briefing_screen.dart b/lib/screens/briefing/briefing_screen.dart index 41678d0..90fcfb0 100644 --- a/lib/screens/briefing/briefing_screen.dart +++ b/lib/screens/briefing/briefing_screen.dart @@ -11,6 +11,7 @@ import '../../widgets/chat_message_bubble.dart'; import '../../widgets/weather_card.dart'; import '../../widgets/news_card.dart'; import 'briefing_history_screen.dart'; +import '../../providers/settings_provider.dart'; import '../../providers/voice_provider.dart'; import '../../widgets/voice_mic_button.dart'; @@ -313,6 +314,7 @@ class _BriefingScreenState extends ConsumerState reactions: _reactions, onReaction: _handleReaction, onDiscuss: _handleDiscuss, + rssEnabled: ref.watch(rssEnabledProvider), ); }, ), @@ -428,6 +430,7 @@ class _BriefingMessageItem extends StatelessWidget { final Map reactions; final void Function(int itemId, String reaction) onReaction; final void Function(int convId, int itemId) onDiscuss; + final bool rssEnabled; const _BriefingMessageItem({ required this.message, @@ -435,6 +438,7 @@ class _BriefingMessageItem extends StatelessWidget { required this.reactions, required this.onReaction, required this.onDiscuss, + this.rssEnabled = false, }); @override @@ -446,11 +450,12 @@ class _BriefingMessageItem extends StatelessWidget { final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather'); final weatherData = hasWeatherKey ? meta['weather'] as Map? : null; - // RSS news cards — cap at 3 - final rssItemsRaw = isAssistant && meta != null - ? (meta['rss_items'] as List?)?.cast>() ?? [] - : >[]; - final rssItems = rssItemsRaw.map(RssItemMeta.fromJson).take(3).toList(); + // RSS news cards — cap at 3 (only when RSS is enabled) + final rssItems = []; + if (rssEnabled && isAssistant && meta != null) { + final raw = (meta['rss_items'] as List?)?.cast>() ?? []; + rssItems.addAll(raw.map(RssItemMeta.fromJson).take(3)); + } return Column( crossAxisAlignment: CrossAxisAlignment.stretch,