diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..bcb2d8f
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..f3bf7cb
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..7d83267
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..e2c7b68
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..2355359
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..c79c58a
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index db77bb4..843c7aa 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 17987b7..da017d7 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 09d4391..6c9ee8e 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index d5f1c8d..3103d3e 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index 4d6372e..9c663a5 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..b3bc90c
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #6366f1
+
\ No newline at end of file
diff --git a/assets/icon/app_icon.png b/assets/icon/app_icon.png
new file mode 100644
index 0000000..a9c7e62
Binary files /dev/null and b/assets/icon/app_icon.png differ
diff --git a/assets/icon/app_icon_fg.png b/assets/icon/app_icon_fg.png
new file mode 100644
index 0000000..113d792
Binary files /dev/null and b/assets/icon/app_icon_fg.png differ
diff --git a/lib/app.dart b/lib/app.dart
index fa88276..61f4e82 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -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((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 _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 _drainQueue() async {
+ final queue = ref.read(captureQueueProvider);
+ if (queue.isEmpty) return;
+ final api = ref.read(quickCaptureApiProvider);
+ for (final text in List.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,
diff --git a/lib/data/api/chat_api.dart b/lib/data/api/chat_api.dart
index 3af7050..4707e16 100644
--- a/lib/data/api/chat_api.dart
+++ b/lib/data/api/chat_api.dart
@@ -42,17 +42,18 @@ class ChatApi {
}
}
- // Messages are embedded in the conversation detail response.
- Future> getMessages(int conversationId) async {
+ // Returns the conversation metadata AND its messages in a single request.
+ Future<(Conversation, List)> getMessages(int conversationId) async {
try {
final response =
await _dio.get('/api/chat/conversations/$conversationId');
final conv = response.data as Map;
+ final conversation = Conversation.fromJson(conv);
final list = conv['messages'] as List? ?? [];
- return list
+ final messages = list
.map((e) => Message.fromJson(e as Map))
- .where((m) => m.status != 'generating') // skip in-flight placeholders
.toList();
+ return (conversation, messages);
} on DioException catch (e) {
throw dioToApp(e);
}
@@ -77,7 +78,12 @@ class ChatApi {
'/api/chat/conversations/$conversationId/generation/stream',
options: Options(
responseType: ResponseType.stream,
- headers: {'Accept': 'text/event-stream'},
+ receiveTimeout: Duration.zero, // SSE streams run indefinitely
+ sendTimeout: Duration.zero,
+ headers: {
+ 'Accept': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ },
),
);
@@ -98,15 +104,19 @@ class ChatApi {
if (line.startsWith('event: ')) {
currentEvent = line.substring(7).trim();
} else if (line.startsWith('data: ')) {
- final jsonStr = line.substring(6).trim();
- if (currentEvent == 'chunk') {
+ final data = line.substring(6).trim();
+ if (data == '[DONE]') return;
+ if (currentEvent == 'done' || currentEvent == 'error') return;
+
+ // Parse as JSON if possible, otherwise yield raw text.
+ if (currentEvent == 'chunk' || currentEvent.isEmpty) {
try {
- final data = json.decode(jsonStr) as Map;
- final text = data['text'] as String? ?? '';
+ final obj = json.decode(data) as Map;
+ final text = obj['text'] as String? ?? '';
if (text.isNotEmpty) yield text;
- } catch (_) {}
- } else if (currentEvent == 'done' || currentEvent == 'error') {
- return;
+ } catch (_) {
+ if (data.isNotEmpty) yield data;
+ }
}
} else if (line.isEmpty) {
currentEvent = ''; // blank line = SSE event separator
diff --git a/lib/data/api/quick_capture_api.dart b/lib/data/api/quick_capture_api.dart
new file mode 100644
index 0000000..19ca190
--- /dev/null
+++ b/lib/data/api/quick_capture_api.dart
@@ -0,0 +1,49 @@
+import 'package:dio/dio.dart';
+
+import 'api_client.dart';
+
+/// Result returned by POST /api/quick-capture.
+/// type is one of: "note", "task", "event", "todo"
+class CaptureResult {
+ final String type;
+ final String message; // human-readable summary from the server
+ final bool fallback; // true when the server used a note as a fallback
+ final int? id;
+ final String title;
+
+ const CaptureResult({
+ required this.type,
+ required this.message,
+ this.fallback = false,
+ this.id,
+ required this.title,
+ });
+
+ factory CaptureResult.fromJson(Map json) {
+ final data = json['data'] as Map? ?? {};
+ return CaptureResult(
+ type: json['type'] as String? ?? 'note',
+ message: json['message'] as String? ?? '',
+ fallback: json['fallback'] as bool? ?? false,
+ id: data['id'] as int?,
+ title: data['title'] as String? ?? '',
+ );
+ }
+}
+
+class QuickCaptureApi {
+ final Dio _dio;
+ const QuickCaptureApi(this._dio);
+
+ Future capture(String text) async {
+ try {
+ final response = await _dio.post(
+ '/api/quick-capture',
+ data: {'text': text},
+ );
+ return CaptureResult.fromJson(response.data as Map);
+ } on DioException catch (e) {
+ throw dioToApp(e);
+ }
+ }
+}
diff --git a/lib/data/models/conversation.dart b/lib/data/models/conversation.dart
index 129c528..8edf44e 100644
--- a/lib/data/models/conversation.dart
+++ b/lib/data/models/conversation.dart
@@ -13,7 +13,7 @@ class Conversation {
factory Conversation.fromJson(Map json) => Conversation(
id: json['id'] as int,
- title: json['title'] as String,
+ title: json['title'] as String? ?? '',
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
diff --git a/lib/data/repositories/chat_repository.dart b/lib/data/repositories/chat_repository.dart
index afa2d3b..8d548c0 100644
--- a/lib/data/repositories/chat_repository.dart
+++ b/lib/data/repositories/chat_repository.dart
@@ -10,7 +10,7 @@ class ChatRepository {
Future createConversation(String title) =>
_api.createConversation(title);
Future deleteConversation(int id) => _api.deleteConversation(id);
- Future> getMessages(int conversationId) =>
+ Future<(Conversation, List)> getMessages(int conversationId) =>
_api.getMessages(conversationId);
Future sendMessage(int conversationId, String content) =>
_api.sendMessage(conversationId, content);
diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart
index 8a337a9..2c416c3 100644
--- a/lib/providers/api_client_provider.dart
+++ b/lib/providers/api_client_provider.dart
@@ -6,6 +6,7 @@ import '../data/api/api_client.dart';
import '../data/api/auth_api.dart';
import '../data/api/chat_api.dart';
import '../data/api/notes_api.dart';
+import '../data/api/quick_capture_api.dart';
import '../data/api/tasks_api.dart';
import '../data/repositories/auth_repository.dart';
import '../data/repositories/chat_repository.dart';
@@ -40,6 +41,10 @@ final chatApiProvider = Provider((ref) {
return ChatApi(ref.watch(dioProvider));
});
+final quickCaptureApiProvider = Provider((ref) {
+ return QuickCaptureApi(ref.watch(dioProvider));
+});
+
final authRepositoryProvider = Provider((ref) {
return AuthRepository(ref.watch(authApiProvider));
});
diff --git a/lib/providers/capture_queue_provider.dart b/lib/providers/capture_queue_provider.dart
new file mode 100644
index 0000000..89f2bcb
--- /dev/null
+++ b/lib/providers/capture_queue_provider.dart
@@ -0,0 +1,29 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'settings_provider.dart';
+
+final captureQueueProvider =
+ StateNotifierProvider>(
+ (ref) => CaptureQueueNotifier(ref.watch(sharedPreferencesProvider)),
+);
+
+class CaptureQueueNotifier extends StateNotifier> {
+ static const _key = 'capture_queue';
+ final SharedPreferences _prefs;
+
+ CaptureQueueNotifier(this._prefs)
+ : super(_prefs.getStringList(_key) ?? []);
+
+ Future enqueue(String text) async {
+ final updated = [...state, text];
+ await _prefs.setStringList(_key, updated);
+ state = updated;
+ }
+
+ Future dequeue(String text) async {
+ final updated = List.from(state)..remove(text);
+ await _prefs.setStringList(_key, updated);
+ state = updated;
+ }
+}
diff --git a/lib/providers/chat_provider.dart b/lib/providers/chat_provider.dart
index f76c8b8..1d53d05 100644
--- a/lib/providers/chat_provider.dart
+++ b/lib/providers/chat_provider.dart
@@ -4,6 +4,9 @@ import '../data/models/conversation.dart';
import '../data/models/message.dart';
import 'api_client_provider.dart';
+// Separate StateProvider so UI re-builds immediately when streaming starts/stops.
+final isStreamingProvider = StateProvider.family((ref, _) => false);
+
final conversationsProvider =
AsyncNotifierProvider>(
ConversationsNotifier.new);
@@ -28,71 +31,101 @@ class ConversationsNotifier extends AsyncNotifier> {
if (c.id != id) c,
]);
}
+
+ // Called after a message is sent to patch the server-generated title
+ // in-place without triggering a full reload or loading state.
+ void patchConversation(Conversation updated) {
+ final list = state.valueOrNull;
+ if (list == null) return;
+ state = AsyncData([
+ for (final c in list)
+ if (c.id == updated.id) updated else c,
+ ]);
+ }
}
final messagesProvider = AsyncNotifierProvider.family, int>(MessagesNotifier.new);
class MessagesNotifier extends FamilyAsyncNotifier, int> {
- bool _isStreaming = false;
- bool get isStreaming => _isStreaming;
-
@override
Future> build(int arg) async {
- return ref.watch(chatRepositoryProvider).getMessages(arg);
+ final (_, messages) =
+ await ref.watch(chatRepositoryProvider).getMessages(arg);
+ return messages;
}
Future sendMessage(String content) async {
- final conversationId = arg;
+ final convId = arg;
final repo = ref.read(chatRepositoryProvider);
+ final previousMessages = state.valueOrNull ?? [];
- // Optimistically add the user message.
+ // Optimistic UI: show the user message + assistant placeholder immediately.
final userMsg = Message(
- conversationId: conversationId,
+ conversationId: convId,
role: MessageRole.user,
content: content,
);
- state = AsyncData([...state.valueOrNull ?? [], userMsg]);
-
- // Add an empty assistant placeholder.
final placeholder = Message(
- conversationId: conversationId,
+ conversationId: convId,
role: MessageRole.assistant,
content: '',
status: 'generating',
);
- state = AsyncData([...state.requireValue, placeholder]);
+ state = AsyncData([...previousMessages, userMsg, placeholder]);
+ ref.read(isStreamingProvider(convId).notifier).state = true;
- _isStreaming = true;
try {
- // Step 1: POST the message (fires background generation on server).
- await repo.sendMessage(conversationId, content);
-
- // Step 2: Stream chunks and update the placeholder in place.
- await for (final chunk in repo.streamGeneration(conversationId)) {
- final msgs = state.requireValue;
- final last = msgs.last.copyWith(content: msgs.last.content + chunk);
- state = AsyncData([...msgs.sublist(0, msgs.length - 1), last]);
- }
-
- // Mark the assistant message as complete.
- final msgs = state.requireValue;
- final last = msgs.last.copyWith(status: 'complete');
- state = AsyncData([...msgs.sublist(0, msgs.length - 1), last]);
+ // ── Step 1: POST the message. If this fails the server never got it. ──
+ await repo.sendMessage(convId, content);
} catch (e) {
- // Remove the placeholder on error so the user can retry.
- state = AsyncData([
- for (final m in state.valueOrNull ?? [])
- if (m.status != 'generating') m,
- ]);
+ state = AsyncData(previousMessages);
+ ref.read(isStreamingProvider(convId).notifier).state = false;
rethrow;
+ }
+
+ // ── Step 2: Stream the response (best effort — silent on failure). ──
+ bool streamedContent = false;
+ try {
+ await for (final chunk in repo.streamGeneration(convId)) {
+ streamedContent = true;
+ final msgs = state.requireValue;
+ final updated = msgs.last.copyWith(content: msgs.last.content + chunk);
+ state = AsyncData([...msgs.sublist(0, msgs.length - 1), updated]);
+ }
+ } catch (_) {
+ // SSE failed — fall through to the polling reload below.
+ }
+
+ // ── Step 3: Poll the API until we have a completed assistant response.
+ //
+ // If SSE delivered content the server is done; one reload suffices.
+ // If SSE delivered nothing, the LLM may still be generating — we retry
+ // with short delays so the response appears as soon as it's ready.
+ // A completed message is one whose status is not 'generating'.
+ try {
+ final maxAttempts = streamedContent ? 1 : 20;
+ for (var attempt = 0; attempt < maxAttempts; attempt++) {
+ if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
+ final (conv, fresh) = await repo.getMessages(convId);
+ state = AsyncData(fresh);
+ ref.read(conversationsProvider.notifier).patchConversation(conv);
+ final done = fresh.any(
+ (m) => m.role == MessageRole.assistant && m.status != 'generating',
+ );
+ if (done) break;
+ }
+ } catch (_) {
+ // If every reload fails, at least clear the generating placeholder.
+ final msgs = state.valueOrNull;
+ if (msgs != null && msgs.isNotEmpty && msgs.last.status == 'generating') {
+ state = AsyncData([
+ ...msgs.sublist(0, msgs.length - 1),
+ msgs.last.copyWith(status: 'complete'),
+ ]);
+ }
} finally {
- _isStreaming = false;
+ ref.read(isStreamingProvider(convId).notifier).state = false;
}
}
}
-
-final isStreamingProvider = Provider.family((ref, convId) {
- final notifier = ref.watch(messagesProvider(convId).notifier);
- return notifier.isStreaming;
-});
diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart
index 6421d68..1c0cf54 100644
--- a/lib/providers/settings_provider.dart
+++ b/lib/providers/settings_provider.dart
@@ -1,7 +1,9 @@
+import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _kServerUrl = 'server_url';
+const _kThemeMode = 'theme_mode';
final sharedPreferencesProvider = Provider((ref) {
throw UnimplementedError('Override in ProviderScope');
@@ -11,6 +13,35 @@ final cookiesPathProvider = Provider((ref) {
throw UnimplementedError('Override in ProviderScope');
});
+final themeModeProvider =
+ StateNotifierProvider((ref) {
+ final prefs = ref.watch(sharedPreferencesProvider);
+ return ThemeModeNotifier(prefs);
+});
+
+class ThemeModeNotifier extends StateNotifier {
+ final SharedPreferences _prefs;
+
+ ThemeModeNotifier(this._prefs)
+ : super(_fromString(_prefs.getString(_kThemeMode)));
+
+ static ThemeMode _fromString(String? value) => switch (value) {
+ 'light' => ThemeMode.light,
+ 'dark' => ThemeMode.dark,
+ _ => ThemeMode.system,
+ };
+
+ Future setMode(ThemeMode mode) async {
+ final value = switch (mode) {
+ ThemeMode.light => 'light',
+ ThemeMode.dark => 'dark',
+ _ => 'system',
+ };
+ await _prefs.setString(_kThemeMode, value);
+ state = mode;
+ }
+}
+
final serverUrlProvider = StateNotifierProvider((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return ServerUrlNotifier(prefs);
diff --git a/lib/screens/chat/chat_screen.dart b/lib/screens/chat/chat_screen.dart
index 98d3bd8..a12ea46 100644
--- a/lib/screens/chat/chat_screen.dart
+++ b/lib/screens/chat/chat_screen.dart
@@ -50,6 +50,12 @@ class _ChatScreenState extends ConsumerState {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
}
+ } catch (_) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Failed to send message.')),
+ );
+ }
}
}
@@ -63,15 +69,26 @@ class _ChatScreenState extends ConsumerState {
_scrollToBottom();
});
+ final convTitle = ref
+ .watch(conversationsProvider)
+ .valueOrNull
+ ?.where((c) => c.id == widget.conversationId)
+ .firstOrNull
+ ?.title;
+
return Scaffold(
- appBar: AppBar(title: const Text('Chat')),
+ appBar: AppBar(
+ title: Text(convTitle?.isNotEmpty == true ? convTitle! : 'Chat'),
+ ),
body: Column(
children: [
Expanded(
child: messagesAsync.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
- error: (e, _) => Center(child: Text('Error: $e')),
+ error: (_, _) => const Center(
+ child: Text('Could not load messages.'),
+ ),
data: (messages) {
if (messages.isEmpty) {
return const Center(
diff --git a/lib/screens/chat/conversations_list_screen.dart b/lib/screens/chat/conversations_list_screen.dart
index 7321d09..28dfbf8 100644
--- a/lib/screens/chat/conversations_list_screen.dart
+++ b/lib/screens/chat/conversations_list_screen.dart
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
+import '../../core/exceptions.dart';
import '../../providers/chat_provider.dart';
class ConversationsListScreen extends ConsumerWidget {
@@ -13,14 +14,16 @@ class ConversationsListScreen extends ConsumerWidget {
final convsAsync = ref.watch(conversationsProvider);
return Scaffold(
- appBar: AppBar(title: const Text('Chat')),
body: convsAsync.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 conversations.'),
+ const SizedBox(height: 4),
TextButton(
onPressed: () => ref.invalidate(conversationsProvider),
child: const Text('Retry'),
@@ -42,10 +45,14 @@ class ConversationsListScreen extends ConsumerWidget {
final conv = convs[i];
return ListTile(
leading: const Icon(Icons.chat_bubble_outline),
- title: Text(conv.title),
+ title: Text(
+ conv.title.isNotEmpty ? conv.title : 'New conversation',
+ ),
subtitle: Text(
conv.updatedAt.toLocal().toString().substring(0, 16),
- style: Theme.of(context).textTheme.bodySmall,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
),
onTap: () => context.push(
Routes.chat.replaceFirst(':id', '${conv.id}'),
@@ -53,16 +60,21 @@ class ConversationsListScreen extends ConsumerWidget {
onLongPress: () async {
final confirm = await showDialog(
context: context,
- builder: (_) => AlertDialog(
+ builder: (dialogContext) => AlertDialog(
title: const Text('Delete conversation?'),
- content: Text(conv.title),
+ content: Text(
+ conv.title.isNotEmpty
+ ? conv.title
+ : 'New conversation',
+ ),
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'),
),
],
@@ -89,34 +101,16 @@ class ConversationsListScreen extends ConsumerWidget {
}
Future _newConversation(BuildContext context, WidgetRef ref) async {
- final controller = TextEditingController();
- final title = await showDialog(
- context: context,
- builder: (_) => AlertDialog(
- title: const Text('New Conversation'),
- content: TextField(
- controller: controller,
- decoration: const InputDecoration(hintText: 'Title'),
- autofocus: true,
- ),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(context),
- child: const Text('Cancel'),
- ),
- TextButton(
- onPressed: () => Navigator.pop(context, controller.text.trim()),
- child: const Text('Create'),
- ),
- ],
- ),
- );
- if (title != null && title.isNotEmpty) {
- final conv =
- await ref.read(conversationsProvider.notifier).create(title);
+ try {
+ final conv = await ref.read(conversationsProvider.notifier).create('');
if (context.mounted) {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
+ } on AppException catch (e) {
+ if (context.mounted) {
+ ScaffoldMessenger.of(context)
+ .showSnackBar(SnackBar(content: Text(e.message)));
+ }
}
}
}
diff --git a/lib/screens/notes/note_edit_screen.dart b/lib/screens/notes/note_edit_screen.dart
index 6381bd5..51f3fcd 100644
--- a/lib/screens/notes/note_edit_screen.dart
+++ b/lib/screens/notes/note_edit_screen.dart
@@ -41,6 +41,30 @@ class _NoteEditScreenState extends ConsumerState {
_loaded = true;
}
+ Future _delete() async {
+ final confirm = await showDialog(
+ context: context,
+ builder: (dialogContext) => AlertDialog(
+ title: const Text('Delete note?'),
+ content: Text(_titleController.text),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, true),
+ child: const Text('Delete'),
+ ),
+ ],
+ ),
+ );
+ if (confirm == true) {
+ await ref.read(notesProvider.notifier).delete(widget.noteId!);
+ if (mounted) context.pop();
+ }
+ }
+
Future _save() async {
final title = _titleController.text.trim();
final body = _contentController.text;
@@ -79,6 +103,12 @@ class _NoteEditScreenState extends ConsumerState {
appBar: AppBar(
title: Text(widget.noteId == null ? 'New Note' : 'Edit Note'),
actions: [
+ if (widget.noteId != null)
+ IconButton(
+ icon: const Icon(Icons.delete_outline),
+ tooltip: 'Delete',
+ onPressed: _delete,
+ ),
IconButton(
icon: Icon(_preview ? Icons.edit : Icons.preview),
tooltip: _preview ? 'Edit' : 'Preview',
diff --git a/lib/screens/notes/notes_list_screen.dart b/lib/screens/notes/notes_list_screen.dart
index c43d1c2..2835939 100644
--- a/lib/screens/notes/notes_list_screen.dart
+++ b/lib/screens/notes/notes_list_screen.dart
@@ -5,22 +5,63 @@ import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../providers/notes_provider.dart';
-class NotesListScreen extends ConsumerWidget {
+class NotesListScreen extends ConsumerStatefulWidget {
const NotesListScreen({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ ConsumerState createState() => _NotesListScreenState();
+}
+
+class _NotesListScreenState extends ConsumerState {
+ bool _showSearch = false;
+ String _search = '';
+
+ @override
+ Widget build(BuildContext context) {
final notesAsync = ref.watch(notesProvider);
return Scaffold(
- appBar: AppBar(title: const Text('Notes')),
+ appBar: AppBar(
+ automaticallyImplyLeading: false,
+ title: _showSearch
+ ? TextField(
+ autofocus: true,
+ decoration: const InputDecoration(
+ hintText: 'Search notes…',
+ 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),
+ ),
+ ],
+ ),
body: notesAsync.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 notes.'),
+ const SizedBox(height: 4),
TextButton(
onPressed: () => ref.invalidate(notesProvider),
child: const Text('Retry'),
@@ -29,21 +70,46 @@ class NotesListScreen extends ConsumerWidget {
),
),
data: (notes) {
- if (notes.isEmpty) {
- return const Center(child: Text('No notes yet. Tap + to create one.'));
+ final filtered = _search.isEmpty
+ ? notes
+ : notes
+ .where((n) =>
+ n.title.toLowerCase().contains(_search) ||
+ n.body.toLowerCase().contains(_search))
+ .toList();
+
+ if (filtered.isEmpty) {
+ return Center(
+ child: Text(
+ _search.isEmpty
+ ? 'No notes yet. Tap + to create one.'
+ : 'No notes match "$_search".',
+ ),
+ );
}
+
return RefreshIndicator(
onRefresh: () => ref.refresh(notesProvider.future),
child: ListView.separated(
- itemCount: notes.length,
+ itemCount: filtered.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) {
- final note = notes[i];
+ final note = filtered[i];
+ final preview = note.body
+ .split('\n')
+ .firstWhere((l) => l.trim().isNotEmpty, orElse: () => '')
+ .trim();
return ListTile(
title: Text(note.title),
subtitle: Text(
- note.updatedAt.toLocal().toString().substring(0, 16),
- style: Theme.of(context).textTheme.bodySmall,
+ preview.isNotEmpty
+ ? preview
+ : note.updatedAt.toLocal().toString().substring(0, 16),
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
),
onTap: () => context.push(
Routes.noteDetail.replaceFirst(':id', '${note.id}'),
diff --git a/lib/screens/quick_capture/quick_capture_screen.dart b/lib/screens/quick_capture/quick_capture_screen.dart
index ea245e9..15dbeee 100644
--- a/lib/screens/quick_capture/quick_capture_screen.dart
+++ b/lib/screens/quick_capture/quick_capture_screen.dart
@@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/exceptions.dart';
-import '../../data/models/task.dart';
+import '../../providers/api_client_provider.dart';
import '../../providers/notes_provider.dart';
import '../../providers/tasks_provider.dart';
@@ -15,182 +15,137 @@ class QuickCaptureScreen extends ConsumerStatefulWidget {
_QuickCaptureScreenState();
}
-class _QuickCaptureScreenState extends ConsumerState
- with SingleTickerProviderStateMixin {
- late final TabController _tabs;
- final _noteTitleController = TextEditingController();
- final _noteContentController = TextEditingController();
- final _taskTitleController = TextEditingController();
- TaskPriority _taskPriority = TaskPriority.medium;
- bool _saving = false;
-
- @override
- void initState() {
- super.initState();
- _tabs = TabController(length: 2, vsync: this);
- }
+class _QuickCaptureScreenState extends ConsumerState {
+ final _controller = TextEditingController();
+ final _focusNode = FocusNode();
+ bool _loading = false;
@override
void dispose() {
- _tabs.dispose();
- _noteTitleController.dispose();
- _noteContentController.dispose();
- _taskTitleController.dispose();
+ _controller.dispose();
+ _focusNode.dispose();
super.dispose();
}
- Future _saveNote() async {
- final title = _noteTitleController.text.trim();
- if (title.isEmpty) {
- ScaffoldMessenger.of(context)
- .showSnackBar(const SnackBar(content: Text('Title is required.')));
- return;
- }
- setState(() => _saving = true);
+ Future _send() async {
+ final text = _controller.text.trim();
+ if (text.isEmpty) return;
+ setState(() => _loading = true);
try {
- await ref
- .read(notesProvider.notifier)
- .create(title, _noteContentController.text);
- if (mounted) context.pop();
+ final result =
+ await ref.read(quickCaptureApiProvider).capture(text);
+ // Invalidate the relevant provider so the list screen re-fetches.
+ switch (result.type) {
+ case 'note':
+ ref.invalidate(notesProvider);
+ case 'task':
+ case 'todo':
+ ref.invalidate(tasksProvider);
+ }
+
+ if (mounted) {
+ // Use the server's human-readable message if available, else compose one.
+ final msg = result.message.isNotEmpty
+ ? result.message
+ : '${_typeLabel(result.type)} created: ${result.title}';
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(msg),
+ behavior: SnackBarBehavior.floating,
+ ),
+ );
+ context.pop();
+ }
} on AppException catch (e) {
if (mounted) {
- ScaffoldMessenger.of(context)
- .showSnackBar(SnackBar(content: Text(e.message)));
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(e.message),
+ behavior: SnackBarBehavior.floating,
+ ),
+ );
}
} finally {
- if (mounted) setState(() => _saving = false);
+ if (mounted) setState(() => _loading = false);
}
}
- Future _saveTask() async {
- final title = _taskTitleController.text.trim();
- if (title.isEmpty) {
- ScaffoldMessenger.of(context)
- .showSnackBar(const SnackBar(content: Text('Title is required.')));
- return;
- }
- setState(() => _saving = true);
- try {
- await ref.read(tasksProvider.notifier).create(
- title: title,
- status: TaskStatus.todo,
- priority: _taskPriority,
- );
- if (mounted) context.pop();
- } on AppException catch (e) {
- if (mounted) {
- ScaffoldMessenger.of(context)
- .showSnackBar(SnackBar(content: Text(e.message)));
- }
- } finally {
- if (mounted) setState(() => _saving = false);
- }
- }
+ String _typeLabel(String type) => switch (type) {
+ 'note' => 'Note',
+ 'task' => 'Task',
+ 'event' => 'Event',
+ 'todo' => 'To-do',
+ _ => type,
+ };
@override
Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+
return Scaffold(
- appBar: AppBar(
- title: const Text('Quick Capture'),
- bottom: TabBar(
- controller: _tabs,
- tabs: const [Tab(text: 'Note'), Tab(text: 'Task')],
+ appBar: AppBar(title: const Text('Quick Capture')),
+ body: Padding(
+ padding: const EdgeInsets.all(20),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ 'What\'s on your mind?',
+ style: theme.textTheme.titleMedium,
+ ),
+ const SizedBox(height: 4),
+ Text(
+ 'Describe a note, task, event, or research item — Fabled will figure out the rest.',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: _controller,
+ focusNode: _focusNode,
+ autofocus: true,
+ enabled: !_loading,
+ maxLines: 6,
+ minLines: 3,
+ keyboardType: TextInputType.multiline,
+ decoration: InputDecoration(
+ hintText:
+ 'e.g. "Remind me to call the dentist next Monday" or "Note: project meeting went well, key points were..."',
+ border: const OutlineInputBorder(),
+ alignLabelWithHint: true,
+ suffixIcon: _controller.text.isNotEmpty
+ ? IconButton(
+ icon: const Icon(Icons.clear),
+ onPressed: () {
+ _controller.clear();
+ setState(() {});
+ },
+ )
+ : null,
+ ),
+ onChanged: (_) => setState(() {}),
+ ),
+ const SizedBox(height: 16),
+ FilledButton.icon(
+ onPressed: (_loading || _controller.text.trim().isEmpty)
+ ? null
+ : _send,
+ icon: _loading
+ ? const SizedBox(
+ width: 18,
+ height: 18,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: Colors.white,
+ ),
+ )
+ : const Icon(Icons.auto_awesome),
+ label: Text(_loading ? 'Processing...' : 'Capture'),
+ ),
+ ],
),
),
- body: TabBarView(
- controller: _tabs,
- children: [
- // Note tab
- Padding(
- padding: const EdgeInsets.all(16),
- child: Column(
- children: [
- TextField(
- controller: _noteTitleController,
- decoration: const InputDecoration(
- labelText: 'Title',
- border: OutlineInputBorder(),
- ),
- textInputAction: TextInputAction.next,
- autofocus: true,
- ),
- const SizedBox(height: 12),
- Expanded(
- child: TextField(
- controller: _noteContentController,
- decoration: const InputDecoration(
- hintText: 'Content (optional, supports markdown)',
- border: OutlineInputBorder(),
- alignLabelWithHint: true,
- ),
- maxLines: null,
- expands: true,
- textAlignVertical: TextAlignVertical.top,
- keyboardType: TextInputType.multiline,
- ),
- ),
- const SizedBox(height: 12),
- SizedBox(
- width: double.infinity,
- child: FilledButton(
- onPressed: _saving ? null : _saveNote,
- child: _saving
- ? const SizedBox(
- height: 20,
- width: 20,
- child: CircularProgressIndicator(strokeWidth: 2),
- )
- : const Text('Save Note'),
- ),
- ),
- ],
- ),
- ),
- // Task tab
- Padding(
- padding: const EdgeInsets.all(16),
- child: Column(
- children: [
- TextField(
- controller: _taskTitleController,
- decoration: const InputDecoration(
- labelText: 'Task title',
- border: OutlineInputBorder(),
- ),
- autofocus: true,
- ),
- const SizedBox(height: 12),
- DropdownButtonFormField(
- initialValue: _taskPriority,
- decoration: const InputDecoration(
- labelText: 'Priority',
- border: OutlineInputBorder(),
- ),
- items: TaskPriority.values
- .map((p) =>
- DropdownMenuItem(value: p, child: Text(p.label)))
- .toList(),
- onChanged: (v) => setState(() => _taskPriority = v!),
- ),
- const SizedBox(height: 16),
- SizedBox(
- width: double.infinity,
- child: FilledButton(
- onPressed: _saving ? null : _saveTask,
- child: _saving
- ? const SizedBox(
- height: 20,
- width: 20,
- child: CircularProgressIndicator(strokeWidth: 2),
- )
- : const Text('Save Task'),
- ),
- ),
- ],
- ),
- ),
- ],
- ),
);
}
}
diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart
index f48406f..3ea70dc 100644
--- a/lib/screens/settings/settings_screen.dart
+++ b/lib/screens/settings/settings_screen.dart
@@ -12,6 +12,7 @@ class SettingsScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final serverUrl = ref.watch(serverUrlProvider);
+ final themeMode = ref.watch(themeModeProvider);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
@@ -24,6 +25,33 @@ class SettingsScreen extends ConsumerWidget {
onTap: () => context.go(Routes.setup),
),
const Divider(),
+ ListTile(
+ title: const Text('Appearance'),
+ leading: const Icon(Icons.brightness_6),
+ trailing: SegmentedButton(
+ segments: const [
+ ButtonSegment(
+ value: ThemeMode.system,
+ icon: Icon(Icons.brightness_auto),
+ tooltip: 'System',
+ ),
+ ButtonSegment(
+ value: ThemeMode.light,
+ icon: Icon(Icons.light_mode),
+ tooltip: 'Light',
+ ),
+ ButtonSegment(
+ value: ThemeMode.dark,
+ icon: Icon(Icons.dark_mode),
+ tooltip: 'Dark',
+ ),
+ ],
+ selected: {themeMode},
+ onSelectionChanged: (modes) =>
+ ref.read(themeModeProvider.notifier).setMode(modes.first),
+ ),
+ ),
+ const Divider(),
ListTile(
title: const Text('Sign Out'),
leading: const Icon(Icons.logout),
diff --git a/lib/screens/tasks/task_edit_screen.dart b/lib/screens/tasks/task_edit_screen.dart
index ddaae41..1a87f79 100644
--- a/lib/screens/tasks/task_edit_screen.dart
+++ b/lib/screens/tasks/task_edit_screen.dart
@@ -85,14 +85,15 @@ class _TaskEditScreenState extends ConsumerState {
Future _delete() async {
final confirm = await showDialog(
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')),
],
),
diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart
index 176536e..0d5594f 100644
--- a/lib/screens/tasks/tasks_list_screen.dart
+++ b/lib/screens/tasks/tasks_list_screen.dart
@@ -16,6 +16,8 @@ class TasksListScreen extends ConsumerStatefulWidget {
class _TasksListScreenState extends ConsumerState
with SingleTickerProviderStateMixin {
late final TabController _tabs;
+ bool _showSearch = false;
+ String _search = '';
@override
void initState() {
@@ -35,7 +37,35 @@ class _TasksListScreenState extends ConsumerState
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
),
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
),
),
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
class _TaskList extends ConsumerWidget {
final List 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}'),
diff --git a/pubspec.lock b/pubspec.lock
index 3095666..a2db2d8 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
+ archive:
+ dependency: transitive
+ description:
+ name: archive
+ sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.0.9"
args:
dependency: transitive
description:
@@ -33,6 +41,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
+ checked_yaml:
+ dependency: transitive
+ description:
+ name: checked_yaml
+ sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.4"
+ cli_util:
+ dependency: transitive
+ description:
+ name: cli_util
+ sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.4.2"
clock:
dependency: transitive
description:
@@ -198,6 +222,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.0"
+ flutter_launcher_icons:
+ dependency: "direct dev"
+ description:
+ name: flutter_launcher_icons
+ sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
@@ -264,6 +296,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
+ image:
+ dependency: transitive
+ description:
+ name: image
+ sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.8.0"
+ json_annotation:
+ dependency: transitive
+ description:
+ name: json_annotation
+ sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.11.0"
leak_tracker:
dependency: transitive
description:
@@ -416,6 +464,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
+ petitparser:
+ dependency: transitive
+ description:
+ name: petitparser
+ sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.2"
platform:
dependency: transitive
description:
@@ -432,6 +488,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
+ posix:
+ dependency: transitive
+ description:
+ name: posix
+ sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.5.0"
pub_semver:
dependency: transitive
description:
@@ -613,6 +677,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
+ xml:
+ dependency: transitive
+ description:
+ name: xml
+ sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.6.1"
yaml:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index c7224a8..a9c4a40 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -26,6 +26,18 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
+ flutter_launcher_icons: ^0.14.3
+
+flutter_launcher_icons:
+ android: true
+ ios: false
+ remove_alpha_ios: false
+ image_path: "assets/icon/app_icon.png"
+ adaptive_icon_background: "#6366f1"
+ adaptive_icon_foreground: "assets/icon/app_icon_fg.png"
+ min_sdk_android: 21
flutter:
uses-material-design: true
+ assets:
+ - assets/icon/