def7519feb
- app.dart: add braces to single-statement if body - briefing_api.dart: escape <id> in doc comment (unintended HTML) - notes_api.dart, tasks_api.dart, projects_api.dart: use null-aware map elements (?'key': value) instead of if-null guards - task_edit_screen.dart: remove unused tasks_api.dart import - task_edit_screen.dart, project_selector.dart: suppress deprecated_member_use on DropdownButtonFormField.value (value is the controlled-widget param; switching to initialValue would break current-selection display) - project_selector.dart: use _ instead of __ for ignored error params
480 lines
15 KiB
Dart
480 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import 'core/constants.dart';
|
|
import 'core/exceptions.dart';
|
|
import 'core/theme.dart';
|
|
import 'providers/api_client_provider.dart';
|
|
import 'providers/auth_provider.dart';
|
|
import 'providers/capture_queue_provider.dart';
|
|
import 'providers/capture_work_queue_provider.dart';
|
|
import 'providers/notes_provider.dart';
|
|
import 'providers/settings_provider.dart';
|
|
import 'providers/update_provider.dart';
|
|
import 'providers/tasks_provider.dart';
|
|
import 'screens/auth/login_screen.dart';
|
|
import 'screens/briefing/briefing_screen.dart';
|
|
import 'screens/chat/chat_screen.dart';
|
|
import 'screens/chat/conversations_tab_screen.dart';
|
|
import 'screens/library/library_screen.dart';
|
|
import 'screens/notes/note_detail_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';
|
|
|
|
// 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: (_, _) => const NoteEditScreen(),
|
|
),
|
|
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.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.library,
|
|
builder: (_, _) => const LibraryScreen(),
|
|
),
|
|
GoRoute(
|
|
path: Routes.conversations,
|
|
builder: (_, _) => const ConversationsTabScreen(),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
});
|
|
|
|
class _Shell extends ConsumerStatefulWidget {
|
|
final Widget child;
|
|
const _Shell({required this.child});
|
|
|
|
@override
|
|
ConsumerState<_Shell> createState() => _ShellState();
|
|
}
|
|
|
|
class _ShellState extends ConsumerState<_Shell> {
|
|
static const _tabs = [
|
|
Routes.briefing,
|
|
Routes.library,
|
|
Routes.conversations,
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Silent update check on first app load.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final repoUrl = ref.read(forgejoRepoUrlProvider);
|
|
if (repoUrl != null && repoUrl.isNotEmpty) {
|
|
ref.read(updateProvider.notifier).check(repoUrl);
|
|
}
|
|
});
|
|
}
|
|
|
|
int _tabIndex(String location) {
|
|
for (var i = 0; i < _tabs.length; i++) {
|
|
if (location.startsWith(_tabs[i])) return i;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
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,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: const Text('Later'),
|
|
),
|
|
if (!isDownloading)
|
|
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);
|
|
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.library_books_outlined),
|
|
selectedIcon: Icon(Icons.library_books),
|
|
label: Text('Library'),
|
|
),
|
|
NavigationRailDestination(
|
|
icon: Icon(Icons.chat_bubble_outline),
|
|
selectedIcon: Icon(Icons.chat_bubble),
|
|
label: Text('Chat'),
|
|
),
|
|
],
|
|
),
|
|
const VerticalDivider(width: 1),
|
|
Expanded(child: child),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Scaffold(
|
|
body: Column(
|
|
children: [
|
|
const _QuickCaptureBar(),
|
|
Expanded(child: child),
|
|
],
|
|
),
|
|
bottomNavigationBar: NavigationBar(
|
|
selectedIndex: index,
|
|
onDestinationSelected: (i) => context.go(_tabs[i]),
|
|
destinations: const [
|
|
NavigationDestination(
|
|
icon: Icon(Icons.wb_sunny_outlined),
|
|
selectedIcon: Icon(Icons.wb_sunny),
|
|
label: 'Briefing',
|
|
),
|
|
NavigationDestination(
|
|
icon: Icon(Icons.library_books_outlined),
|
|
selectedIcon: Icon(Icons.library_books),
|
|
label: 'Library',
|
|
),
|
|
NavigationDestination(
|
|
icon: Icon(Icons.chat_bubble_outline),
|
|
selectedIcon: Icon(Icons.chat_bubble),
|
|
label: 'Chat',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
final api = ref.read(quickCaptureApiProvider);
|
|
for (final text in List<String>.from(queue)) {
|
|
if (!mounted) break;
|
|
try {
|
|
final result = await api.capture(text);
|
|
if (!mounted) break;
|
|
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
|
switch (result.type) {
|
|
case 'note':
|
|
ref.invalidate(notesProvider);
|
|
case 'task':
|
|
case 'todo':
|
|
ref.invalidate(tasksProvider);
|
|
}
|
|
} on NetworkException {
|
|
break;
|
|
} catch (_) {
|
|
if (mounted) {
|
|
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
String _hintForLocation(String location) {
|
|
if (location.startsWith(Routes.library) &&
|
|
location.contains('tasks')) { return 'Add a task…'; }
|
|
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: _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,
|
|
),
|
|
),
|
|
),
|
|
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,
|
|
);
|
|
}
|
|
}
|