dd250788f6
The backend retired /api/briefing/* and the RSS feature entirely. This
Flutter change mirrors what landed web-side: rename the briefing surface
to journal, repoint at /api/journal/*, and drop the news/RSS UI since
its endpoints no longer exist.
New (mirrors briefing structure with adapted shapes):
- lib/data/api/journal_api.dart — getToday, getDay, getDays, triggerPrep
- lib/data/models/journal_day.dart — {day_date, conversation, messages}
- lib/providers/journal_provider.dart — async notifier, sendReply, polling,
silent refresh, regeneratePrep. Mirrors the briefing notifier 1:1
- lib/widgets/journal_prep_card.dart — adapted briefing_digest_card
- lib/screens/journal/journal_screen.dart — adapted briefing_screen,
weather card preserved (rendered from msg_metadata.sections.weather
on the daily-prep assistant message). News cards / RSS reactions /
article-discuss removed
- lib/screens/journal/journal_history_screen.dart — past days picker
pulls /api/journal/days, drills into /api/journal/day/<iso>
Wiring:
- Routes.briefing → Routes.journal (constants.dart)
- Routes.news removed
- briefingApiProvider → journalApiProvider (api_client_provider.dart)
- newsApiProvider removed
- app.dart: shell tab "Briefing" → "Journal"; News destination removed
from nav rail, bottom nav, and the More sheet
- splash_screen.dart and login_screen.dart: redirect Routes.journal
instead of Routes.briefing
- chat_api.dart: drop openArticleInChat (calls deleted /api/chat/from-article)
- settings_provider.dart: drop rssEnabled getter and rssEnabledProvider
Deleted:
- lib/screens/briefing/ (whole directory)
- lib/screens/news/ (whole directory)
- lib/data/api/briefing_api.dart, news_api.dart
- lib/data/models/briefing_conversation.dart, briefing_feed.dart, news_item.dart
- lib/providers/briefing_provider.dart, news_provider.dart
- lib/widgets/briefing_digest_card.dart, news_card.dart
- test cases for NewsItem and BriefingFeed in test/widget_test.dart
flutter analyze: 0 issues.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
647 lines
21 KiB
Dart
647 lines
21 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/calendar_provider.dart';
|
|
import 'providers/chat_provider.dart';
|
|
import 'providers/journal_provider.dart';
|
|
import 'providers/knowledge_provider.dart';
|
|
import 'providers/settings_provider.dart';
|
|
import 'providers/update_provider.dart';
|
|
import 'screens/auth/login_screen.dart';
|
|
import 'screens/journal/journal_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/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/offline_banner.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());
|
|
ref.listen(hasEverLoggedInProvider, (_, _) => 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);
|
|
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
|
|
|
|
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;
|
|
}
|
|
|
|
// Offline and never logged in on this device — can't prove identity,
|
|
// so gate behind login. Offline + ever-logged-in falls through to
|
|
// normal navigation with the offline banner surfacing in _Shell.
|
|
if (authStatus == AuthStatus.offline && !hasEverLoggedIn) {
|
|
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.journal,
|
|
builder: (_, _) => const JournalScreen(),
|
|
),
|
|
GoRoute(
|
|
path: Routes.knowledge,
|
|
builder: (_, _) => const KnowledgeScreen(),
|
|
),
|
|
GoRoute(
|
|
path: Routes.conversations,
|
|
builder: (_, _) => const ConversationsTabScreen(),
|
|
),
|
|
GoRoute(
|
|
path: Routes.projects,
|
|
builder: (_, _) => const ProjectsScreen(),
|
|
),
|
|
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 _baseTabs = [
|
|
Routes.journal,
|
|
Routes.knowledge,
|
|
Routes.conversations,
|
|
Routes.projects,
|
|
];
|
|
|
|
List<String> _tabs() => [
|
|
..._baseTabs,
|
|
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((_) {
|
|
// 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) {
|
|
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 journal 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(knowledgeProvider.notifier).refresh();
|
|
// journalProvider is an AsyncNotifier; invalidating is safe even if
|
|
// the journal screen isn't currently mounted.
|
|
ref.invalidate(journalProvider);
|
|
}
|
|
|
|
/// Refresh only the provider backing the given shell tab route.
|
|
void _refreshTab(String route) {
|
|
if (route == Routes.journal) {
|
|
ref.invalidate(journalProvider);
|
|
} else if (route == Routes.knowledge) {
|
|
ref.read(knowledgeProvider.notifier).refresh();
|
|
} else if (route == Routes.conversations) {
|
|
ref.read(conversationsProvider.notifier).refresh();
|
|
} else if (route == Routes.calendar) {
|
|
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, List<String> tabs) {
|
|
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.calendar_month_outlined),
|
|
title: const Text('Calendar'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
context.push(Routes.calendar);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Show update dialog once when a new version is detected.
|
|
ref.listen(updateProvider, (prev, next) {
|
|
if (next.status == UpdateStatus.readyToInstall &&
|
|
prev?.status != UpdateStatus.readyToInstall) {
|
|
WidgetsBinding.instance
|
|
.addPostFrameCallback((_) => _showUpdateSnackbar(next));
|
|
}
|
|
});
|
|
final tabs = _tabs();
|
|
final location = GoRouterState.of(context).matchedLocation;
|
|
final index = _tabIndex(location, tabs);
|
|
|
|
// Refresh the incoming tab's data when switching between shell tabs.
|
|
if (_prevTabIndex != null && _prevTabIndex != index) {
|
|
final route = index < tabs.length ? tabs[index] : tabs[0];
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(route));
|
|
}
|
|
_prevTabIndex = index;
|
|
|
|
final child = widget.child;
|
|
final isWide = MediaQuery.of(context).size.width >= 600;
|
|
|
|
if (isWide) {
|
|
return Scaffold(
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
const OfflineBanner(),
|
|
const _QuickCaptureBar(),
|
|
Expanded(
|
|
child: Row(
|
|
children: [
|
|
NavigationRail(
|
|
selectedIndex: index,
|
|
onDestinationSelected: (i) => context.go(tabs[i]),
|
|
labelType: NavigationRailLabelType.all,
|
|
destinations: const [
|
|
NavigationRailDestination(
|
|
icon: Icon(Icons.menu_book_outlined),
|
|
selectedIcon: Icon(Icons.menu_book),
|
|
label: Text('Journal'),
|
|
),
|
|
NavigationRailDestination(
|
|
icon: Icon(Icons.lightbulb_outline),
|
|
selectedIcon: Icon(Icons.lightbulb),
|
|
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.calendar_month_outlined),
|
|
selectedIcon: Icon(Icons.calendar_month),
|
|
label: Text('Calendar'),
|
|
),
|
|
],
|
|
),
|
|
const VerticalDivider(width: 1),
|
|
Expanded(child: child),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Scaffold(
|
|
body: Column(
|
|
children: [
|
|
const OfflineBanner(),
|
|
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.menu_book_outlined),
|
|
selectedIcon: Icon(Icons.menu_book),
|
|
label: 'Journal',
|
|
),
|
|
NavigationDestination(
|
|
icon: Icon(Icons.lightbulb_outline),
|
|
selectedIcon: Icon(Icons.lightbulb),
|
|
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,
|
|
);
|
|
}
|
|
}
|