This repository has been archived on 2026-06-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
FabledApp/lib/app.dart
T
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

700 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_timezone/flutter_timezone.dart';
import 'core/constants.dart';
import 'core/theme.dart';
import 'providers/api_client_provider.dart';
import 'providers/auth_provider.dart';
import 'core/exceptions.dart';
import 'providers/capture_queue_provider.dart';
import 'providers/capture_work_queue_provider.dart';
import 'providers/briefing_provider.dart';
import 'providers/calendar_provider.dart';
import 'providers/chat_provider.dart';
import 'providers/knowledge_provider.dart';
import 'providers/news_provider.dart';
import 'providers/settings_provider.dart';
import 'providers/update_provider.dart';
import 'screens/auth/login_screen.dart';
import 'screens/briefing/briefing_screen.dart';
import 'screens/knowledge/knowledge_screen.dart';
import 'screens/library/project_tasks_screen.dart';
import 'screens/chat/chat_screen.dart';
import 'screens/chat/conversations_tab_screen.dart';
import 'screens/notes/note_detail_screen.dart';
import 'screens/projects/project_edit_screen.dart';
import 'screens/projects/projects_screen.dart';
import 'screens/notes/note_edit_screen.dart';
import 'screens/settings/settings_screen.dart';
import 'screens/news/news_screen.dart';
import 'screens/setup/setup_screen.dart';
import 'screens/splash/splash_screen.dart';
import 'screens/tasks/task_edit_screen.dart';
import 'screens/calendar/calendar_screen.dart';
import 'providers/voice_provider.dart';
import 'widgets/voice_mic_button.dart';
// ChangeNotifier that fires when auth or server URL changes,
// used as GoRouter.refreshListenable so the router re-evaluates redirects
// without being recreated.
class _RouterNotifier extends ChangeNotifier {
_RouterNotifier(Ref ref) {
ref.listen(authProvider, (_, _) => notifyListeners());
ref.listen(serverUrlProvider, (_, _) => notifyListeners());
}
}
final routerProvider = Provider<GoRouter>((ref) {
final notifier = _RouterNotifier(ref);
return GoRouter(
refreshListenable: notifier,
initialLocation: Routes.splash,
redirect: (context, state) {
final location = state.matchedLocation;
final serverUrl = ref.read(serverUrlProvider);
final authStatus = ref.read(authProvider);
if (serverUrl == null || serverUrl.isEmpty) {
if (location != Routes.setup) return Routes.setup;
return null;
}
if (authStatus == AuthStatus.unauthenticated) {
if (location != Routes.login && location != Routes.setup) {
return Routes.login;
}
return null;
}
return null;
},
routes: [
GoRoute(
path: Routes.splash,
builder: (_, _) => const SplashScreen(),
),
GoRoute(
path: Routes.setup,
builder: (_, _) => const SetupScreen(),
),
GoRoute(
path: Routes.login,
builder: (_, _) => const LoginScreen(),
),
GoRoute(
path: Routes.settings,
builder: (_, _) => const SettingsScreen(),
),
GoRoute(
path: Routes.noteNew,
builder: (_, state) {
final extra = state.extra as Map<String, dynamic>?;
return NoteEditScreen(noteType: extra?['noteType'] as String?);
},
),
GoRoute(
path: Routes.noteDetail,
builder: (_, state) => NoteDetailScreen(
noteId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: Routes.noteEdit,
builder: (_, state) => NoteEditScreen(
noteId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: Routes.taskNew,
builder: (_, state) => TaskEditScreen(
initialProjectId: state.uri.queryParameters['projectId'] != null
? int.tryParse(state.uri.queryParameters['projectId']!)
: null,
),
),
GoRoute(
path: Routes.taskEdit,
builder: (_, state) => TaskEditScreen(
taskId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: Routes.projectTasks,
builder: (_, state) => ProjectTasksScreen(
projectId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: '/projects/new',
builder: (_, _) => const ProjectEditScreen(),
),
GoRoute(
path: Routes.projectEdit,
builder: (_, state) => ProjectEditScreen(
projectId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: Routes.chat,
builder: (_, state) => ChatScreen(
conversationId: int.parse(state.pathParameters['id']!),
),
),
ShellRoute(
builder: (context, state, child) => _Shell(child: child),
routes: [
GoRoute(
path: Routes.briefing,
builder: (_, _) => const BriefingScreen(),
),
GoRoute(
path: Routes.knowledge,
builder: (_, _) => const KnowledgeScreen(),
),
GoRoute(
path: Routes.conversations,
builder: (_, _) => const ConversationsTabScreen(),
),
GoRoute(
path: Routes.projects,
builder: (_, _) => const ProjectsScreen(),
),
GoRoute(
path: Routes.news,
builder: (_, _) => const NewsScreen(),
),
GoRoute(
path: Routes.calendar,
builder: (_, _) => const CalendarScreen(),
),
],
),
],
);
});
class _Shell extends ConsumerStatefulWidget {
final Widget child;
const _Shell({required this.child});
@override
ConsumerState<_Shell> createState() => _ShellState();
}
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
static const _tabs = [
Routes.briefing,
Routes.knowledge,
Routes.conversations,
Routes.projects,
Routes.news,
Routes.calendar,
];
// Minimum gap between app-resume refreshes to avoid hammering the server.
static const _resumeCooldown = Duration(seconds: 30);
DateTime? _lastResumeRefresh;
int? _prevTabIndex;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
// Silent update check — only if we haven't already checked this session.
final repoUrl = ref.read(forgejoRepoUrlProvider);
if (repoUrl != null && repoUrl.isNotEmpty) {
final status = ref.read(updateProvider).status;
if (status == UpdateStatus.idle || status == UpdateStatus.error) {
ref.read(updateProvider.notifier).check(repoUrl);
}
}
// Sync device timezone to backend so briefing and chat use local time.
_syncTimezone();
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state != AppLifecycleState.resumed) return;
final now = DateTime.now();
if (_lastResumeRefresh != null &&
now.difference(_lastResumeRefresh!) < _resumeCooldown) {
return;
}
_lastResumeRefresh = now;
_refreshAll();
}
/// Refresh every major data provider. Safe to call speculatively —
/// providers that aren't currently watched are already disposed.
void _refreshAll() {
ref.read(conversationsProvider.notifier).refresh();
ref.read(calendarProvider.notifier).refresh();
ref.read(newsProvider.notifier).refresh();
ref.read(knowledgeProvider.notifier).refresh();
// briefingProvider is an AsyncNotifier family; invalidating is safe
// even if no conversation is open — it doesn't cause flicker since
// the briefing screen isn't a list view.
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();
}
}
Future<void> _syncTimezone() async {
try {
final tzInfo = await FlutterTimezone.getLocalTimezone();
await ref.read(settingsApiProvider).syncTimezone(tzInfo.identifier);
} catch (_) {
// Best-effort — failure is non-critical.
}
}
int _tabIndex(String location) {
for (var i = 0; i < _tabs.length; i++) {
if (location.startsWith(_tabs[i])) return i;
}
return 0;
}
void _showMoreSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.folder_outlined),
title: const Text('Projects'),
onTap: () {
Navigator.pop(context);
context.push(Routes.projects);
},
),
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'),
onTap: () {
Navigator.pop(context);
context.push(Routes.calendar);
},
),
],
),
),
);
}
void _showUpdateDialog(UpdateState update) {
showDialog<void>(
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'),
),
],
);
},
),
);
}
@override
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) {
WidgetsBinding.instance
.addPostFrameCallback((_) => _showUpdateDialog(next));
}
});
final location = GoRouterState.of(context).matchedLocation;
final index = _tabIndex(location);
// Refresh the incoming tab's data when switching between shell tabs.
if (_prevTabIndex != null && _prevTabIndex != index) {
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(index));
}
_prevTabIndex = index;
final child = widget.child;
final isWide = MediaQuery.of(context).size.width >= 600;
if (isWide) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
const _QuickCaptureBar(),
Expanded(
child: Row(
children: [
NavigationRail(
selectedIndex: index,
onDestinationSelected: (i) => context.go(_tabs[i]),
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: Text('Briefing'),
),
NavigationRailDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: Text('Knowledge'),
),
NavigationRailDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: Text('Chat'),
),
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(
icon: Icon(Icons.calendar_month_outlined),
selectedIcon: Icon(Icons.calendar_month),
label: Text('Calendar'),
),
],
),
const VerticalDivider(width: 1),
Expanded(child: child),
],
),
),
],
),
),
);
}
return Scaffold(
body: Column(
children: [
const _QuickCaptureBar(),
Expanded(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: child,
),
),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: index >= 3 ? 3 : index,
onDestinationSelected: (i) {
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: 'Briefing',
),
NavigationDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: 'Knowledge',
),
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: 'Chat',
),
NavigationDestination(
icon: Icon(Icons.more_horiz_outlined),
selectedIcon: Icon(Icons.more_horiz),
label: 'More',
),
],
),
);
}
}
class _QuickCaptureBar extends ConsumerStatefulWidget {
const _QuickCaptureBar();
@override
ConsumerState<_QuickCaptureBar> createState() => _QuickCaptureBarState();
}
class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
final _controller = TextEditingController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue());
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final text = _controller.text.trim();
if (text.isEmpty) return;
_controller.clear();
setState(() {}); // clear suffix icon
ref.read(captureWorkQueueProvider.notifier).enqueue(text);
}
Future<void> _drainOfflineQueue() async {
if (!mounted) return;
final queue = ref.read(captureQueueProvider);
if (queue.isEmpty) return;
for (final text in List<String>.from(queue)) {
if (!mounted) break;
try {
final conv =
await ref.read(conversationsProvider.notifier).create('');
final chatRepo = ref.read(chatRepositoryProvider);
await chatRepo.sendMessage(conv.id, text);
chatRepo.streamGeneration(conv.id).drain<void>().ignore();
await ref.read(captureQueueProvider.notifier).dequeue(text);
} on NetworkException {
break;
} catch (_) {
// Server error — drop from queue to prevent ghost items.
await ref.read(captureQueueProvider.notifier).dequeue(text);
}
}
}
Future<void> _toggleCaptureMic() async {
final voice = ref.read(voiceProvider);
if (voice.voiceModeActive) {
ref.read(voiceProvider.notifier).exitVoiceMode();
return;
}
await ref.read(voiceProvider.notifier).enterVoiceMode(
onTranscript: (transcript) async {
ref.read(captureWorkQueueProvider.notifier).enqueue(transcript);
},
enableTts: false,
onError: (msg) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(msg)));
},
);
}
String _hintForLocation(String location) {
if (location.startsWith(Routes.knowledge)) return 'Capture a note…';
if (location.startsWith(Routes.projects)) return 'Capture a note…';
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
return 'Capture a note…';
}
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
final offlineQueueCount = ref.watch(captureQueueProvider).length;
final workQueue = ref.watch(captureWorkQueueProvider);
final isWorking = workQueue.isNotEmpty;
final totalPending = workQueue.length + offlineQueueCount;
// Show snackbar when a result is published.
ref.listen(captureResultProvider, (_, result) {
if (result == null || !mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.message),
behavior: SnackBarBehavior.floating,
),
);
});
return SafeArea(
bottom: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _submit(),
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: ref.watch(voiceProvider).voiceModeActive
? 'Listening…'
: _hintForLocation(location),
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 10),
prefixIcon: totalPending > 0
? Badge(
label: Text('$totalPending'),
child: const Icon(Icons.cloud_upload_outlined),
)
: isWorking
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2),
),
)
: const Icon(Icons.auto_awesome_outlined),
suffixIcon: _controller.text.trim().isNotEmpty
? IconButton(
icon: const Icon(Icons.send),
onPressed: _submit,
tooltip: 'Capture',
)
: null,
),
),
),
VoiceMicButton(
mode: ref.watch(voiceProvider).mode,
voiceModeActive: ref.watch(voiceProvider).voiceModeActive,
amplitude: ref.watch(voiceProvider).amplitude,
onTap: _toggleCaptureMic,
),
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings',
onPressed: () => context.push(Routes.settings),
),
],
),
),
// Thin progress bar while the work queue is draining.
if (isWorking)
const LinearProgressIndicator(minHeight: 2)
else
const SizedBox(height: 2),
],
),
);
}
}
class FabledApp extends ConsumerWidget {
const FabledApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
final themeMode = ref.watch(themeModeProvider);
return MaterialApp.router(
title: 'Fabled',
themeMode: themeMode,
theme: fabledLightTheme(),
darkTheme: fabledDarkTheme(),
routerConfig: router,
);
}
}