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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 23:15:06 -05:00
parent 1fb792177e
commit 2a35fe5532
33 changed files with 854 additions and 282 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#6366f1</color>
</resources>
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+191 -11
View File
@@ -3,15 +3,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'core/constants.dart';
import 'core/exceptions.dart';
import 'providers/api_client_provider.dart';
import 'providers/auth_provider.dart';
import 'providers/capture_queue_provider.dart';
import 'providers/notes_provider.dart';
import 'providers/settings_provider.dart';
import 'providers/tasks_provider.dart';
import 'screens/auth/login_screen.dart';
import 'screens/chat/chat_screen.dart';
import 'screens/chat/conversations_list_screen.dart';
import 'screens/notes/note_detail_screen.dart';
import 'screens/notes/note_edit_screen.dart';
import 'screens/notes/notes_list_screen.dart';
import 'screens/quick_capture/quick_capture_screen.dart';
import 'screens/settings/settings_screen.dart';
import 'screens/setup/setup_screen.dart';
import 'screens/splash/splash_screen.dart';
@@ -66,10 +70,6 @@ final routerProvider = Provider<GoRouter>((ref) {
path: Routes.login,
builder: (_, _) => const LoginScreen(),
),
GoRoute(
path: Routes.quickCapture,
builder: (_, _) => const QuickCaptureScreen(),
),
GoRoute(
path: Routes.settings,
builder: (_, _) => const SettingsScreen(),
@@ -146,7 +146,12 @@ class _Shell extends ConsumerWidget {
final index = _tabIndex(location);
return Scaffold(
body: child,
body: Column(
children: [
const _QuickCaptureBar(),
Expanded(child: child),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: index,
onDestinationSelected: (i) => context.go(_tabs[i]),
@@ -157,12 +162,185 @@ class _Shell extends ConsumerWidget {
icon: Icon(Icons.chat_bubble), label: 'Chat'),
],
),
floatingActionButton: FloatingActionButton(
heroTag: 'shell_fab',
onPressed: () => context.push(Routes.quickCapture),
child: const Icon(Icons.add),
);
}
}
class _QuickCaptureBar extends ConsumerStatefulWidget {
const _QuickCaptureBar();
@override
ConsumerState<_QuickCaptureBar> createState() => _QuickCaptureBarState();
}
class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
final _controller = TextEditingController();
bool _busy = false;
@override
void initState() {
super.initState();
// Retry any offline-queued captures from previous sessions.
WidgetsBinding.instance.addPostFrameCallback((_) => _drainQueue());
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _submit() async {
final text = _controller.text.trim();
if (text.isEmpty || _busy) return;
_controller.clear();
setState(() => _busy = true);
// Capture the messenger before the async gap so we can show a snackbar
// even if the user has navigated to a deeper view by the time it resolves.
final messenger = ScaffoldMessenger.of(context);
try {
final result = await ref.read(quickCaptureApiProvider).capture(text);
switch (result.type) {
case 'note':
ref.invalidate(notesProvider);
case 'task':
case 'todo':
ref.invalidate(tasksProvider);
}
final msg = result.message.isNotEmpty
? result.message
: '${_typeLabel(result.type)} created: ${result.title}';
messenger.showSnackBar(
SnackBar(content: Text(msg), behavior: SnackBarBehavior.floating),
);
_drainQueue(); // Silently flush any offline-queued captures.
} on NetworkException {
await ref.read(captureQueueProvider.notifier).enqueue(text);
messenger.showSnackBar(
const SnackBar(
content: Text(
"You're offline — capture saved and will retry automatically."),
behavior: SnackBarBehavior.floating,
),
);
} on AppException catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(e.message), behavior: SnackBarBehavior.floating),
);
} catch (_) {
messenger.showSnackBar(
const SnackBar(
content: Text('Capture failed. Please try again.'),
behavior: SnackBarBehavior.floating,
),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _drainQueue() async {
final queue = ref.read(captureQueueProvider);
if (queue.isEmpty) return;
final api = ref.read(quickCaptureApiProvider);
for (final text in List<String>.from(queue)) {
try {
final result = await api.capture(text);
await ref.read(captureQueueProvider.notifier).dequeue(text);
switch (result.type) {
case 'note':
ref.invalidate(notesProvider);
case 'task':
case 'todo':
ref.invalidate(tasksProvider);
}
} on NetworkException {
break; // Still offline — stop draining.
} catch (_) {
// Server/parse error — remove to avoid infinite retries.
await ref.read(captureQueueProvider.notifier).dequeue(text);
}
}
}
String _typeLabel(String type) => switch (type) {
'note' => 'Note',
'task' => 'Task',
'event' => 'Event',
'todo' => 'To-do',
_ => type,
};
String _hintForLocation(String location) {
if (location.startsWith(Routes.tasks)) return 'Add a task…';
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
return 'Capture a note…';
}
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
final queueCount = ref.watch(captureQueueProvider).length;
return SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
enabled: !_busy,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _submit(),
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: _hintForLocation(location),
isDense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
prefixIcon: _busy
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: queueCount > 0
? Badge(
label: Text('$queueCount'),
child:
const Icon(Icons.cloud_upload_outlined),
)
: const Icon(Icons.auto_awesome_outlined),
suffixIcon: _controller.text.trim().isNotEmpty && !_busy
? IconButton(
icon: const Icon(Icons.send),
onPressed: _submit,
tooltip: 'Capture',
)
: null,
),
),
),
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings',
onPressed: () => context.push(Routes.settings),
),
],
),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
);
}
}
@@ -173,9 +351,11 @@ class FabledApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
final themeMode = ref.watch(themeModeProvider);
return MaterialApp.router(
title: 'Fabled',
themeMode: themeMode,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
+22 -12
View File
@@ -42,17 +42,18 @@ class ChatApi {
}
}
// Messages are embedded in the conversation detail response.
Future<List<Message>> getMessages(int conversationId) async {
// Returns the conversation metadata AND its messages in a single request.
Future<(Conversation, List<Message>)> getMessages(int conversationId) async {
try {
final response =
await _dio.get('/api/chat/conversations/$conversationId');
final conv = response.data as Map<String, dynamic>;
final conversation = Conversation.fromJson(conv);
final list = conv['messages'] as List<dynamic>? ?? [];
return list
final messages = list
.map((e) => Message.fromJson(e as Map<String, dynamic>))
.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<String, dynamic>;
final text = data['text'] as String? ?? '';
final obj = json.decode(data) as Map<String, dynamic>;
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
+49
View File
@@ -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<String, dynamic> json) {
final data = json['data'] as Map<String, dynamic>? ?? {};
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<CaptureResult> capture(String text) async {
try {
final response = await _dio.post(
'/api/quick-capture',
data: {'text': text},
);
return CaptureResult.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ class Conversation {
factory Conversation.fromJson(Map<String, dynamic> 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),
);
+1 -1
View File
@@ -10,7 +10,7 @@ class ChatRepository {
Future<Conversation> createConversation(String title) =>
_api.createConversation(title);
Future<void> deleteConversation(int id) => _api.deleteConversation(id);
Future<List<Message>> getMessages(int conversationId) =>
Future<(Conversation, List<Message>)> getMessages(int conversationId) =>
_api.getMessages(conversationId);
Future<void> sendMessage(int conversationId, String content) =>
_api.sendMessage(conversationId, content);
+5
View File
@@ -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<ChatApi>((ref) {
return ChatApi(ref.watch(dioProvider));
});
final quickCaptureApiProvider = Provider<QuickCaptureApi>((ref) {
return QuickCaptureApi(ref.watch(dioProvider));
});
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepository(ref.watch(authApiProvider));
});
+29
View File
@@ -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<CaptureQueueNotifier, List<String>>(
(ref) => CaptureQueueNotifier(ref.watch(sharedPreferencesProvider)),
);
class CaptureQueueNotifier extends StateNotifier<List<String>> {
static const _key = 'capture_queue';
final SharedPreferences _prefs;
CaptureQueueNotifier(this._prefs)
: super(_prefs.getStringList(_key) ?? []);
Future<void> enqueue(String text) async {
final updated = [...state, text];
await _prefs.setStringList(_key, updated);
state = updated;
}
Future<void> dequeue(String text) async {
final updated = List<String>.from(state)..remove(text);
await _prefs.setStringList(_key, updated);
state = updated;
}
}
+68 -35
View File
@@ -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<bool, int>((ref, _) => false);
final conversationsProvider =
AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>(
ConversationsNotifier.new);
@@ -28,71 +31,101 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
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<MessagesNotifier,
List<Message>, int>(MessagesNotifier.new);
class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
bool _isStreaming = false;
bool get isStreaming => _isStreaming;
@override
Future<List<Message>> build(int arg) async {
return ref.watch(chatRepositoryProvider).getMessages(arg);
final (_, messages) =
await ref.watch(chatRepositoryProvider).getMessages(arg);
return messages;
}
Future<void> 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]);
// ── Step 1: POST the message. If this fails the server never got it. ──
await repo.sendMessage(convId, content);
} catch (e) {
state = AsyncData(previousMessages);
ref.read(isStreamingProvider(convId).notifier).state = false;
rethrow;
}
// Mark the assistant message as complete.
// ── 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 last = msgs.last.copyWith(status: 'complete');
state = AsyncData([...msgs.sublist(0, msgs.length - 1), last]);
} catch (e) {
// Remove the placeholder on error so the user can retry.
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([
for (final m in state.valueOrNull ?? [])
if (m.status != 'generating') m,
...msgs.sublist(0, msgs.length - 1),
msgs.last.copyWith(status: 'complete'),
]);
rethrow;
}
} finally {
_isStreaming = false;
ref.read(isStreamingProvider(convId).notifier).state = false;
}
}
}
final isStreamingProvider = Provider.family<bool, int>((ref, convId) {
final notifier = ref.watch(messagesProvider(convId).notifier);
return notifier.isStreaming;
});
+31
View File
@@ -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<SharedPreferences>((ref) {
throw UnimplementedError('Override in ProviderScope');
@@ -11,6 +13,35 @@ final cookiesPathProvider = Provider<String>((ref) {
throw UnimplementedError('Override in ProviderScope');
});
final themeModeProvider =
StateNotifierProvider<ThemeModeNotifier, ThemeMode>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return ThemeModeNotifier(prefs);
});
class ThemeModeNotifier extends StateNotifier<ThemeMode> {
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<void> 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<ServerUrlNotifier, String?>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return ServerUrlNotifier(prefs);
+19 -2
View File
@@ -50,6 +50,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
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<ChatScreen> {
_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(
+28 -34
View File
@@ -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<bool>(
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<void> _newConversation(BuildContext context, WidgetRef ref) async {
final controller = TextEditingController();
final title = await showDialog<String>(
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)));
}
}
}
}
+30
View File
@@ -41,6 +41,30 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
_loaded = true;
}
Future<void> _delete() async {
final confirm = await showDialog<bool>(
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<void> _save() async {
final title = _titleController.text.trim();
final body = _contentController.text;
@@ -79,6 +103,12 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
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',
+77 -11
View File
@@ -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<NotesListScreen> createState() => _NotesListScreenState();
}
class _NotesListScreenState extends ConsumerState<NotesListScreen> {
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}'),
@@ -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<QuickCaptureScreen>
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<QuickCaptureScreen> {
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<void> _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<void> _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<void> _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')],
),
),
body: TabBarView(
controller: _tabs,
children: [
// Note tab
Padding(
padding: const EdgeInsets.all(16),
appBar: AppBar(title: const Text('Quick Capture')),
body: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _noteTitleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
Text(
'What\'s on your mind?',
style: theme.textTheme.titleMedium,
),
textInputAction: TextInputAction.next,
autofocus: true,
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: 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<TaskPriority>(
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),
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(() {});
},
)
: const Text('Save Task'),
: 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'),
),
],
),
),
],
),
);
}
}
+28
View File
@@ -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<ThemeMode>(
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),
+4 -3
View File
@@ -85,14 +85,15 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
Future<void> _delete() async {
final confirm = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
builder: (dialogContext) => AlertDialog(
title: const Text('Delete task?'),
content: Text(_titleController.text),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(context, true),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete')),
],
),
+59 -12
View File
@@ -16,6 +16,8 @@ class TasksListScreen extends ConsumerStatefulWidget {
class _TasksListScreenState extends ConsumerState<TasksListScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabs;
bool _showSearch = false;
String _search = '';
@override
void initState() {
@@ -35,7 +37,35 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
return Scaffold(
appBar: AppBar(
title: const Text('Tasks'),
automaticallyImplyLeading: false,
title: _showSearch
? TextField(
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search tasks…',
border: InputBorder.none,
),
onChanged: (v) =>
setState(() => _search = v.trim().toLowerCase()),
)
: null,
actions: [
if (_showSearch)
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Close search',
onPressed: () => setState(() {
_showSearch = false;
_search = '';
}),
)
else
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search',
onPressed: () => setState(() => _showSearch = true),
),
],
bottom: TabBar(
controller: _tabs,
tabs: const [
@@ -47,11 +77,14 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
),
body: tasksAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
error: (_, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Error: $e'),
const Icon(Icons.cloud_off, size: 48),
const SizedBox(height: 12),
const Text('Could not load tasks.'),
const SizedBox(height: 4),
TextButton(
onPressed: () => ref.invalidate(tasksProvider),
child: const Text('Retry'),
@@ -60,21 +93,29 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
),
),
data: (tasks) {
final filtered = _search.isEmpty
? tasks
: tasks
.where((t) =>
t.title.toLowerCase().contains(_search) ||
(t.description ?? '').toLowerCase().contains(_search))
.toList();
final todo =
tasks.where((t) => t.status == TaskStatus.todo).toList();
filtered.where((t) => t.status == TaskStatus.todo).toList();
final inProgress =
tasks.where((t) => t.status == TaskStatus.inProgress).toList();
filtered.where((t) => t.status == TaskStatus.inProgress).toList();
final done =
tasks.where((t) => t.status == TaskStatus.done).toList();
filtered.where((t) => t.status == TaskStatus.done).toList();
return RefreshIndicator(
onRefresh: () => ref.refresh(tasksProvider.future),
child: TabBarView(
controller: _tabs,
children: [
_TaskList(tasks: todo),
_TaskList(tasks: inProgress),
_TaskList(tasks: done),
_TaskList(tasks: todo, search: _search),
_TaskList(tasks: inProgress, search: _search),
_TaskList(tasks: done, search: _search),
],
),
);
@@ -91,12 +132,17 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
class _TaskList extends ConsumerWidget {
final List<Task> tasks;
const _TaskList({required this.tasks});
final String search;
const _TaskList({required this.tasks, this.search = ''});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (tasks.isEmpty) {
return const Center(child: Text('No tasks here.'));
return Center(
child: Text(
search.isEmpty ? 'No tasks here.' : 'No tasks match "$search".',
),
);
}
return ListView.separated(
itemCount: tasks.length,
@@ -107,7 +153,8 @@ class _TaskList extends ConsumerWidget {
leading: _priorityIcon(task.priority),
title: Text(task.title),
subtitle: task.dueDate != null
? Text('Due: ${task.dueDate!.toLocal().toString().substring(0, 10)}')
? Text(
'Due: ${task.dueDate!.toLocal().toString().substring(0, 10)}')
: null,
onTap: () => context.push(
Routes.taskEdit.replaceFirst(':id', '${task.id}'),
+72
View File
@@ -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:
+12
View File
@@ -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/