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
+4 -3
View File
@@ -85,14 +85,15 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
Future<void> _delete() async {
final confirm = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
builder: (dialogContext) => AlertDialog(
title: const Text('Delete task?'),
content: Text(_titleController.text),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(context, true),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete')),
],
),
+59 -12
View File
@@ -16,6 +16,8 @@ class TasksListScreen extends ConsumerStatefulWidget {
class _TasksListScreenState extends ConsumerState<TasksListScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabs;
bool _showSearch = false;
String _search = '';
@override
void initState() {
@@ -35,7 +37,35 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
return Scaffold(
appBar: AppBar(
title: const Text('Tasks'),
automaticallyImplyLeading: false,
title: _showSearch
? TextField(
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search tasks…',
border: InputBorder.none,
),
onChanged: (v) =>
setState(() => _search = v.trim().toLowerCase()),
)
: null,
actions: [
if (_showSearch)
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Close search',
onPressed: () => setState(() {
_showSearch = false;
_search = '';
}),
)
else
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search',
onPressed: () => setState(() => _showSearch = true),
),
],
bottom: TabBar(
controller: _tabs,
tabs: const [
@@ -47,11 +77,14 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
),
body: tasksAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
error: (_, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Error: $e'),
const Icon(Icons.cloud_off, size: 48),
const SizedBox(height: 12),
const Text('Could not load tasks.'),
const SizedBox(height: 4),
TextButton(
onPressed: () => ref.invalidate(tasksProvider),
child: const Text('Retry'),
@@ -60,21 +93,29 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
),
),
data: (tasks) {
final filtered = _search.isEmpty
? tasks
: tasks
.where((t) =>
t.title.toLowerCase().contains(_search) ||
(t.description ?? '').toLowerCase().contains(_search))
.toList();
final todo =
tasks.where((t) => t.status == TaskStatus.todo).toList();
filtered.where((t) => t.status == TaskStatus.todo).toList();
final inProgress =
tasks.where((t) => t.status == TaskStatus.inProgress).toList();
filtered.where((t) => t.status == TaskStatus.inProgress).toList();
final done =
tasks.where((t) => t.status == TaskStatus.done).toList();
filtered.where((t) => t.status == TaskStatus.done).toList();
return RefreshIndicator(
onRefresh: () => ref.refresh(tasksProvider.future),
child: TabBarView(
controller: _tabs,
children: [
_TaskList(tasks: todo),
_TaskList(tasks: inProgress),
_TaskList(tasks: done),
_TaskList(tasks: todo, search: _search),
_TaskList(tasks: inProgress, search: _search),
_TaskList(tasks: done, search: _search),
],
),
);
@@ -91,12 +132,17 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
class _TaskList extends ConsumerWidget {
final List<Task> tasks;
const _TaskList({required this.tasks});
final String search;
const _TaskList({required this.tasks, this.search = ''});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (tasks.isEmpty) {
return const Center(child: Text('No tasks here.'));
return Center(
child: Text(
search.isEmpty ? 'No tasks here.' : 'No tasks match "$search".',
),
);
}
return ListView.separated(
itemCount: tasks.length,
@@ -107,7 +153,8 @@ class _TaskList extends ConsumerWidget {
leading: _priorityIcon(task.priority),
title: Text(task.title),
subtitle: task.dueDate != null
? Text('Due: ${task.dueDate!.toLocal().toString().substring(0, 10)}')
? Text(
'Due: ${task.dueDate!.toLocal().toString().substring(0, 10)}')
: null,
onTap: () => context.push(
Routes.taskEdit.replaceFirst(':id', '${task.id}'),