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
+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);
}
}
}