Add search, offline queue, app icon, and UI polish

- Collapsible search in notes and tasks: magnifying glass in AppBar
  expands to a text field inline; close button resets the filter
- Offline capture queue: failed quick-captures (NetworkException) are
  persisted to SharedPreferences and retried automatically on next
  successful submit or app start; badge shows pending count
- App icon: book-with-sparkle logo from FabledAssistant SVG rendered
  at all Android densities with adaptive icon (indigo #6366f1 bg)
- Dark mode subtitle fix: use colorScheme.onSurfaceVariant for note
  preview and conversation timestamp text
- Remove swipe-to-delete (accidental deletions); long-press remains
- Chat: SSE streaming reliability, polling fallback, title patching
- Settings: theme toggle (system/light/dark) persisted to prefs
- Notes: delete button in edit screen; body preview in list
- Tasks: fix delete dialog context; description search support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 23:15:06 -05:00
parent 1fb792177e
commit 2a35fe5532
33 changed files with 854 additions and 282 deletions
+191 -11
View File
@@ -3,15 +3,19 @@ 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/quick_capture/quick_capture_screen.dart';
import 'screens/settings/settings_screen.dart';
import 'screens/setup/setup_screen.dart';
import 'screens/splash/splash_screen.dart';
@@ -66,10 +70,6 @@ final routerProvider = Provider<GoRouter>((ref) {
path: Routes.login,
builder: (_, _) => const LoginScreen(),
),
GoRoute(
path: Routes.quickCapture,
builder: (_, _) => const QuickCaptureScreen(),
),
GoRoute(
path: Routes.settings,
builder: (_, _) => const SettingsScreen(),
@@ -146,7 +146,12 @@ class _Shell extends ConsumerWidget {
final index = _tabIndex(location);
return Scaffold(
body: child,
body: Column(
children: [
const _QuickCaptureBar(),
Expanded(child: child),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: index,
onDestinationSelected: (i) => context.go(_tabs[i]),
@@ -157,12 +162,185 @@ class _Shell extends ConsumerWidget {
icon: Icon(Icons.chat_bubble), label: 'Chat'),
],
),
floatingActionButton: FloatingActionButton(
heroTag: 'shell_fab',
onPressed: () => context.push(Routes.quickCapture),
child: const Icon(Icons.add),
);
}
}
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 {
final queue = ref.read(captureQueueProvider);
if (queue.isEmpty) return;
final api = ref.read(quickCaptureApiProvider);
for (final text in List<String>.from(queue)) {
try {
final result = await api.capture(text);
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.
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),
),
],
),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
);
}
}
@@ -173,9 +351,11 @@ class FabledApp extends ConsumerWidget {
@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,