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>
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 11 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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}'),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/
|
||||
|
||||