eef101bef3
- Edit screens (note, task): cache _initFuture in initState instead of
calling _loadExisting() from FutureBuilder on every rebuild. Prevents
race condition where multiple concurrent loads could overwrite edits.
Also removes unnecessary _loaded flag and simplifies the pattern.
- Note editor: remove onChanged: (_) => setState((){}) on the body
TextField — triggered a full rebuild on every keystroke for no reason.
- SSE streaming: use utf8.decode(chunk, allowMalformed: true) so a
malformed byte sequence from the server skips rather than throwing an
unhandled FormatException that escaped all catch blocks.
- Chat provider: guard msgs.isEmpty before accessing msgs.last during
SSE chunk processing to prevent a potential index error on empty state.
- Offline queue drain: check mounted before and after each async gap in
_drainQueue so ref is never accessed on a disposed widget.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
377 lines
11 KiB
Dart
377 lines
11 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 'providers/api_client_provider.dart';
|
|
import 'providers/auth_provider.dart';
|
|
import 'providers/capture_queue_provider.dart';
|
|
import 'providers/notes_provider.dart';
|
|
import 'providers/settings_provider.dart';
|
|
import 'providers/tasks_provider.dart';
|
|
import 'screens/auth/login_screen.dart';
|
|
import 'screens/chat/chat_screen.dart';
|
|
import 'screens/chat/conversations_list_screen.dart';
|
|
import 'screens/notes/note_detail_screen.dart';
|
|
import 'screens/notes/note_edit_screen.dart';
|
|
import 'screens/notes/notes_list_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/tasks/tasks_list_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: (_, _) => const TaskEditScreen(),
|
|
),
|
|
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.notes,
|
|
builder: (_, _) => const NotesListScreen(),
|
|
),
|
|
GoRoute(
|
|
path: Routes.tasks,
|
|
builder: (_, _) => const TasksListScreen(),
|
|
),
|
|
GoRoute(
|
|
path: Routes.conversations,
|
|
builder: (_, _) => const ConversationsListScreen(),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
});
|
|
|
|
class _Shell extends ConsumerWidget {
|
|
final Widget child;
|
|
const _Shell({required this.child});
|
|
|
|
static const _tabs = [Routes.notes, Routes.tasks, Routes.conversations];
|
|
|
|
int _tabIndex(String location) {
|
|
for (var i = 0; i < _tabs.length; i++) {
|
|
if (location.startsWith(_tabs[i])) return i;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final location = GoRouterState.of(context).matchedLocation;
|
|
final index = _tabIndex(location);
|
|
|
|
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.note), label: 'Notes'),
|
|
NavigationDestination(icon: Icon(Icons.check_box), label: 'Tasks'),
|
|
NavigationDestination(
|
|
icon: 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();
|
|
bool _busy = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Retry any offline-queued captures from previous sessions.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _drainQueue());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
final text = _controller.text.trim();
|
|
if (text.isEmpty || _busy) return;
|
|
|
|
_controller.clear();
|
|
setState(() => _busy = true);
|
|
|
|
// Capture the messenger before the async gap so we can show a snackbar
|
|
// even if the user has navigated to a deeper view by the time it resolves.
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
|
|
try {
|
|
final result = await ref.read(quickCaptureApiProvider).capture(text);
|
|
|
|
switch (result.type) {
|
|
case 'note':
|
|
ref.invalidate(notesProvider);
|
|
case 'task':
|
|
case 'todo':
|
|
ref.invalidate(tasksProvider);
|
|
}
|
|
|
|
final msg = result.message.isNotEmpty
|
|
? result.message
|
|
: '${_typeLabel(result.type)} created: ${result.title}';
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(msg), behavior: SnackBarBehavior.floating),
|
|
);
|
|
_drainQueue(); // Silently flush any offline-queued captures.
|
|
} on NetworkException {
|
|
await ref.read(captureQueueProvider.notifier).enqueue(text);
|
|
messenger.showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
"You're offline — capture saved and will retry automatically."),
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
} on AppException catch (e) {
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(e.message), behavior: SnackBarBehavior.floating),
|
|
);
|
|
} catch (_) {
|
|
messenger.showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Capture failed. Please try again.'),
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _drainQueue() 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; // Still offline — stop draining.
|
|
} catch (_) {
|
|
// Server/parse error — remove to avoid infinite retries.
|
|
if (mounted) await ref.read(captureQueueProvider.notifier).dequeue(text);
|
|
}
|
|
}
|
|
}
|
|
|
|
String _typeLabel(String type) => switch (type) {
|
|
'note' => 'Note',
|
|
'task' => 'Task',
|
|
'event' => 'Event',
|
|
'todo' => 'To-do',
|
|
_ => type,
|
|
};
|
|
|
|
String _hintForLocation(String location) {
|
|
if (location.startsWith(Routes.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 queueCount = ref.watch(captureQueueProvider).length;
|
|
return SafeArea(
|
|
bottom: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _controller,
|
|
enabled: !_busy,
|
|
textInputAction: TextInputAction.send,
|
|
onSubmitted: (_) => _submit(),
|
|
onChanged: (_) => setState(() {}),
|
|
decoration: InputDecoration(
|
|
hintText: _hintForLocation(location),
|
|
isDense: true,
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(24),
|
|
),
|
|
prefixIcon: _busy
|
|
? const Padding(
|
|
padding: EdgeInsets.all(12),
|
|
child: SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
)
|
|
: queueCount > 0
|
|
? Badge(
|
|
label: Text('$queueCount'),
|
|
child:
|
|
const Icon(Icons.cloud_upload_outlined),
|
|
)
|
|
: const Icon(Icons.auto_awesome_outlined),
|
|
suffixIcon: _controller.text.trim().isNotEmpty && !_busy
|
|
? 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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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: ThemeData(
|
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
|
useMaterial3: true,
|
|
),
|
|
darkTheme: ThemeData(
|
|
colorScheme: ColorScheme.fromSeed(
|
|
seedColor: Colors.indigo,
|
|
brightness: Brightness.dark,
|
|
),
|
|
useMaterial3: true,
|
|
),
|
|
routerConfig: router,
|
|
);
|
|
}
|
|
}
|