Compare commits

...

11 Commits

Author SHA1 Message Date
bvandeusen 3c9602c7c9 Merge pull request 'feat: voice fixes, background updates, rss_enabled gating, chat images' (#27) from dev into main 2026-04-18 03:36:14 +00:00
bvandeusen aba0ca6256 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 <noreply@anthropic.com>
2026-04-17 14:29:05 -04:00
bvandeusen 70a3279192 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 <noreply@anthropic.com>
2026-04-17 12:38:48 -04:00
bvandeusen fc6c9648f9 fix(voice): eliminate dual-recorder conflict and defunct element crashes
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 <noreply@anthropic.com>
2026-04-17 08:04:03 -04:00
bvandeusen 413b82f724 Merge pull request 'Release v26.04.16.1 — Silero VAD voice detection' (#26) from dev into main 2026-04-17 01:00:54 +00:00
bvandeusen 5f11b344a3 feat(voice): replace amplitude silence detection with Silero VAD
Use VadHandler from the vad package (Silero VAD v5 ONNX) for speech
detection instead of amplitude thresholds. VAD runs its own PCM
stream while the file recorder captures AAC-LC for Whisper. Grace
period starts on speech-start, auto-stop on speech-end after grace.
No-speech guard shows error on manual stop without detected speech.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:57:52 -04:00
bvandeusen 8959b62abe chore: upgrade record to v6, add vad package
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:55:18 -04:00
bvandeusen c967a49e5a Release v26.04.15.1 — Tablet landscape improvements 2026-04-16 05:52:49 +00:00
bvandeusen 58d4cfab4d feat: tablet landscape improvements
Shell: move Projects/News/Calendar into ShellRoute so the NavigationRail
persists across all screens. Show all 6 nav destinations on tablet
instead of 3+More overflow. Phone bottom nav unchanged.

Chat: master-detail layout on tablet — conversations list (320px) with
inline chat panel. Tapping a conversation updates state instead of
pushing a new route.

Knowledge: responsive grid (2 cols portrait, 3 cols landscape) with
card layout showing icon, title, body preview, and tags. Fix snippet
data — read json['snippet'] which the API actually sends. Bump snippet
length from 120 to 200 chars. Tasks now show description as snippet.

News: responsive grid with the same breakpoints. Larger snippet
(5 lines) in grid mode via snippetMaxLines parameter.

Briefing: centered reading column (maxWidth 700) on wide screens,
matching the web UI layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 01:31:52 -04:00
bvandeusen ee0f354312 Merge pull request 'Release v26.04.15.1 — Dynamic voice silence threshold' (#24) from dev into main 2026-04-15 04:38:25 +00:00
bvandeusen bdaa5210f0 feat(voice): dynamic silence threshold
The previous -40 dBFS static threshold sat right on top of typical
phone mic ambient, so silence detection rarely fired and the user
always had to tap stop manually.

Silence threshold is now dynamic: track the session peak dBFS and
treat "silent" as 15 dB below peak. Auto-calibrates per mic and
environment rather than assuming a fixed ambient level.

- Grace period (1500 ms) at start so the user has time to begin
  speaking before checks arm.
- Static -35 dB fallback until the peak clears -20 dB so a dead-
  silent session doesn't spin forever.
- Silence duration bumped 1500 → 2000 ms for breathing room.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 00:28:04 -04:00
21 changed files with 833 additions and 401 deletions
+86 -122
View File
@@ -159,20 +159,20 @@ final routerProvider = Provider<GoRouter>((ref) {
path: Routes.conversations, path: Routes.conversations,
builder: (_, _) => const ConversationsTabScreen(), builder: (_, _) => const ConversationsTabScreen(),
), ),
GoRoute(
path: Routes.projects,
builder: (_, _) => const ProjectsScreen(),
),
GoRoute(
path: Routes.news,
builder: (_, _) => const NewsScreen(),
),
GoRoute(
path: Routes.calendar,
builder: (_, _) => const CalendarScreen(),
),
], ],
), ),
GoRoute(
path: Routes.projects,
builder: (_, _) => const ProjectsScreen(),
),
GoRoute(
path: Routes.news,
builder: (_, _) => const NewsScreen(),
),
GoRoute(
path: Routes.calendar,
builder: (_, _) => const CalendarScreen(),
),
], ],
); );
}); });
@@ -186,12 +186,19 @@ class _Shell extends ConsumerStatefulWidget {
} }
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
static const _tabs = [ static const _baseTabs = [
Routes.briefing, Routes.briefing,
Routes.knowledge, Routes.knowledge,
Routes.conversations, Routes.conversations,
Routes.projects,
]; ];
List<String> _tabs(bool rssEnabled) => [
..._baseTabs,
if (rssEnabled) Routes.news,
Routes.calendar,
];
// Minimum gap between app-resume refreshes to avoid hammering the server. // Minimum gap between app-resume refreshes to avoid hammering the server.
static const _resumeCooldown = Duration(seconds: 30); static const _resumeCooldown = Duration(seconds: 30);
DateTime? _lastResumeRefresh; DateTime? _lastResumeRefresh;
@@ -202,6 +209,8 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) { 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. // Silent update check — only if we haven't already checked this session.
final repoUrl = ref.read(forgejoRepoUrlProvider); final repoUrl = ref.read(forgejoRepoUrlProvider);
if (repoUrl != null && repoUrl.isNotEmpty) { if (repoUrl != null && repoUrl.isNotEmpty) {
@@ -246,15 +255,18 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
ref.invalidate(briefingProvider); ref.invalidate(briefingProvider);
} }
/// Refresh only the provider backing the given shell tab index. /// Refresh only the provider backing the given shell tab route.
void _refreshTab(int index) { void _refreshTab(String route) {
switch (index) { if (route == Routes.briefing) {
case 0: ref.invalidate(briefingProvider);
ref.invalidate(briefingProvider); } else if (route == Routes.knowledge) {
case 1: ref.read(knowledgeProvider.notifier).refresh();
ref.read(knowledgeProvider.notifier).refresh(); } else if (route == Routes.conversations) {
case 2: ref.read(conversationsProvider.notifier).refresh();
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();
} }
} }
@@ -267,14 +279,9 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
} }
} }
int _tabIndex(String location) { int _tabIndex(String location, List<String> tabs) {
for (var i = 0; i < _tabs.length; i++) { for (var i = 0; i < tabs.length; i++) {
if (location.startsWith(_tabs[i])) return i; if (location.startsWith(tabs[i])) return i;
}
if (location.startsWith(Routes.projects) ||
location.startsWith(Routes.news) ||
location.startsWith(Routes.calendar)) {
return 3;
} }
return 0; return 0;
} }
@@ -294,14 +301,15 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
context.push(Routes.projects); context.push(Routes.projects);
}, },
), ),
ListTile( if (ref.read(rssEnabledProvider))
leading: const Icon(Icons.newspaper_outlined), ListTile(
title: const Text('News'), leading: const Icon(Icons.newspaper_outlined),
onTap: () { title: const Text('News'),
Navigator.pop(context); onTap: () {
context.push(Routes.news); Navigator.pop(context);
}, context.push(Routes.news);
), },
),
ListTile( ListTile(
leading: const Icon(Icons.calendar_month_outlined), leading: const Icon(Icons.calendar_month_outlined),
title: const Text('Calendar'), title: const Text('Calendar'),
@@ -316,66 +324,15 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
); );
} }
void _showUpdateDialog(UpdateState update) { void _showUpdateSnackbar(UpdateState update) {
showDialog<void>( ScaffoldMessenger.of(context).showSnackBar(
context: context, SnackBar(
builder: (dialogContext) => Consumer( content: Text('v${update.latestVersion} ready to install'),
builder: (context, ref, _) { duration: const Duration(seconds: 6),
final state = ref.watch(updateProvider); action: SnackBarAction(
final isDownloading = state.status == UpdateStatus.downloading; label: 'Install',
return AlertDialog( onPressed: () => ref.read(updateProvider.notifier).install(),
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'),
),
],
);
},
), ),
); );
} }
@@ -384,18 +341,21 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Show update dialog once when a new version is detected. // Show update dialog once when a new version is detected.
ref.listen(updateProvider, (prev, next) { ref.listen(updateProvider, (prev, next) {
if (next.status == UpdateStatus.available && if (next.status == UpdateStatus.readyToInstall &&
prev?.status != UpdateStatus.available) { prev?.status != UpdateStatus.readyToInstall) {
WidgetsBinding.instance WidgetsBinding.instance
.addPostFrameCallback((_) => _showUpdateDialog(next)); .addPostFrameCallback((_) => _showUpdateSnackbar(next));
} }
}); });
final rssEnabled = ref.watch(rssEnabledProvider);
final tabs = _tabs(rssEnabled);
final location = GoRouterState.of(context).matchedLocation; 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. // Refresh the incoming tab's data when switching between shell tabs.
if (_prevTabIndex != null && _prevTabIndex != index && index < 3) { 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; _prevTabIndex = index;
@@ -413,34 +373,39 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
children: [ children: [
NavigationRail( NavigationRail(
selectedIndex: index, selectedIndex: index,
onDestinationSelected: (i) { onDestinationSelected: (i) => context.go(tabs[i]),
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
}
},
labelType: NavigationRailLabelType.all, labelType: NavigationRailLabelType.all,
destinations: const [ destinations: [
NavigationRailDestination( const NavigationRailDestination(
icon: Icon(Icons.wb_sunny_outlined), icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny), selectedIcon: Icon(Icons.wb_sunny),
label: Text('Briefing'), label: Text('Briefing'),
), ),
NavigationRailDestination( const NavigationRailDestination(
icon: Icon(Icons.menu_book_outlined), icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book), selectedIcon: Icon(Icons.menu_book),
label: Text('Knowledge'), label: Text('Knowledge'),
), ),
NavigationRailDestination( const NavigationRailDestination(
icon: Icon(Icons.chat_bubble_outline), icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble), selectedIcon: Icon(Icons.chat_bubble),
label: Text('Chat'), label: Text('Chat'),
), ),
NavigationRailDestination( const NavigationRailDestination(
icon: Icon(Icons.more_horiz_outlined), icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.more_horiz), selectedIcon: Icon(Icons.folder),
label: Text('More'), label: Text('Projects'),
),
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'),
), ),
], ],
), ),
@@ -469,12 +434,12 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
], ],
), ),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
selectedIndex: index, selectedIndex: index >= 3 ? 3 : index,
onDestinationSelected: (i) { onDestinationSelected: (i) {
if (i == 3) { if (i == 3) {
_showMoreSheet(context); _showMoreSheet(context);
} else { } else {
context.go(_tabs[i]); context.go(tabs[i]);
} }
}, },
destinations: const [ destinations: const [
@@ -523,7 +488,6 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
@override @override
void dispose() { void dispose() {
_controller.dispose(); _controller.dispose();
ref.read(voiceProvider.notifier).exitVoiceMode();
super.dispose(); super.dispose();
} }
+9
View File
@@ -10,4 +10,13 @@ class SettingsApi {
data: {'user_timezone': ianaTimezone}, data: {'user_timezone': ianaTimezone},
); );
} }
Future<Map<String, dynamic>> getAll() async {
final response = await _dio.get<Map<String, dynamic>>('/api/settings');
return response.data ?? {};
}
Future<void> update(Map<String, String> updates) async {
await _dio.put<void>('/api/settings', data: updates);
}
} }
+3 -3
View File
@@ -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 /// [context] is optional recent conversation text passed as initial_prompt
/// to Whisper, reducing mishearings of domain-specific words. /// to Whisper, reducing mishearings of domain-specific words.
/// Returns empty string on empty or error response. /// Returns empty string on empty or error response.
@@ -49,8 +49,8 @@ class VoiceApi {
final fields = <String, dynamic>{ final fields = <String, dynamic>{
'audio': MultipartFile.fromBytes( 'audio': MultipartFile.fromBytes(
audioBytes, audioBytes,
filename: 'audio.m4a', filename: 'audio.wav',
contentType: DioMediaType('audio', 'mp4'), contentType: DioMediaType('audio', 'wav'),
), ),
if (context != null && context.isNotEmpty) 'context': context, if (context != null && context.isNotEmpty) 'context': context,
}; };
+1 -1
View File
@@ -52,7 +52,7 @@ class KnowledgeItem {
id: json['id'] as int, id: json['id'] as int,
noteType: json['note_type'] as String? ?? 'note', noteType: json['note_type'] as String? ?? 'note',
title: json['title'] as String? ?? '', title: json['title'] as String? ?? '',
body: json['body'] as String? ?? '', body: (json['snippet'] ?? json['body']) as String? ?? '',
tags: (json['tags'] as List<dynamic>?) tags: (json['tags'] as List<dynamic>?)
?.map((e) => e as String) ?.map((e) => e as String)
.toList() ?? .toList() ??
+31
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'api_client_provider.dart';
const _kServerUrl = 'server_url'; const _kServerUrl = 'server_url';
const _kThemeMode = 'theme_mode'; const _kThemeMode = 'theme_mode';
const _kForgejoRepoUrl = 'forgejo_repo_url'; const _kForgejoRepoUrl = 'forgejo_repo_url';
@@ -82,3 +84,32 @@ class ServerUrlNotifier extends Notifier<String?> {
state = null; state = null;
} }
} }
final serverSettingsProvider =
AsyncNotifierProvider<ServerSettingsNotifier, Map<String, dynamic>>(
ServerSettingsNotifier.new);
class ServerSettingsNotifier extends AsyncNotifier<Map<String, dynamic>> {
@override
Future<Map<String, dynamic>> 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<void> refresh() async {
state = AsyncData(await ref.read(settingsApiProvider).getAll());
}
}
final rssEnabledProvider = Provider<bool>((ref) {
final settings = ref.watch(serverSettingsProvider).value ?? {};
return settings['rss_enabled']?.toString().toLowerCase() == 'true';
});
+86 -32
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:open_file/open_file.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:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.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 { class UpdateState {
final UpdateStatus status; final UpdateStatus status;
@@ -14,6 +23,7 @@ class UpdateState {
final String? downloadUrl; final String? downloadUrl;
final double downloadProgress; final double downloadProgress;
final String? errorMessage; final String? errorMessage;
final String? apkPath;
const UpdateState({ const UpdateState({
this.status = UpdateStatus.idle, this.status = UpdateStatus.idle,
@@ -22,6 +32,7 @@ class UpdateState {
this.downloadUrl, this.downloadUrl,
this.downloadProgress = 0.0, this.downloadProgress = 0.0,
this.errorMessage, this.errorMessage,
this.apkPath,
}); });
UpdateState copyWith({ UpdateState copyWith({
@@ -31,6 +42,7 @@ class UpdateState {
String? downloadUrl, String? downloadUrl,
double? downloadProgress, double? downloadProgress,
String? errorMessage, String? errorMessage,
String? apkPath,
}) => }) =>
UpdateState( UpdateState(
status: status ?? this.status, status: status ?? this.status,
@@ -39,6 +51,7 @@ class UpdateState {
downloadUrl: downloadUrl ?? this.downloadUrl, downloadUrl: downloadUrl ?? this.downloadUrl,
downloadProgress: downloadProgress ?? this.downloadProgress, downloadProgress: downloadProgress ?? this.downloadProgress,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
apkPath: apkPath ?? this.apkPath,
); );
} }
@@ -46,20 +59,15 @@ class UpdateNotifier extends Notifier<UpdateState> {
@override @override
UpdateState build() => const UpdateState(); UpdateState build() => const UpdateState();
/// [repoUrl] is the Forgejo repo page URL, e.g.
/// "https://git.example.com/user/fabled_app"
Future<void> check(String repoUrl) async { Future<void> check(String repoUrl) async {
state = state.copyWith(status: UpdateStatus.checking); state = state.copyWith(status: UpdateStatus.checking);
try { try {
final packageInfo = await PackageInfo.fromPlatform(); final packageInfo = await PackageInfo.fromPlatform();
// Combine versionName + buildNumber to match the YY.MM.DD.N tag format.
final currentVersion = final currentVersion =
'${packageInfo.version}.${packageInfo.buildNumber}'; '${packageInfo.version}.${packageInfo.buildNumber}';
// Parse repo URL → Forgejo API endpoint
final uri = Uri.parse(repoUrl); final uri = Uri.parse(repoUrl);
final parts = final parts = uri.pathSegments.where((s) => s.isNotEmpty).toList();
uri.pathSegments.where((s) => s.isNotEmpty).toList();
if (parts.length < 2) throw 'Invalid repository URL (need /owner/repo)'; if (parts.length < 2) throw 'Invalid repository URL (need /owner/repo)';
final apiUrl = final apiUrl =
'${uri.scheme}://${uri.authority}/api/v1/repos/${parts[0]}/${parts[1]}/releases/latest'; '${uri.scheme}://${uri.authority}/api/v1/repos/${parts[0]}/${parts[1]}/releases/latest';
@@ -70,20 +78,20 @@ class UpdateNotifier extends Notifier<UpdateState> {
tagName.startsWith('v') ? tagName.substring(1) : tagName; tagName.startsWith('v') ? tagName.substring(1) : tagName;
if (_isNewer(latestVersion, currentVersion)) { if (_isNewer(latestVersion, currentVersion)) {
final assets = final assets = (response.data['assets'] as List<dynamic>? ?? [])
(response.data['assets'] as List<dynamic>? ?? []) .cast<Map<String, dynamic>>();
.cast<Map<String, dynamic>>();
final apk = assets.firstWhere( final apk = assets.firstWhere(
(a) => (a['name'] as String? ?? '').endsWith('.apk'), (a) => (a['name'] as String? ?? '').endsWith('.apk'),
orElse: () => {}, orElse: () => {},
); );
if (apk.isNotEmpty) { if (apk.isNotEmpty) {
final downloadUrl = apk['browser_download_url'] as String?;
state = state.copyWith( state = state.copyWith(
status: UpdateStatus.available,
currentVersion: currentVersion, currentVersion: currentVersion,
latestVersion: latestVersion, latestVersion: latestVersion,
downloadUrl: apk['browser_download_url'] as String?, downloadUrl: downloadUrl,
); );
await _downloadInBackground();
return; return;
} }
} }
@@ -101,11 +109,52 @@ class UpdateNotifier extends Notifier<UpdateState> {
} }
} }
Future<void> downloadAndInstall() async { Future<void> _downloadInBackground() async {
if (state.downloadUrl == null) return; 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<void> 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; final installPermission = await Permission.requestInstallPackages.status;
if (!installPermission.isGranted) { if (!installPermission.isGranted) {
final result = await Permission.requestInstallPackages.request(); final result = await Permission.requestInstallPackages.request();
@@ -119,30 +168,13 @@ class UpdateNotifier extends Notifier<UpdateState> {
} }
} }
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
try { 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( final result = await OpenFile.open(
path, path,
type: 'application/vnd.android.package-archive', type: 'application/vnd.android.package-archive',
); );
if (result.type == ResultType.done) { if (result.type == ResultType.done) {
// Installer launched — reset to idle so the dialog closes naturally.
state = const UpdateState(); state = const UpdateState();
} else { } else {
state = state.copyWith( state = state.copyWith(
@@ -158,8 +190,30 @@ class UpdateNotifier extends Notifier<UpdateState> {
} }
} }
/// Remove any previously cached APKs.
Future<void> cleanup() async {
final dir = await _apkDir();
await _cleanupApks(dir);
}
void dismiss() => state = const UpdateState(); void dismiss() => state = const UpdateState();
Future<Directory> _apkDir() async {
return await getExternalStorageDirectory() ??
await getTemporaryDirectory();
}
Future<void> _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) { bool _isNewer(String latest, String current) {
try { try {
final l = latest.split('.').map(int.parse).toList(); final l = latest.split('.').map(int.parse).toList();
+181 -121
View File
@@ -1,13 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart'; import 'package:vad/vad.dart';
import 'api_client_provider.dart'; import 'api_client_provider.dart';
@@ -51,6 +52,53 @@ String stripMarkdownForTts(String text) {
.trim(); .trim();
} }
/// Encode float PCM samples (-1..1) as a 16-bit mono WAV at 16 kHz.
Uint8List encodeWav(List<double> 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 ───────────────────────────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────────────────────────
enum VoiceMode { idle, recording, transcribing, playing } enum VoiceMode { idle, recording, transcribing, playing }
@@ -59,9 +107,7 @@ class VoiceState {
final VoiceMode mode; final VoiceMode mode;
final bool voiceModeActive; final bool voiceModeActive;
final bool available; final bool available;
/// Normalized mic amplitude 0.01.0 while recording. Drives the live /// Normalized mic amplitude 0.01.0 while recording.
/// pulse on VoiceMicButton so the user has obvious feedback that audio
/// is actually being picked up.
final double amplitude; final double amplitude;
const VoiceState({ const VoiceState({
@@ -88,22 +134,25 @@ class VoiceState {
// ── Provider ────────────────────────────────────────────────────────────────── // ── Provider ──────────────────────────────────────────────────────────────────
final voiceProvider = final voiceProvider =
NotifierProvider<VoiceNotifier, VoiceState>(VoiceNotifier.new); NotifierProvider.autoDispose<VoiceNotifier, VoiceState>(VoiceNotifier.new);
// ── Notifier ────────────────────────────────────────────────────────────────── // ── Notifier ──────────────────────────────────────────────────────────────────
class VoiceNotifier extends Notifier<VoiceState> { class VoiceNotifier extends Notifier<VoiceState> {
// Audio I/O // Audio playback
AudioRecorder? _recorder;
AudioPlayer? _player; AudioPlayer? _player;
StreamSubscription<Amplitude>? _amplitudeSubscription;
// Recording / silence detection // VAD — sole owner of the microphone
int _recordingStartMs = 0; VadHandler? _vadHandler;
int _silenceMs = 0; StreamSubscription<void>? _vadSpeechStartSub;
static const _silenceThresholdDb = -40.0; StreamSubscription<List<double>>? _vadSpeechEndSub;
static const _silenceDurationMs = 1500; StreamSubscription<({double isSpeech, double notSpeech, List<double> frame})>?
static const _minRecordingMs = 300; _vadFrameSub;
StreamSubscription<String>? _vadErrorSub;
bool _speechDetected = false;
int _speechStartMs = 0;
static const _vadGraceMs = 1500;
bool _disposed = false;
// Voice mode callbacks // Voice mode callbacks
Future<void> Function(String transcript)? _onTranscript; Future<void> Function(String transcript)? _onTranscript;
@@ -115,11 +164,10 @@ class VoiceNotifier extends Notifier<VoiceState> {
int _lastSeenLength = 0; int _lastSeenLength = 0;
bool _streamComplete = false; bool _streamComplete = false;
// Last complete assistant response — passed to Whisper as initial_prompt // Whisper context hint
// to reduce STT mishearings of domain-specific words.
String _lastAssistantContent = ''; String _lastAssistantContent = '';
// Empty transcript counter — show feedback after consecutive blanks // Empty transcript counter
int _emptyTranscriptCount = 0; int _emptyTranscriptCount = 0;
// TTS playback queue // TTS playback queue
@@ -130,52 +178,51 @@ class VoiceNotifier extends Notifier<VoiceState> {
@override @override
VoiceState build() { VoiceState build() {
_disposed = false;
_player = AudioPlayer(); _player = AudioPlayer();
ref.onDispose(() { ref.onDispose(() {
_amplitudeSubscription?.cancel(); _disposed = true;
_recorder?.dispose(); _cancelSubscriptions();
_vadHandler?.dispose();
_vadHandler = null;
_player?.dispose(); _player?.dispose();
_player = null;
}); });
return const VoiceState(); return const VoiceState();
} }
// ── Public API ────────────────────────────────────────────────────────────── // ── 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<void> enterVoiceMode({ Future<void> enterVoiceMode({
required Future<void> Function(String transcript) onTranscript, required Future<void> Function(String transcript) onTranscript,
bool enableTts = false, bool enableTts = false,
required void Function(String message) onError, required void Function(String message) onError,
}) async { }) async {
if (state.voiceModeActive) { if (state.voiceModeActive) {
if (state.mode == VoiceMode.recording && !_speechDetected) {
onError('No speech detected');
}
exitVoiceMode(); exitVoiceMode();
return; return;
} }
// Check server availability — STT is required, TTS is optional.
try { try {
final status = await ref.read(voiceRepositoryProvider).checkStatus(); final status = await ref.read(voiceRepositoryProvider).checkStatus();
if (!status.enabled || !status.stt) { if (!status.enabled || !status.stt) {
onError('Speech-to-text not available on this server'); onError('Speech-to-text not available on this server');
return; return;
} }
// Downgrade to STT-only when TTS is unavailable
if (!status.tts) enableTts = false; if (!status.tts) enableTts = false;
} catch (_) { } catch (_) {
onError('Could not reach voice service'); onError('Could not reach voice service');
return; return;
} }
// Check microphone permission
var permStatus = await Permission.microphone.request(); var permStatus = await Permission.microphone.request();
if (permStatus == PermissionStatus.permanentlyDenied) { if (permStatus == PermissionStatus.permanentlyDenied) {
onError('Microphone blocked — opening settings'); onError('Microphone blocked — opening settings');
final opened = await openAppSettings(); final opened = await openAppSettings();
if (!opened) return; if (!opened) return;
// Re-check after user returns from settings
permStatus = await Permission.microphone.status; permStatus = await Permission.microphone.status;
} }
if (!permStatus.isGranted) { if (!permStatus.isGranted) {
@@ -193,25 +240,11 @@ class VoiceNotifier extends Notifier<VoiceState> {
await _startListening(); await _startListening();
} }
/// Exit voice mode, stop all recording and TTS.
void exitVoiceMode() { void exitVoiceMode() {
_amplitudeSubscription?.cancel(); _cleanup();
_amplitudeSubscription = null; if (!_disposed) state = const VoiceState();
_recorder?.stop();
_player?.stop();
_ttsQueue.clear();
_ttsPlaying = false;
_sentenceBuffer = '';
_lastSeenLength = 0;
_streamComplete = false;
_onTranscript = null;
_onError = null;
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}) { void feedContent(String fullContent, {required bool isComplete}) {
if (!state.voiceModeActive || !_enableTts) return; if (!state.voiceModeActive || !_enableTts) return;
@@ -230,106 +263,131 @@ class VoiceNotifier extends Notifier<VoiceState> {
} }
} }
// ── 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 ────────────────────────────────────────────────────── // ── Internal recording ──────────────────────────────────────────────────────
Future<void> _startListening() async { Future<void> _startListening() async {
if (!state.voiceModeActive) return; if (_disposed || !state.voiceModeActive) return;
_silenceMs = 0; _speechDetected = false;
_recordingStartMs = DateTime.now().millisecondsSinceEpoch; _speechStartMs = 0;
state = state.copyWith(mode: VoiceMode.recording);
final dir = _tempDir ?? await getTemporaryDirectory();
final path =
'${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a';
try { try {
// Recreate the recorder each session — the record package can leave await _stopVad();
// the native AudioRecord in a bad state after stop/error cycles, _vadHandler = VadHandler.create();
// and a stale instance is the most common cause of "could not start".
_recorder?.dispose();
_recorder = AudioRecorder();
if (!await _recorder!.hasPermission()) { _vadSpeechStartSub = _vadHandler!.onSpeechStart.listen((_) {
_onError?.call('Microphone permission was revoked'); if (_disposed || !state.voiceModeActive) return;
exitVoiceMode(); if (!_speechDetected) {
return; _speechDetected = true;
_speechStartMs = DateTime.now().millisecondsSinceEpoch;
}
});
_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) {
_stopVad();
_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);
} }
await _recorder!.start(
const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000),
path: path,
);
_amplitudeSubscription?.cancel();
_amplitudeSubscription = _recorder!
.onAmplitudeChanged(const Duration(milliseconds: 200))
.listen(_onAmplitude);
} catch (e) { } catch (e) {
_onError?.call('Microphone error: $e'); _onError?.call('Microphone error: $e');
exitVoiceMode(); _cleanup();
if (!_disposed) state = const VoiceState();
} }
} }
void _onAmplitude(Amplitude event) { Future<void> _stopVad() async {
if (!state.voiceModeActive) return; _cancelSubscriptions();
if (_vadHandler != null) {
final db = event.current; final handler = _vadHandler!;
final validDb = !(db.isNaN || db.isInfinite); _vadHandler = null;
await handler.dispose();
// Normalize dB to 0..1 for the pulse animation. -60dB is dead-quiet,
// 0dB is peak; we clamp and bias so the button visibly breathes even
// on soft speech without exploding on loud input.
if (validDb) {
final norm = ((db + 60.0) / 60.0).clamp(0.0, 1.0);
if ((norm - state.amplitude).abs() > 0.02) {
state = state.copyWith(amplitude: norm);
}
}
final elapsed =
DateTime.now().millisecondsSinceEpoch - _recordingStartMs;
if (elapsed < _minRecordingMs) return;
final isSilent = !validDb || db < _silenceThresholdDb;
if (isSilent) {
_silenceMs += 200;
if (_silenceMs >= _silenceDurationMs) {
_amplitudeSubscription?.cancel();
_amplitudeSubscription = null;
_handleSilence();
}
} else {
_silenceMs = 0;
} }
} }
Future<void> _handleSilence() async { Future<void> _handleSpeechEnd(List<double> audioSamples) async {
if (!state.voiceModeActive) return; if (_disposed || !state.voiceModeActive) return;
state = state.copyWith(mode: VoiceMode.transcribing); state = state.copyWith(mode: VoiceMode.transcribing);
final path = await _recorder!.stop();
if (path == null || !state.voiceModeActive) return;
try { try {
final bytes = await File(path).readAsBytes(); final wavBytes = encodeWav(audioSamples);
await File(path).delete().catchError((_) => File(path));
if (!state.voiceModeActive) return; if (_disposed || !state.voiceModeActive) return;
final transcript = await ref.read(voiceRepositoryProvider).transcribe( final transcript = await ref.read(voiceRepositoryProvider).transcribe(
bytes, wavBytes,
context: _lastAssistantContent.isNotEmpty ? _lastAssistantContent : null, context:
_lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
); );
if (!state.voiceModeActive) return; if (_disposed || !state.voiceModeActive) return;
if (transcript.isEmpty) { if (transcript.isEmpty) {
_emptyTranscriptCount++; _emptyTranscriptCount++;
if (_emptyTranscriptCount >= 3) { if (_emptyTranscriptCount >= 3) {
_onError?.call('No speech detected — tap the mic to try again'); _onError?.call('No speech detected — tap the mic to try again');
exitVoiceMode(); _cleanup();
if (!_disposed) state = const VoiceState();
return; return;
} }
await _startListening(); await _startListening();
@@ -337,24 +395,25 @@ class VoiceNotifier extends Notifier<VoiceState> {
} }
_emptyTranscriptCount = 0; _emptyTranscriptCount = 0;
// Reset TTS state for this new turn
_sentenceBuffer = ''; _sentenceBuffer = '';
_lastSeenLength = 0; _lastSeenLength = 0;
_streamComplete = false; _streamComplete = false;
if (_enableTts) { if (_enableTts && !_disposed) {
state = state.copyWith(mode: VoiceMode.playing); state = state.copyWith(mode: VoiceMode.playing);
} }
await _onTranscript?.call(transcript); await _onTranscript?.call(transcript);
// If TTS is not enabled, loop immediately // In STT-only mode (no TTS), return to idle after transcript is sent.
if (!_enableTts && state.voiceModeActive) { // The user taps the mic again to record another message.
await _startListening(); if (!_enableTts && !_disposed && state.voiceModeActive) {
state = state.copyWith(mode: VoiceMode.idle);
} }
} catch (e) { } catch (e) {
_onError?.call('Voice error: transcription failed'); _onError?.call('Voice error: transcription failed');
exitVoiceMode(); _cleanup();
if (!_disposed) state = const VoiceState();
} }
} }
@@ -382,7 +441,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
try { try {
final wavBytes = final wavBytes =
await ref.read(voiceRepositoryProvider).synthesise(text); await ref.read(voiceRepositoryProvider).synthesise(text);
if (!state.voiceModeActive) return; if (_disposed || !state.voiceModeActive) return;
_ttsQueue.add(wavBytes); _ttsQueue.add(wavBytes);
if (!_ttsPlaying) _drainTtsQueue(); if (!_ttsPlaying) _drainTtsQueue();
} catch (_) { } catch (_) {
@@ -421,6 +480,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
} }
void _checkRestartListening() { void _checkRestartListening() {
if (_disposed) return;
if (_streamComplete && if (_streamComplete &&
_ttsQueue.isEmpty && _ttsQueue.isEmpty &&
!_ttsPlaying && !_ttsPlaying &&
+21 -7
View File
@@ -11,6 +11,7 @@ import '../../widgets/chat_message_bubble.dart';
import '../../widgets/weather_card.dart'; import '../../widgets/weather_card.dart';
import '../../widgets/news_card.dart'; import '../../widgets/news_card.dart';
import 'briefing_history_screen.dart'; import 'briefing_history_screen.dart';
import '../../providers/settings_provider.dart';
import '../../providers/voice_provider.dart'; import '../../providers/voice_provider.dart';
import '../../widgets/voice_mic_button.dart'; import '../../widgets/voice_mic_button.dart';
@@ -76,7 +77,6 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_controller.dispose(); _controller.dispose();
_scrollController.dispose(); _scrollController.dispose();
ref.read(voiceProvider.notifier).exitVoiceMode();
super.dispose(); super.dispose();
} }
@@ -266,7 +266,8 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
), ),
), ),
data: (conv) { data: (conv) {
return Column( final isWide = MediaQuery.of(context).size.width >= 600;
Widget body = Column(
children: [ children: [
Expanded( Expanded(
child: RefreshIndicator( child: RefreshIndicator(
@@ -313,6 +314,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
reactions: _reactions, reactions: _reactions,
onReaction: _handleReaction, onReaction: _handleReaction,
onDiscuss: _handleDiscuss, onDiscuss: _handleDiscuss,
rssEnabled: ref.watch(rssEnabledProvider),
); );
}, },
), ),
@@ -392,6 +394,15 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
), ),
], ],
); );
if (isWide) {
body = Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 700),
child: body,
),
);
}
return body;
}, },
), ),
); );
@@ -419,6 +430,7 @@ class _BriefingMessageItem extends StatelessWidget {
final Map<int, String?> reactions; final Map<int, String?> reactions;
final void Function(int itemId, String reaction) onReaction; final void Function(int itemId, String reaction) onReaction;
final void Function(int convId, int itemId) onDiscuss; final void Function(int convId, int itemId) onDiscuss;
final bool rssEnabled;
const _BriefingMessageItem({ const _BriefingMessageItem({
required this.message, required this.message,
@@ -426,6 +438,7 @@ class _BriefingMessageItem extends StatelessWidget {
required this.reactions, required this.reactions,
required this.onReaction, required this.onReaction,
required this.onDiscuss, required this.onDiscuss,
this.rssEnabled = false,
}); });
@override @override
@@ -437,11 +450,12 @@ class _BriefingMessageItem extends StatelessWidget {
final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather'); final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather');
final weatherData = hasWeatherKey ? meta['weather'] as Map<String, dynamic>? : null; final weatherData = hasWeatherKey ? meta['weather'] as Map<String, dynamic>? : null;
// RSS news cards — cap at 3 // RSS news cards — cap at 3 (only when RSS is enabled)
final rssItemsRaw = isAssistant && meta != null final rssItems = <RssItemMeta>[];
? (meta['rss_items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [] if (rssEnabled && isAssistant && meta != null) {
: <Map<String, dynamic>>[]; final raw = (meta['rss_items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
final rssItems = rssItemsRaw.map(RssItemMeta.fromJson).take(3).toList(); rssItems.addAll(raw.map(RssItemMeta.fromJson).take(3));
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
-2
View File
@@ -68,8 +68,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_controller.dispose(); _controller.dispose();
_scrollController.dispose(); _scrollController.dispose();
// Exit voice mode if the user navigates away mid-session.
ref.read(voiceProvider.notifier).exitVoiceMode();
super.dispose(); super.dispose();
} }
+97 -45
View File
@@ -4,30 +4,79 @@ import 'package:go_router/go_router.dart';
import '../../core/constants.dart'; import '../../core/constants.dart';
import '../../providers/chat_provider.dart'; import '../../providers/chat_provider.dart';
import 'chat_screen.dart';
class ConversationsTabScreen extends ConsumerWidget { class ConversationsTabScreen extends ConsumerStatefulWidget {
const ConversationsTabScreen({super.key}); const ConversationsTabScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<ConversationsTabScreen> createState() =>
_ConversationsTabScreenState();
}
class _ConversationsTabScreenState
extends ConsumerState<ConversationsTabScreen> {
int? _selectedConvId;
Future<void> _createConversation() async {
final conv =
await ref.read(conversationsProvider.notifier).create('');
if (!mounted) return;
final isWide = MediaQuery.of(context).size.width >= 600;
if (isWide) {
setState(() => _selectedConvId = conv.id);
} else {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
}
void _openConversation(int id) {
final isWide = MediaQuery.of(context).size.width >= 600;
if (isWide) {
setState(() => _selectedConvId = id);
} else {
context.push(Routes.chat.replaceFirst(':id', '$id'));
}
}
Future<void> _confirmDelete(int id, String title) async {
final ok = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete conversation?'),
content: Text('"$title" will be permanently deleted.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete')),
],
),
);
if (ok == true) {
await ref.read(conversationsProvider.notifier).delete(id);
if (_selectedConvId == id) {
setState(() => _selectedConvId = null);
}
}
}
@override
Widget build(BuildContext context) {
final isWide = MediaQuery.of(context).size.width >= 600;
final theme = Theme.of(context); final theme = Theme.of(context);
final convsAsync = ref.watch(conversationsProvider); final convsAsync = ref.watch(conversationsProvider);
return Scaffold( final listPanel = Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Chat', style: theme.textTheme.titleLarge), title: Text('Chat', style: theme.textTheme.titleLarge),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
tooltip: 'New conversation', tooltip: 'New conversation',
onPressed: () async { onPressed: _createConversation,
final conv = await ref
.read(conversationsProvider.notifier)
.create('');
if (context.mounted) {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
},
), ),
], ],
), ),
@@ -49,26 +98,20 @@ class ConversationsTabScreen extends ConsumerWidget {
FilledButton.icon( FilledButton.icon(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('Start a conversation'), label: const Text('Start a conversation'),
onPressed: () async { onPressed: _createConversation,
final conv = await ref
.read(conversationsProvider.notifier)
.create('');
if (context.mounted) {
context.push(
Routes.chat.replaceFirst(':id', '${conv.id}'));
}
},
), ),
], ],
), ),
); );
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => ref.read(conversationsProvider.notifier).refresh(), onRefresh: () =>
ref.read(conversationsProvider.notifier).refresh(),
child: ListView.builder( child: ListView.builder(
itemCount: convs.length, itemCount: convs.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final c = convs[i]; final c = convs[i];
final selected = isWide && c.id == _selectedConvId;
return ListTile( return ListTile(
leading: const Icon(Icons.chat_bubble_outline), leading: const Icon(Icons.chat_bubble_outline),
title: Text( title: Text(
@@ -79,13 +122,12 @@ class ConversationsTabScreen extends ConsumerWidget {
_relativeTime(c.updatedAt), _relativeTime(c.updatedAt),
style: theme.textTheme.labelSmall, style: theme.textTheme.labelSmall,
), ),
selected: selected,
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
onPressed: () => onPressed: () => _confirmDelete(c.id, c.title),
_confirmDelete(context, ref, c.id, c.title),
), ),
onTap: () => onTap: () => _openConversation(c.id),
ctx.push(Routes.chat.replaceFirst(':id', '${c.id}')),
); );
}, },
), ),
@@ -93,28 +135,38 @@ class ConversationsTabScreen extends ConsumerWidget {
}, },
), ),
); );
}
Future<void> _confirmDelete( if (!isWide) return listPanel;
BuildContext context, WidgetRef ref, int id, String title) async {
final ok = await showDialog<bool>( return Row(
context: context, children: [
builder: (dialogContext) => AlertDialog( SizedBox(
title: const Text('Delete conversation?'), width: 320,
content: Text('"$title" will be permanently deleted.'), child: listPanel,
actions: [ ),
TextButton( const VerticalDivider(width: 1),
onPressed: () => Navigator.pop(dialogContext, false), Expanded(
child: const Text('Cancel')), child: _selectedConvId != null
FilledButton( ? ChatScreen(
onPressed: () => Navigator.pop(dialogContext, true), key: ValueKey(_selectedConvId),
child: const Text('Delete')), conversationId: _selectedConvId!,
], )
), : Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_bubble_outline,
size: 48,
color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text('Select a conversation',
style: theme.textTheme.titleMedium),
],
),
),
),
],
); );
if (ok == true) {
await ref.read(conversationsProvider.notifier).delete(id);
}
} }
} }
+49 -10
View File
@@ -186,18 +186,57 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(), onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(),
child: ListView.separated( child: LayoutBuilder(
controller: _scrollController, builder: (context, constraints) {
itemCount: items.length + (state.isLoadingBatch ? 1 : 0), final cols = constraints.maxWidth >= 900
separatorBuilder: (_, _) => const Divider(height: 1), ? 3
itemBuilder: (_, i) { : constraints.maxWidth >= 600
if (i >= items.length) { ? 2
return const Padding( : 1;
padding: EdgeInsets.all(16), if (cols == 1) {
child: Center(child: CircularProgressIndicator()), return ListView.separated(
controller: _scrollController,
itemCount: items.length + (state.isLoadingBatch ? 1 : 0),
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, i) {
if (i >= items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
return KnowledgeItemCard(item: items[i]);
},
); );
} }
return KnowledgeItemCard(item: items[i]); return CustomScrollView(
controller: _scrollController,
slivers: [
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.8,
),
delegate: SliverChildBuilderDelegate(
(_, i) {
if (i >= items.length) {
return const Center(
child: CircularProgressIndicator());
}
return KnowledgeItemCard(item: items[i])
.buildGridCard(context);
},
childCount:
items.length + (state.isLoadingBatch ? 1 : 0),
),
),
),
],
);
}, },
), ),
); );
+79 -30
View File
@@ -50,6 +50,34 @@ class _NewsScreenState extends ConsumerState<NewsScreen> {
} }
} }
Widget _buildNewsItem(NewsState news, int i, {int cols = 1}) {
if (i >= news.items.length) {
if (!news.hasMore) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Center(
child: news.loadingMore
? const CircularProgressIndicator()
: FilledButton.tonal(
onPressed: _loadMore,
child: const Text('Load more'),
),
),
);
}
final item = news.items[i];
return NewsCard(
item: RssItemMeta.fromNewsItem(item),
reaction: news.reactions[item.id],
snippetMaxLines: cols > 1 ? 5 : 2,
onReaction: (itemId, reaction) =>
ref.read(newsProvider.notifier).toggleReaction(itemId, reaction),
onDiscuss: _openingChat.contains(item.id)
? null
: () => _handleDiscuss(item.id),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final newsAsync = ref.watch(newsProvider); final newsAsync = ref.watch(newsProvider);
@@ -98,38 +126,59 @@ class _NewsScreenState extends ConsumerState<NewsScreen> {
Expanded( Expanded(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => ref.read(newsProvider.notifier).refresh(), onRefresh: () => ref.read(newsProvider.notifier).refresh(),
child: ListView.builder( child: LayoutBuilder(
padding: builder: (context, constraints) {
const EdgeInsets.symmetric(horizontal: 8, vertical: 8), final cols = constraints.maxWidth >= 900
itemCount: news.items.length + 1, ? 3
itemBuilder: (_, i) { : constraints.maxWidth >= 600
if (i == news.items.length) { ? 2
if (!news.hasMore) return const SizedBox.shrink(); : 1;
return Padding( if (cols == 1) {
padding: const EdgeInsets.symmetric(vertical: 8), return ListView.builder(
child: Center( padding: const EdgeInsets.symmetric(
child: news.loadingMore horizontal: 8, vertical: 8),
? const CircularProgressIndicator() itemCount: news.items.length + 1,
: FilledButton.tonal( itemBuilder: (_, i) =>
onPressed: _loadMore, _buildNewsItem(news, i),
child: const Text('Load more'), );
}
return CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverGrid(
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.6,
),
delegate: SliverChildBuilderDelegate(
(_, i) => _buildNewsItem(news, i, cols: cols),
childCount: news.items.length,
),
),
),
if (news.hasMore)
SliverToBoxAdapter(
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 12),
child: Center(
child: news.loadingMore
? const CircularProgressIndicator()
: FilledButton.tonal(
onPressed: _loadMore,
child: const Text('Load more'),
),
), ),
), ),
),
],
); );
} },
final item = news.items[i]; ),
return NewsCard(
item: RssItemMeta.fromNewsItem(item),
reaction: news.reactions[item.id],
onReaction: (itemId, reaction) => ref
.read(newsProvider.notifier)
.toggleReaction(itemId, reaction),
onDiscuss: _openingChat.contains(item.id)
? null
: () => _handleDiscuss(item.id),
);
},
),
), ),
), ),
], ],
+4 -4
View File
@@ -92,7 +92,7 @@ class SettingsScreen extends ConsumerWidget {
child: const Text('Check'), child: const Text('Check'),
), ),
), ),
if (update.status == UpdateStatus.available || if (update.status == UpdateStatus.readyToInstall ||
update.status == UpdateStatus.downloading) update.status == UpdateStatus.downloading)
_UpdateTile(update: update), _UpdateTile(update: update),
if (update.status == UpdateStatus.error) if (update.status == UpdateStatus.error)
@@ -131,7 +131,7 @@ class SettingsScreen extends ConsumerWidget {
if (update.status == UpdateStatus.upToDate) { if (update.status == UpdateStatus.upToDate) {
return Text('v$current — up to date'); return Text('v$current — up to date');
} }
if (update.status == UpdateStatus.available || if (update.status == UpdateStatus.readyToInstall ||
update.status == UpdateStatus.downloading) { update.status == UpdateStatus.downloading) {
return Text('v$current installed'); return Text('v$current installed');
} }
@@ -202,12 +202,12 @@ class _UpdateTile extends ConsumerWidget {
'${(update.downloadProgress * 100).toStringAsFixed(0)}%'), '${(update.downloadProgress * 100).toStringAsFixed(0)}%'),
], ],
) )
: const Text('Tap to download and install'), : const Text('Ready to install'),
trailing: isDownloading trailing: isDownloading
? null ? null
: FilledButton( : FilledButton(
onPressed: () => onPressed: () =>
ref.read(updateProvider.notifier).downloadAndInstall(), ref.read(updateProvider.notifier).install(),
child: const Text('Install'), child: const Text('Install'),
), ),
); );
+83 -2
View File
@@ -1,12 +1,17 @@
import 'dart:math' show min; import 'dart:math' show min;
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/message.dart'; import '../data/models/message.dart';
import '../providers/api_client_provider.dart';
import '../providers/settings_provider.dart';
import 'tool_call_chip.dart'; import 'tool_call_chip.dart';
class ChatMessageBubble extends StatelessWidget { class ChatMessageBubble extends ConsumerWidget {
final Message message; final Message message;
final String streamingStatus; final String streamingStatus;
const ChatMessageBubble({ const ChatMessageBubble({
@@ -16,9 +21,11 @@ class ChatMessageBubble extends StatelessWidget {
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final isUser = message.role == MessageRole.user; final isUser = message.role == MessageRole.user;
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final serverUrl = ref.watch(serverUrlProvider) ?? '';
final dio = ref.watch(dioProvider);
final isGenerating = message.status == 'generating'; final isGenerating = message.status == 'generating';
final toolCalls = message.toolCalls ?? const []; final toolCalls = message.toolCalls ?? const [];
@@ -116,6 +123,14 @@ class ChatMessageBubble extends StatelessWidget {
if (message.content.isNotEmpty) if (message.content.isNotEmpty)
MarkdownBody( MarkdownBody(
data: message.content, data: message.content,
imageBuilder: (uri, title, alt) {
return _AuthImage(
uri: uri,
alt: alt,
serverUrl: serverUrl,
dio: dio,
);
},
styleSheet: MarkdownStyleSheet( styleSheet: MarkdownStyleSheet(
p: TextStyle( p: TextStyle(
color: isUser 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<Uint8List> _future;
@override
void initState() {
super.initState();
_future = _fetchImage();
}
Future<Uint8List> _fetchImage() async {
var url = widget.uri.toString();
if (url.startsWith('/')) {
url = '${widget.serverUrl}$url';
}
final response = await widget.dio.get<List<int>>(
url,
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List>(
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'),
),
);
},
);
}
}
+71 -8
View File
@@ -27,12 +27,24 @@ class KnowledgeItemCard extends StatelessWidget {
String? get _subtitle { String? get _subtitle {
if (item.noteType == 'task') { if (item.noteType == 'task') {
if (item.body.trim().isNotEmpty) {
final preview = item.body.trim().replaceAll('\n', ' ');
return preview.length > 200 ? '${preview.substring(0, 200)}' : preview;
}
if (item.dueDate != null) return 'Due ${item.dueDate}'; if (item.dueDate != null) return 'Due ${item.dueDate}';
return item.status; return item.status;
} }
if (item.body.trim().isEmpty) return null; if (item.body.trim().isEmpty) return null;
final preview = item.body.trim().replaceAll('\n', ' '); final preview = item.body.trim().replaceAll('\n', ' ');
return preview.length > 120 ? '${preview.substring(0, 120)}' : preview; return preview.length > 200 ? '${preview.substring(0, 200)}' : preview;
}
void _onTap(BuildContext context) {
if (item.noteType == 'task') {
context.push('/tasks/${item.id}/edit');
} else {
context.push('/notes/${item.id}');
}
} }
@override @override
@@ -53,13 +65,64 @@ class KnowledgeItemCard extends StatelessWidget {
) )
: null, : null,
trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null, trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null,
onTap: () { onTap: () => _onTap(context),
if (item.noteType == 'task') { );
context.push('/tasks/${item.id}/edit'); }
} else {
context.push('/notes/${item.id}'); Widget buildGridCard(BuildContext context) {
} final scheme = Theme.of(context).colorScheme;
}, final textTheme = Theme.of(context).textTheme;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: scheme.outlineVariant),
),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => _onTap(context),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(_icon, size: 18, color: _statusColor(context)),
const SizedBox(width: 8),
Expanded(
child: Text(
item.title.isEmpty ? '(untitled)' : item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
),
),
],
),
if (_subtitle != null) ...[
const SizedBox(height: 8),
Expanded(
child: Text(
_subtitle!,
overflow: TextOverflow.ellipsis,
maxLines: 4,
style: textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
height: 1.4,
),
),
),
],
if (item.tags.isNotEmpty) ...[
const Spacer(),
_TagChips(tags: item.tags),
],
],
),
),
),
); );
} }
} }
+3 -1
View File
@@ -54,6 +54,7 @@ class NewsCard extends StatelessWidget {
final String? reaction; // 'up' | 'down' | null final String? reaction; // 'up' | 'down' | null
final void Function(int itemId, String reaction) onReaction; final void Function(int itemId, String reaction) onReaction;
final VoidCallback? onDiscuss; final VoidCallback? onDiscuss;
final int snippetMaxLines;
const NewsCard({ const NewsCard({
super.key, super.key,
@@ -61,6 +62,7 @@ class NewsCard extends StatelessWidget {
required this.reaction, required this.reaction,
required this.onReaction, required this.onReaction,
this.onDiscuss, this.onDiscuss,
this.snippetMaxLines = 2,
}); });
Future<void> _openUrl() async { Future<void> _openUrl() async {
@@ -135,7 +137,7 @@ class NewsCard extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
item.snippet, item.snippet,
maxLines: 2, maxLines: snippetMaxLines,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith( style: textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
+1
View File
@@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
vad
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)
@@ -11,7 +11,7 @@ import flutter_timezone
import just_audio import just_audio
import open_file_mac import open_file_mac
import package_info_plus import package_info_plus
import record_darwin import record_macos
import shared_preferences_foundation import shared_preferences_foundation
import url_launcher_macos import url_launcher_macos
@@ -22,7 +22,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) 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")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
} }
+23 -7
View File
@@ -804,10 +804,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: record name: record
sha256: "2e3d56d196abcd69f1046339b75e5f3855b2406fc087e5991f6703f188aa03a6" sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.2.1" version: "6.2.0"
record_android: record_android:
dependency: transitive dependency: transitive
description: description:
@@ -816,22 +816,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.1" version: "1.5.1"
record_darwin: record_ios:
dependency: transitive dependency: transitive
description: description:
name: record_darwin name: record_ios
sha256: e487eccb19d82a9a39cd0126945cfc47b9986e0df211734e2788c95e3f63c82c sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.2" version: "1.2.0"
record_linux: record_linux:
dependency: "direct overridden" dependency: transitive
description: description:
name: record_linux name: record_linux
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
record_macos:
dependency: transitive
description:
name: record_macos
sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
record_platform_interface: record_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -1165,6 +1173,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.3" version: "4.5.3"
vad:
dependency: "direct main"
description:
name: vad
sha256: ef6c8b12c5af7a6a519ff5684f074b8a2ac00c434705f544af379ea77bccd258
url: "https://pub.dev"
source: hosted
version: "0.0.7+1"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
+2 -4
View File
@@ -28,13 +28,11 @@ dependencies:
google_fonts: ^8.0.2 google_fonts: ^8.0.2
flutter_timezone: ^5.0.2 flutter_timezone: ^5.0.2
url_launcher: ^6.3.1 url_launcher: ^6.3.1
record: ^5.0.0 record: ^6.2.0
vad: ^0.0.7
just_audio: ^0.9.39 just_audio: ^0.9.39
table_calendar: ^3.1.2 table_calendar: ^3.1.2
dependency_overrides:
record_linux: ^1.3.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
+1
View File
@@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
vad
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)