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/