01aa362d3c
Previously, if the backend was unreachable at launch, the splash screen routed to the login screen. The server URL remained persisted in SharedPreferences but visually appeared lost, frustrating any user who isn't the service operator. - New AuthStatus.offline distinguishes network failures (NetworkException) from HTTP 401 in AuthNotifier.verify(). - Persist has_ever_logged_in flag on first successful verify/login. - Offline + ever-logged-in lands on the briefing with a sticky offline banner (retry button) instead of being punted to login. - OfflineBanner widget is Tier-2-ready so we can surface "last sync X min ago" once real caching lands (Fable task #147).
674 lines
22 KiB
Dart
674 lines
22 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/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.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 _baseTabs = [
|
|
Routes.briefing,
|
|
Routes.knowledge,
|
|
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.
|
|
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 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 route.
|
|
void _refreshTab(String route) {
|
|
if (route == Routes.briefing) {
|
|
ref.invalidate(briefingProvider);
|
|
} else if (route == Routes.knowledge) {
|
|
ref.read(knowledgeProvider.notifier).refresh();
|
|
} else if (route == Routes.conversations) {
|
|
ref.read(conversationsProvider.notifier).refresh();
|
|
} else if (route == Routes.news) {
|
|
ref.read(newsProvider.notifier).refresh();
|
|
} else if (route == Routes.calendar) {
|
|
ref.read(calendarProvider.notifier).refresh();
|
|
}
|
|
}
|
|
|
|
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);
|
|
},
|
|
),
|
|
if (ref.read(rssEnabledProvider))
|
|
ListTile(
|
|
leading: const Icon(Icons.newspaper_outlined),
|
|
title: const Text('News'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
context.push(Routes.news);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.calendar_month_outlined),
|
|
title: const Text('Calendar'),
|
|
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 rssEnabled = ref.watch(rssEnabledProvider);
|
|
final tabs = _tabs(rssEnabled);
|
|
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.wb_sunny_outlined),
|
|
selectedIcon: Icon(Icons.wb_sunny),
|
|
label: Text('Briefing'),
|
|
),
|
|
const NavigationRailDestination(
|
|
icon: Icon(Icons.menu_book_outlined),
|
|
selectedIcon: Icon(Icons.menu_book),
|
|
label: Text('Knowledge'),
|
|
),
|
|
const NavigationRailDestination(
|
|
icon: Icon(Icons.chat_bubble_outline),
|
|
selectedIcon: Icon(Icons.chat_bubble),
|
|
label: Text('Chat'),
|
|
),
|
|
const NavigationRailDestination(
|
|
icon: Icon(Icons.folder_outlined),
|
|
selectedIcon: Icon(Icons.folder),
|
|
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'),
|
|
),
|
|
],
|
|
),
|
|
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.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,
|
|
);
|
|
}
|
|
}
|