diff --git a/lib/app.dart b/lib/app.dart index 63fa63a..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; @@ -205,6 +209,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) { @@ -249,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(); } } @@ -274,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; } @@ -296,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'), @@ -318,66 +324,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,18 +341,21 @@ 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 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; @@ -415,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'), @@ -480,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 [ @@ -529,7 +488,6 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { @override void dispose() { _controller.dispose(); - ref.read(voiceProvider.notifier).exitVoiceMode(); super.dispose(); } 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/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/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/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/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..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'; @@ -76,7 +77,6 @@ class _BriefingScreenState extends ConsumerState WidgetsBinding.instance.removeObserver(this); _controller.dispose(); _scrollController.dispose(); - ref.read(voiceProvider.notifier).exitVoiceMode(); super.dispose(); } @@ -314,6 +314,7 @@ class _BriefingScreenState extends ConsumerState reactions: _reactions, onReaction: _handleReaction, onDiscuss: _handleDiscuss, + rssEnabled: ref.watch(rssEnabledProvider), ); }, ), @@ -429,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, @@ -436,6 +438,7 @@ class _BriefingMessageItem extends StatelessWidget { required this.reactions, required this.onReaction, required this.onDiscuss, + this.rssEnabled = false, }); @override @@ -447,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, 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/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'), + ), + ); + }, + ); + } +} 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)