Compare commits
8 Commits
v26.04.28.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c3a650e3f5 | |||
| a77b71e0e2 | |||
| 116dc42923 | |||
| 76aff4ea9e | |||
| 7df7b5ff85 | |||
| fe10067761 | |||
| 6ef658558a | |||
| b9e68e3bc8 |
+54
-24
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -6,6 +7,7 @@ import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
|
||||
import 'core/constants.dart';
|
||||
import 'core/theme.dart';
|
||||
import 'data/repositories/write_queue.dart';
|
||||
import 'providers/api_client_provider.dart';
|
||||
import 'providers/auth_provider.dart';
|
||||
import 'core/exceptions.dart';
|
||||
@@ -296,7 +298,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
leading: const Icon(LucideIcons.folder),
|
||||
title: const Text('Projects'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
@@ -304,7 +306,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.calendar_month_outlined),
|
||||
leading: const Icon(LucideIcons.calendar),
|
||||
title: const Text('Calendar'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
@@ -330,6 +332,17 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
void _showQueueFailureSnackbar(QueueFailure failure) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(failure.message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 6),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Show update dialog once when a new version is detected.
|
||||
@@ -340,6 +353,23 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
.addPostFrameCallback((_) => _showUpdateSnackbar(next));
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 3 — drain the offline write queue when we transition into
|
||||
// an online state. Fires for unknown→authenticated (cold start),
|
||||
// offline→authenticated (came back), and unauthenticated→authenticated
|
||||
// (login). Idempotent if the queue is empty.
|
||||
ref.listen(authProvider, (prev, next) {
|
||||
if (next == AuthStatus.authenticated &&
|
||||
prev != AuthStatus.authenticated) {
|
||||
ref.read(writeQueueProvider).replay();
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 3 — surface queue failures (overwrites, missing targets, 4xx
|
||||
// rejections) so the user knows their offline edit didn't land.
|
||||
ref.listen(writeQueueFailuresProvider, (_, next) {
|
||||
next.whenData(_showQueueFailureSnackbar);
|
||||
});
|
||||
final tabs = _tabs();
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final index = _tabIndex(location, tabs);
|
||||
@@ -370,28 +400,28 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: const [
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.menu_book_outlined),
|
||||
selectedIcon: Icon(Icons.menu_book),
|
||||
icon: Icon(LucideIcons.bookOpen),
|
||||
selectedIcon: Icon(LucideIcons.bookOpen),
|
||||
label: Text('Journal'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.lightbulb_outline),
|
||||
selectedIcon: Icon(Icons.lightbulb),
|
||||
icon: Icon(LucideIcons.lightbulb),
|
||||
selectedIcon: Icon(LucideIcons.lightbulb),
|
||||
label: Text('Knowledge'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
icon: Icon(LucideIcons.messageCircle),
|
||||
selectedIcon: Icon(LucideIcons.messageCircle),
|
||||
label: Text('Chat'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.folder_outlined),
|
||||
selectedIcon: Icon(Icons.folder),
|
||||
icon: Icon(LucideIcons.folder),
|
||||
selectedIcon: Icon(LucideIcons.folder),
|
||||
label: Text('Projects'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.calendar_month_outlined),
|
||||
selectedIcon: Icon(Icons.calendar_month),
|
||||
icon: Icon(LucideIcons.calendar),
|
||||
selectedIcon: Icon(LucideIcons.calendar),
|
||||
label: Text('Calendar'),
|
||||
),
|
||||
],
|
||||
@@ -432,23 +462,23 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.menu_book_outlined),
|
||||
selectedIcon: Icon(Icons.menu_book),
|
||||
icon: Icon(LucideIcons.bookOpen),
|
||||
selectedIcon: Icon(LucideIcons.bookOpen),
|
||||
label: 'Journal',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.lightbulb_outline),
|
||||
selectedIcon: Icon(Icons.lightbulb),
|
||||
icon: Icon(LucideIcons.lightbulb),
|
||||
selectedIcon: Icon(LucideIcons.lightbulb),
|
||||
label: 'Knowledge',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
icon: Icon(LucideIcons.messageCircle),
|
||||
selectedIcon: Icon(LucideIcons.messageCircle),
|
||||
label: 'Chat',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.more_horiz_outlined),
|
||||
selectedIcon: Icon(Icons.more_horiz),
|
||||
icon: Icon(LucideIcons.moreHorizontal),
|
||||
selectedIcon: Icon(LucideIcons.moreHorizontal),
|
||||
label: 'More',
|
||||
),
|
||||
],
|
||||
@@ -579,7 +609,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
prefixIcon: totalPending > 0
|
||||
? Badge(
|
||||
label: Text('$totalPending'),
|
||||
child: const Icon(Icons.cloud_upload_outlined),
|
||||
child: const Icon(LucideIcons.uploadCloud),
|
||||
)
|
||||
: isWorking
|
||||
? const Padding(
|
||||
@@ -591,10 +621,10 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome_outlined),
|
||||
: const Icon(LucideIcons.sparkles),
|
||||
suffixIcon: _controller.text.trim().isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
icon: const Icon(LucideIcons.send),
|
||||
onPressed: _submit,
|
||||
tooltip: 'Capture',
|
||||
)
|
||||
@@ -609,7 +639,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
onTap: _toggleCaptureMic,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
icon: const Icon(LucideIcons.settings),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => context.push(Routes.settings),
|
||||
),
|
||||
|
||||
@@ -17,3 +17,12 @@ class AuthException extends AppException {
|
||||
class NotFoundException extends AppException {
|
||||
const NotFoundException(super.message);
|
||||
}
|
||||
|
||||
/// 4xx/5xx response received from the server (excluding 401/404 which have
|
||||
/// dedicated subclasses). Distinguished from `NetworkException` (connection
|
||||
/// failure) so the offline write queue can drop non-retryable failures
|
||||
/// instead of looping forever on a 422.
|
||||
class ServerException extends AppException {
|
||||
final int statusCode;
|
||||
const ServerException(super.message, this.statusCode);
|
||||
}
|
||||
|
||||
+6
-6
@@ -199,15 +199,15 @@ ThemeData fabledDarkTheme() {
|
||||
filled: true,
|
||||
fillColor: _darkSurfaceVar,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: _darkOutline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: _darkOutline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: _darkPrimary, width: 2),
|
||||
),
|
||||
),
|
||||
@@ -279,15 +279,15 @@ ThemeData fabledLightTheme() {
|
||||
filled: true,
|
||||
fillColor: _lightSurfaceVar,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: _lightOutline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: _lightOutline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: _lightPrimary, width: 2),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -63,5 +63,21 @@ AppException dioToApp(DioException e) {
|
||||
final status = e.response?.statusCode;
|
||||
if (status == 401) return const AuthException('Not authenticated.');
|
||||
if (status == 404) return const NotFoundException('Resource not found.');
|
||||
if (status != null && status >= 400) {
|
||||
final msg = _extractServerErrorMessage(e.response) ??
|
||||
'Request failed (HTTP $status).';
|
||||
return ServerException(msg, status);
|
||||
}
|
||||
return NetworkException(e.message ?? 'Unknown network error.');
|
||||
}
|
||||
|
||||
String? _extractServerErrorMessage(Response? resp) {
|
||||
final data = resp?.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
for (final key in const ['detail', 'error', 'message']) {
|
||||
final value = data[key];
|
||||
if (value is String && value.isNotEmpty) return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,760 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';
|
||||
|
||||
import '../models/calendar_event.dart';
|
||||
import '../models/conversation.dart';
|
||||
import '../models/milestone.dart';
|
||||
import '../models/note.dart';
|
||||
import '../models/project.dart';
|
||||
import '../models/task.dart';
|
||||
|
||||
part 'database.g.dart';
|
||||
|
||||
// ── Tables ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Each cached_* table mirrors the corresponding model 1:1. Tags / list fields
|
||||
// are stored as JSON-encoded text; SQLite lacks a native list type and
|
||||
// Drift's typed converters add ceremony we don't need for read-side caching.
|
||||
// `cachedAt` tracks when the row was last written from a successful API
|
||||
// response; the SyncMetadata table tracks the per-domain "last bulk fetch"
|
||||
// timestamp used by the OfflineBanner.
|
||||
|
||||
class CachedNotes extends Table {
|
||||
IntColumn get id => integer()();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get body => text()();
|
||||
TextColumn get tagsJson => text().withDefault(const Constant('[]'))();
|
||||
TextColumn get noteType => text().withDefault(const Constant('note'))();
|
||||
IntColumn get projectId => integer().nullable()();
|
||||
IntColumn get milestoneId => integer().nullable()();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
DateTimeColumn get cachedAt =>
|
||||
dateTime().clientDefault(() => DateTime.now())();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class CachedTasks extends Table {
|
||||
IntColumn get id => integer()();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get description => text().nullable()();
|
||||
TextColumn get status => text()();
|
||||
TextColumn get priority => text()();
|
||||
DateTimeColumn get dueDate => dateTime().nullable()();
|
||||
IntColumn get projectId => integer().nullable()();
|
||||
IntColumn get milestoneId => integer().nullable()();
|
||||
IntColumn get parentId => integer().nullable()();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
DateTimeColumn get cachedAt =>
|
||||
dateTime().clientDefault(() => DateTime.now())();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class CachedProjects extends Table {
|
||||
IntColumn get id => integer()();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get description => text().nullable()();
|
||||
TextColumn get goal => text().nullable()();
|
||||
TextColumn get status => text()();
|
||||
TextColumn get color => text().nullable()();
|
||||
TextColumn get autoSummary => text().nullable()();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
DateTimeColumn get cachedAt =>
|
||||
dateTime().clientDefault(() => DateTime.now())();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class CachedMilestones extends Table {
|
||||
IntColumn get id => integer()();
|
||||
IntColumn get projectId => integer()();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get description => text().nullable()();
|
||||
TextColumn get status => text()();
|
||||
IntColumn get orderIndex => integer().withDefault(const Constant(0))();
|
||||
IntColumn get total => integer().withDefault(const Constant(0))();
|
||||
IntColumn get completed => integer().withDefault(const Constant(0))();
|
||||
RealColumn get pct => real().withDefault(const Constant(0.0))();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
DateTimeColumn get cachedAt =>
|
||||
dateTime().clientDefault(() => DateTime.now())();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class CachedCalendarEvents extends Table {
|
||||
IntColumn get id => integer()();
|
||||
TextColumn get title => text()();
|
||||
DateTimeColumn get startDt => dateTime()();
|
||||
DateTimeColumn get endDt => dateTime().nullable()();
|
||||
BoolColumn get allDay => boolean().withDefault(const Constant(false))();
|
||||
TextColumn get description => text().withDefault(const Constant(''))();
|
||||
TextColumn get location => text().withDefault(const Constant(''))();
|
||||
TextColumn get color => text().withDefault(const Constant(''))();
|
||||
TextColumn get recurrence => text().nullable()();
|
||||
IntColumn get projectId => integer().nullable()();
|
||||
IntColumn get reminderMinutes => integer().nullable()();
|
||||
DateTimeColumn get cachedAt =>
|
||||
dateTime().clientDefault(() => DateTime.now())();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class CachedConversations extends Table {
|
||||
IntColumn get id => integer()();
|
||||
TextColumn get title => text()();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
DateTimeColumn get cachedAt =>
|
||||
dateTime().clientDefault(() => DateTime.now())();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class SyncMetadata extends Table {
|
||||
TextColumn get domain => text()();
|
||||
DateTimeColumn get lastSyncedAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {domain};
|
||||
}
|
||||
|
||||
/// Phase 3 — generic offline-write queue. One row per pending API call;
|
||||
/// drained in oldest-first order when the device comes back online.
|
||||
///
|
||||
/// `targetId` is the server id for updates/deletes; `tempId` is the
|
||||
/// client-allocated negative placeholder for offline creates. `payloadJson`
|
||||
/// is the typed args the API method needs (verb-specific shape).
|
||||
/// `baselineUpdatedAt` snapshots the cached row's `updated_at` at edit
|
||||
/// time so the replayer can drop the op if the server has moved on
|
||||
/// (server-wins conflict resolution).
|
||||
class PendingWrites extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get domain => text()();
|
||||
TextColumn get verb => text()();
|
||||
IntColumn get targetId => integer().nullable()();
|
||||
IntColumn get tempId => integer().nullable()();
|
||||
TextColumn get payloadJson => text().withDefault(const Constant('{}'))();
|
||||
DateTimeColumn get baselineUpdatedAt => dateTime().nullable()();
|
||||
DateTimeColumn get createdAt =>
|
||||
dateTime().clientDefault(() => DateTime.now())();
|
||||
IntColumn get tries => integer().withDefault(const Constant(0))();
|
||||
TextColumn get lastError => text().nullable()();
|
||||
}
|
||||
|
||||
const String kSyncDomainNotes = 'notes';
|
||||
const String kSyncDomainTasks = 'tasks';
|
||||
const String kSyncDomainProjects = 'projects';
|
||||
const String kSyncDomainMilestones = 'milestones';
|
||||
const String kSyncDomainEvents = 'events';
|
||||
const String kSyncDomainConversations = 'conversations';
|
||||
|
||||
const String kWriteVerbCreate = 'create';
|
||||
const String kWriteVerbUpdate = 'update';
|
||||
const String kWriteVerbDelete = 'delete';
|
||||
|
||||
@DriftDatabase(tables: [
|
||||
CachedNotes,
|
||||
CachedTasks,
|
||||
CachedProjects,
|
||||
CachedMilestones,
|
||||
CachedCalendarEvents,
|
||||
CachedConversations,
|
||||
SyncMetadata,
|
||||
PendingWrites,
|
||||
])
|
||||
class FabledDatabase extends _$FabledDatabase {
|
||||
FabledDatabase() : super(_openConnection());
|
||||
FabledDatabase.forTesting(super.executor);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 3;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onCreate: (m) => m.createAll(),
|
||||
onUpgrade: (m, from, to) async {
|
||||
// v1 → v2: added the per-domain caches beyond notes.
|
||||
if (from < 2) {
|
||||
await m.createTable(cachedTasks);
|
||||
await m.createTable(cachedProjects);
|
||||
await m.createTable(cachedMilestones);
|
||||
await m.createTable(cachedCalendarEvents);
|
||||
await m.createTable(cachedConversations);
|
||||
}
|
||||
// v2 → v3: added the offline write queue.
|
||||
if (from < 3) {
|
||||
await m.createTable(pendingWrites);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Notes ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<Note>> getAllNotes() async {
|
||||
final rows = await (select(cachedNotes)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||
.get();
|
||||
return rows.map(_noteFromRow).toList();
|
||||
}
|
||||
|
||||
Future<Note?> getNote(int id) async {
|
||||
final row = await (select(cachedNotes)..where((t) => t.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
return row == null ? null : _noteFromRow(row);
|
||||
}
|
||||
|
||||
Future<void> replaceAllNotes(List<Note> notes) async {
|
||||
await transaction(() async {
|
||||
await delete(cachedNotes).go();
|
||||
if (notes.isNotEmpty) {
|
||||
await batch((b) {
|
||||
b.insertAll(cachedNotes, notes.map(_noteToCompanion).toList());
|
||||
});
|
||||
}
|
||||
await _setLastSyncIn(kSyncDomainNotes, DateTime.now());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> upsertNote(Note note) async {
|
||||
await into(cachedNotes).insertOnConflictUpdate(_noteToCompanion(note));
|
||||
}
|
||||
|
||||
Future<void> deleteNote(int id) async {
|
||||
await (delete(cachedNotes)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
// ── Tasks ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<Task>> getAllTasks() async {
|
||||
final rows = await (select(cachedTasks)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||
.get();
|
||||
return rows.map(_taskFromRow).toList();
|
||||
}
|
||||
|
||||
Future<Task?> getTask(int id) async {
|
||||
final row = await (select(cachedTasks)..where((t) => t.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
return row == null ? null : _taskFromRow(row);
|
||||
}
|
||||
|
||||
Future<List<Task>> getTasksByProject(int projectId) async {
|
||||
final rows = await (select(cachedTasks)
|
||||
..where((t) => t.projectId.equals(projectId))
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||
.get();
|
||||
return rows.map(_taskFromRow).toList();
|
||||
}
|
||||
|
||||
Future<List<Task>> getSubTasks(int parentId) async {
|
||||
final rows = await (select(cachedTasks)
|
||||
..where((t) => t.parentId.equals(parentId))
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||
.get();
|
||||
return rows.map(_taskFromRow).toList();
|
||||
}
|
||||
|
||||
Future<void> replaceAllTasks(List<Task> tasks) async {
|
||||
await transaction(() async {
|
||||
await delete(cachedTasks).go();
|
||||
if (tasks.isNotEmpty) {
|
||||
await batch((b) {
|
||||
b.insertAll(cachedTasks, tasks.map(_taskToCompanion).toList());
|
||||
});
|
||||
}
|
||||
await _setLastSyncIn(kSyncDomainTasks, DateTime.now());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> upsertTask(Task task) async {
|
||||
await into(cachedTasks).insertOnConflictUpdate(_taskToCompanion(task));
|
||||
}
|
||||
|
||||
Future<void> deleteTask(int id) async {
|
||||
await (delete(cachedTasks)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
// ── Projects ─────────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<Project>> getAllProjects() async {
|
||||
final rows = await (select(cachedProjects)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||
.get();
|
||||
return rows.map(_projectFromRow).toList();
|
||||
}
|
||||
|
||||
Future<Project?> getProject(int id) async {
|
||||
final row = await (select(cachedProjects)..where((t) => t.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
return row == null ? null : _projectFromRow(row);
|
||||
}
|
||||
|
||||
Future<void> replaceAllProjects(List<Project> projects) async {
|
||||
await transaction(() async {
|
||||
await delete(cachedProjects).go();
|
||||
if (projects.isNotEmpty) {
|
||||
await batch((b) {
|
||||
b.insertAll(
|
||||
cachedProjects,
|
||||
projects.map(_projectToCompanion).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
await _setLastSyncIn(kSyncDomainProjects, DateTime.now());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> upsertProject(Project project) async {
|
||||
await into(cachedProjects)
|
||||
.insertOnConflictUpdate(_projectToCompanion(project));
|
||||
}
|
||||
|
||||
Future<void> deleteProject(int id) async {
|
||||
await (delete(cachedProjects)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
// ── Milestones ───────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<Milestone>> getMilestonesForProject(int projectId) async {
|
||||
final rows = await (select(cachedMilestones)
|
||||
..where((t) => t.projectId.equals(projectId))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.orderIndex)]))
|
||||
.get();
|
||||
return rows.map(_milestoneFromRow).toList();
|
||||
}
|
||||
|
||||
Future<void> replaceMilestonesForProject(
|
||||
int projectId,
|
||||
List<Milestone> milestones,
|
||||
) async {
|
||||
await transaction(() async {
|
||||
await (delete(cachedMilestones)
|
||||
..where((t) => t.projectId.equals(projectId)))
|
||||
.go();
|
||||
if (milestones.isNotEmpty) {
|
||||
await batch((b) {
|
||||
b.insertAll(
|
||||
cachedMilestones,
|
||||
milestones.map(_milestoneToCompanion).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
await _setLastSyncIn(kSyncDomainMilestones, DateTime.now());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> upsertMilestone(Milestone milestone) async {
|
||||
await into(cachedMilestones)
|
||||
.insertOnConflictUpdate(_milestoneToCompanion(milestone));
|
||||
}
|
||||
|
||||
Future<void> deleteMilestone(int id) async {
|
||||
await (delete(cachedMilestones)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
// ── Calendar events ──────────────────────────────────────────────────────
|
||||
|
||||
/// Returns events whose start_dt falls within [from, to). Recurrence
|
||||
/// expansion still happens server-side; a stored event that has a
|
||||
/// non-null recurrence rule will only have one row in cache (its
|
||||
/// canonical instance), so cached date-range queries on recurring
|
||||
/// events are best-effort and may miss future occurrences.
|
||||
Future<List<CalendarEvent>> getEventsInRange(
|
||||
DateTime from, DateTime to) async {
|
||||
final rows = await (select(cachedCalendarEvents)
|
||||
..where((t) =>
|
||||
t.startDt.isBiggerOrEqualValue(from) &
|
||||
t.startDt.isSmallerThanValue(to))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.startDt)]))
|
||||
.get();
|
||||
return rows.map(_eventFromRow).toList();
|
||||
}
|
||||
|
||||
/// Replace the cache for the given range. Events outside [from, to)
|
||||
/// are left alone — this matches the API's range-scoped semantics so
|
||||
/// repeated fetches over disjoint ranges don't clobber each other.
|
||||
Future<void> replaceEventsInRange(
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
List<CalendarEvent> events,
|
||||
) async {
|
||||
await transaction(() async {
|
||||
await (delete(cachedCalendarEvents)
|
||||
..where((t) =>
|
||||
t.startDt.isBiggerOrEqualValue(from) &
|
||||
t.startDt.isSmallerThanValue(to)))
|
||||
.go();
|
||||
if (events.isNotEmpty) {
|
||||
await batch((b) {
|
||||
b.insertAll(
|
||||
cachedCalendarEvents,
|
||||
events.map(_eventToCompanion).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
await _setLastSyncIn(kSyncDomainEvents, DateTime.now());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> upsertEvent(CalendarEvent event) async {
|
||||
await into(cachedCalendarEvents)
|
||||
.insertOnConflictUpdate(_eventToCompanion(event));
|
||||
}
|
||||
|
||||
Future<void> deleteEvent(int id) async {
|
||||
await (delete(cachedCalendarEvents)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
// ── Conversations (chat list — not messages) ─────────────────────────────
|
||||
|
||||
Future<List<Conversation>> getAllConversations() async {
|
||||
final rows = await (select(cachedConversations)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||
.get();
|
||||
return rows.map(_conversationFromRow).toList();
|
||||
}
|
||||
|
||||
Future<void> replaceAllConversations(
|
||||
List<Conversation> conversations) async {
|
||||
await transaction(() async {
|
||||
await delete(cachedConversations).go();
|
||||
if (conversations.isNotEmpty) {
|
||||
await batch((b) {
|
||||
b.insertAll(
|
||||
cachedConversations,
|
||||
conversations.map(_conversationToCompanion).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
await _setLastSyncIn(kSyncDomainConversations, DateTime.now());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteConversation(int id) async {
|
||||
await (delete(cachedConversations)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
// ── Sync metadata ────────────────────────────────────────────────────────
|
||||
|
||||
Future<DateTime?> getLastSync(String domain) async {
|
||||
final row = await (select(syncMetadata)
|
||||
..where((t) => t.domain.equals(domain)))
|
||||
.getSingleOrNull();
|
||||
return row?.lastSyncedAt;
|
||||
}
|
||||
|
||||
/// Most recent sync across all domains. Used by the OfflineBanner so the
|
||||
/// hint reads as "your data was current as of X" regardless of which
|
||||
/// screen the user happens to be on.
|
||||
Future<DateTime?> getLatestSync() async {
|
||||
final row = await (select(syncMetadata)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.lastSyncedAt)])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
return row?.lastSyncedAt;
|
||||
}
|
||||
|
||||
Future<void> _setLastSyncIn(String domain, DateTime timestamp) {
|
||||
return into(syncMetadata).insertOnConflictUpdate(
|
||||
SyncMetadataCompanion(
|
||||
domain: Value(domain),
|
||||
lastSyncedAt: Value(timestamp),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pending writes (Phase 3 offline queue) ───────────────────────────────
|
||||
|
||||
/// Allocate a fresh negative id used as a placeholder for an
|
||||
/// offline-created row until its server id arrives via replay.
|
||||
Future<int> nextTempId() async {
|
||||
final query = customSelect(
|
||||
'SELECT MIN(temp_id) AS m FROM pending_writes WHERE temp_id IS NOT NULL',
|
||||
);
|
||||
final row = await query.getSingleOrNull();
|
||||
final current = row?.read<int?>('m');
|
||||
return (current ?? 0) - 1;
|
||||
}
|
||||
|
||||
Future<int> enqueuePending({
|
||||
required String domain,
|
||||
required String verb,
|
||||
int? targetId,
|
||||
int? tempId,
|
||||
Map<String, dynamic> payload = const {},
|
||||
DateTime? baselineUpdatedAt,
|
||||
}) {
|
||||
return into(pendingWrites).insert(
|
||||
PendingWritesCompanion.insert(
|
||||
domain: domain,
|
||||
verb: verb,
|
||||
targetId: Value(targetId),
|
||||
tempId: Value(tempId),
|
||||
payloadJson: Value(jsonEncode(payload)),
|
||||
baselineUpdatedAt: Value(baselineUpdatedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Find the queued create-op for an offline-created row so a follow-up
|
||||
/// edit can be coalesced into the original payload (no separate update
|
||||
/// op gets queued). Returns null if no matching create is queued.
|
||||
Future<PendingWrite?> findQueuedCreate(String domain, int tempId) {
|
||||
return (select(pendingWrites)
|
||||
..where((t) =>
|
||||
t.domain.equals(domain) &
|
||||
t.verb.equals(kWriteVerbCreate) &
|
||||
t.tempId.equals(tempId)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> updatePendingPayload(int opId, Map<String, dynamic> payload) {
|
||||
return (update(pendingWrites)..where((t) => t.id.equals(opId))).write(
|
||||
PendingWritesCompanion(payloadJson: Value(jsonEncode(payload))),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deletePending(int opId) {
|
||||
return (delete(pendingWrites)..where((t) => t.id.equals(opId))).go();
|
||||
}
|
||||
|
||||
/// Pending ops oldest first — replay order.
|
||||
Future<List<PendingWrite>> listPending() {
|
||||
return (select(pendingWrites)
|
||||
..orderBy([(t) => OrderingTerm.asc(t.id)]))
|
||||
.get();
|
||||
}
|
||||
|
||||
Future<int> queueDepth() async {
|
||||
final row = await customSelect('SELECT COUNT(*) AS c FROM pending_writes')
|
||||
.getSingle();
|
||||
return row.read<int>('c');
|
||||
}
|
||||
|
||||
Future<void> markPendingFailed(int opId, String error) {
|
||||
return (update(pendingWrites)..where((t) => t.id.equals(opId))).write(
|
||||
PendingWritesCompanion(
|
||||
tries: const Value.absent(),
|
||||
lastError: Value(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> incrementPendingTries(int opId) {
|
||||
return customStatement(
|
||||
'UPDATE pending_writes SET tries = tries + 1 WHERE id = ?',
|
||||
[opId],
|
||||
);
|
||||
}
|
||||
|
||||
/// Watch queue depth — used by OfflineBanner for the "Retry (N)" affordance.
|
||||
Stream<int> watchQueueDepth() {
|
||||
final query = customSelect(
|
||||
'SELECT COUNT(*) AS c FROM pending_writes',
|
||||
readsFrom: {pendingWrites},
|
||||
);
|
||||
return query.watchSingle().map((row) => row.read<int>('c'));
|
||||
}
|
||||
|
||||
/// Watch the set of ids in [domain] that have a queued write (either a
|
||||
/// `target_id` for an update/delete or a `temp_id` for an offline create).
|
||||
/// Phase 4 — used by per-row pending-sync indicators in the list views.
|
||||
Stream<Set<int>> watchPendingIds(String domain) {
|
||||
final query = customSelect(
|
||||
'SELECT target_id, temp_id FROM pending_writes WHERE domain = ?',
|
||||
variables: [Variable.withString(domain)],
|
||||
readsFrom: {pendingWrites},
|
||||
);
|
||||
return query.watch().map((rows) {
|
||||
final ids = <int>{};
|
||||
for (final r in rows) {
|
||||
final t = r.read<int?>('target_id');
|
||||
final tmp = r.read<int?>('temp_id');
|
||||
if (t != null) ids.add(t);
|
||||
if (tmp != null) ids.add(tmp);
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Row ↔ model converters ──────────────────────────────────────────────────
|
||||
|
||||
CachedNotesCompanion _noteToCompanion(Note n) => CachedNotesCompanion(
|
||||
id: Value(n.id),
|
||||
title: Value(n.title),
|
||||
body: Value(n.body),
|
||||
tagsJson: Value(jsonEncode(n.tags)),
|
||||
noteType: Value(n.noteType),
|
||||
projectId: Value(n.projectId),
|
||||
milestoneId: Value(n.milestoneId),
|
||||
createdAt: Value(n.createdAt),
|
||||
updatedAt: Value(n.updatedAt),
|
||||
);
|
||||
|
||||
Note _noteFromRow(CachedNote r) => Note(
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
body: r.body,
|
||||
tags: (jsonDecode(r.tagsJson) as List<dynamic>).cast<String>(),
|
||||
noteType: r.noteType,
|
||||
projectId: r.projectId,
|
||||
milestoneId: r.milestoneId,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
);
|
||||
|
||||
CachedTasksCompanion _taskToCompanion(Task t) => CachedTasksCompanion(
|
||||
id: Value(t.id),
|
||||
title: Value(t.title),
|
||||
description: Value(t.description),
|
||||
status: Value(t.status.value),
|
||||
priority: Value(t.priority.value),
|
||||
dueDate: Value(t.dueDate),
|
||||
projectId: Value(t.projectId),
|
||||
milestoneId: Value(t.milestoneId),
|
||||
parentId: Value(t.parentId),
|
||||
createdAt: Value(t.createdAt),
|
||||
updatedAt: Value(t.updatedAt),
|
||||
);
|
||||
|
||||
Task _taskFromRow(CachedTask r) => Task(
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
status: TaskStatusExtension.fromString(r.status),
|
||||
priority: TaskPriorityExtension.fromString(r.priority),
|
||||
dueDate: r.dueDate,
|
||||
projectId: r.projectId,
|
||||
milestoneId: r.milestoneId,
|
||||
parentId: r.parentId,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
);
|
||||
|
||||
CachedProjectsCompanion _projectToCompanion(Project p) =>
|
||||
CachedProjectsCompanion(
|
||||
id: Value(p.id),
|
||||
title: Value(p.title),
|
||||
description: Value(p.description),
|
||||
goal: Value(p.goal),
|
||||
status: Value(p.status),
|
||||
color: Value(p.color),
|
||||
autoSummary: Value(p.autoSummary),
|
||||
createdAt: Value(p.createdAt),
|
||||
updatedAt: Value(p.updatedAt),
|
||||
);
|
||||
|
||||
Project _projectFromRow(CachedProject r) => Project(
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
goal: r.goal,
|
||||
status: r.status,
|
||||
color: r.color,
|
||||
autoSummary: r.autoSummary,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
);
|
||||
|
||||
CachedMilestonesCompanion _milestoneToCompanion(Milestone m) =>
|
||||
CachedMilestonesCompanion(
|
||||
id: Value(m.id),
|
||||
projectId: Value(m.projectId),
|
||||
title: Value(m.title),
|
||||
description: Value(m.description),
|
||||
status: Value(m.status),
|
||||
orderIndex: Value(m.orderIndex),
|
||||
total: Value(m.total),
|
||||
completed: Value(m.completed),
|
||||
pct: Value(m.pct),
|
||||
createdAt: Value(m.createdAt),
|
||||
updatedAt: Value(m.updatedAt),
|
||||
);
|
||||
|
||||
Milestone _milestoneFromRow(CachedMilestone r) => Milestone(
|
||||
id: r.id,
|
||||
projectId: r.projectId,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
status: r.status,
|
||||
orderIndex: r.orderIndex,
|
||||
total: r.total,
|
||||
completed: r.completed,
|
||||
pct: r.pct,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
);
|
||||
|
||||
CachedCalendarEventsCompanion _eventToCompanion(CalendarEvent e) =>
|
||||
CachedCalendarEventsCompanion(
|
||||
id: Value(e.id),
|
||||
title: Value(e.title),
|
||||
startDt: Value(e.startDt),
|
||||
endDt: Value(e.endDt),
|
||||
allDay: Value(e.allDay),
|
||||
description: Value(e.description),
|
||||
location: Value(e.location),
|
||||
color: Value(e.color),
|
||||
recurrence: Value(e.recurrence),
|
||||
projectId: Value(e.projectId),
|
||||
reminderMinutes: Value(e.reminderMinutes),
|
||||
);
|
||||
|
||||
CalendarEvent _eventFromRow(CachedCalendarEvent r) => CalendarEvent(
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
startDt: r.startDt,
|
||||
endDt: r.endDt,
|
||||
allDay: r.allDay,
|
||||
description: r.description,
|
||||
location: r.location,
|
||||
color: r.color,
|
||||
recurrence: r.recurrence,
|
||||
projectId: r.projectId,
|
||||
reminderMinutes: r.reminderMinutes,
|
||||
);
|
||||
|
||||
CachedConversationsCompanion _conversationToCompanion(Conversation c) =>
|
||||
CachedConversationsCompanion(
|
||||
id: Value(c.id),
|
||||
title: Value(c.title),
|
||||
createdAt: Value(c.createdAt),
|
||||
updatedAt: Value(c.updatedAt),
|
||||
);
|
||||
|
||||
Conversation _conversationFromRow(CachedConversation r) => Conversation(
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
);
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
return LazyDatabase(() async {
|
||||
if (Platform.isAndroid) {
|
||||
await applyWorkaroundToOpenSqlite3OnOldAndroidVersions();
|
||||
}
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final file = File(p.join(dir.path, 'fabled_cache.sqlite'));
|
||||
return NativeDatabase.createInBackground(file);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,46 @@
|
||||
import '../../core/exceptions.dart';
|
||||
import '../api/chat_api.dart';
|
||||
export '../api/chat_api.dart'
|
||||
show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate, ChatToolCall;
|
||||
import '../local/database.dart';
|
||||
import '../models/conversation.dart';
|
||||
import '../models/message.dart';
|
||||
|
||||
/// Chat repository with read-through caching for the conversation list only.
|
||||
/// Messages and the live SSE stream are intentionally not cached — they are
|
||||
/// per-conversation, large, and require a live network anyway.
|
||||
class ChatRepository {
|
||||
final ChatApi _api;
|
||||
const ChatRepository(this._api);
|
||||
final FabledDatabase _db;
|
||||
|
||||
const ChatRepository(this._api, this._db);
|
||||
|
||||
Future<List<Conversation>> getConversations() async {
|
||||
try {
|
||||
final conversations = await _api.getConversations();
|
||||
await _db.replaceAllConversations(conversations);
|
||||
return conversations;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getAllConversations();
|
||||
if (cached.isEmpty) rethrow;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Conversation>> getConversations() => _api.getConversations();
|
||||
Future<Conversation> createConversation(String title) =>
|
||||
_api.createConversation(title);
|
||||
Future<void> deleteConversation(int id) => _api.deleteConversation(id);
|
||||
|
||||
Future<void> deleteConversation(int id) async {
|
||||
await _api.deleteConversation(id);
|
||||
await _db.deleteConversation(id);
|
||||
}
|
||||
|
||||
Future<(Conversation, List<Message>)> getMessages(int conversationId) =>
|
||||
_api.getMessages(conversationId);
|
||||
|
||||
Future<void> sendMessage(int conversationId, String content) =>
|
||||
_api.sendMessage(conversationId, content);
|
||||
|
||||
Stream<ChatStreamEvent> streamGeneration(int conversationId) =>
|
||||
_api.streamGeneration(conversationId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import '../../core/exceptions.dart';
|
||||
import '../api/events_api.dart';
|
||||
import '../local/database.dart';
|
||||
import '../models/calendar_event.dart';
|
||||
|
||||
/// Calendar events repository with read-through caching (Phase 1+2) and
|
||||
/// offline-write queueing (Phase 3). Range-scoped reads: `getEvents(from, to)`
|
||||
/// mirrors that window into the cache (events outside the window are left
|
||||
/// alone) so successive disjoint range fetches don't clobber each other.
|
||||
///
|
||||
/// CalendarEvent has no `updatedAt` field, so write replay uses last-
|
||||
/// writer-wins on update/delete (no server-side baseline check).
|
||||
class EventsRepository {
|
||||
final EventsApi _api;
|
||||
final FabledDatabase _db;
|
||||
|
||||
const EventsRepository(this._api, this._db);
|
||||
|
||||
Future<List<CalendarEvent>> getEvents(DateTime from, DateTime to) async {
|
||||
try {
|
||||
final events = await _api.getEvents(from, to);
|
||||
await _db.replaceEventsInRange(from, to, events);
|
||||
return events;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getEventsInRange(from, to);
|
||||
if (cached.isEmpty) rethrow;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async {
|
||||
try {
|
||||
final event = await _api.createEvent(payload);
|
||||
await _db.upsertEvent(event);
|
||||
return event;
|
||||
} on NetworkException {
|
||||
final tempId = await _db.nextTempId();
|
||||
final optimistic = _eventFromPayload(tempId, payload);
|
||||
await _db.upsertEvent(optimistic);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainEvents,
|
||||
verb: kWriteVerbCreate,
|
||||
tempId: tempId,
|
||||
payload: payload,
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<CalendarEvent> updateEvent(
|
||||
int id, Map<String, dynamic> fields) async {
|
||||
try {
|
||||
final updated = await _api.updateEvent(id, fields);
|
||||
await _db.upsertEvent(updated);
|
||||
return updated;
|
||||
} on NetworkException {
|
||||
final cached = (await _db.getEventsInRange(
|
||||
DateTime.fromMicrosecondsSinceEpoch(0),
|
||||
DateTime.now().add(const Duration(days: 365 * 100)),
|
||||
))
|
||||
.where((e) => e.id == id)
|
||||
.firstOrNull;
|
||||
if (cached == null) rethrow;
|
||||
final optimistic = _applyEventFields(cached, fields);
|
||||
await _db.upsertEvent(optimistic);
|
||||
if (id < 0) {
|
||||
final queued = await _db.findQueuedCreate(kSyncDomainEvents, id);
|
||||
if (queued != null) {
|
||||
await _db.updatePendingPayload(queued.id, fields);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainEvents,
|
||||
verb: kWriteVerbUpdate,
|
||||
targetId: id,
|
||||
payload: fields,
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteEvent(int id) async {
|
||||
try {
|
||||
await _api.deleteEvent(id);
|
||||
await _db.deleteEvent(id);
|
||||
} on NetworkException {
|
||||
if (id < 0) {
|
||||
final queued = await _db.findQueuedCreate(kSyncDomainEvents, id);
|
||||
if (queued != null) await _db.deletePending(queued.id);
|
||||
await _db.deleteEvent(id);
|
||||
return;
|
||||
}
|
||||
await _db.deleteEvent(id);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainEvents,
|
||||
verb: kWriteVerbDelete,
|
||||
targetId: id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CalendarEvent _eventFromPayload(int id, Map<String, dynamic> p) {
|
||||
return CalendarEvent(
|
||||
id: id,
|
||||
title: p['title'] as String? ?? '',
|
||||
startDt: _parseIso(p['start_dt'])!,
|
||||
endDt: _parseIso(p['end_dt']),
|
||||
allDay: p['all_day'] as bool? ?? false,
|
||||
description: p['description'] as String? ?? '',
|
||||
location: p['location'] as String? ?? '',
|
||||
color: p['color'] as String? ?? '',
|
||||
recurrence: p['recurrence'] as String?,
|
||||
projectId: p['project_id'] as int?,
|
||||
reminderMinutes: p['reminder_minutes'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
CalendarEvent _applyEventFields(CalendarEvent e, Map<String, dynamic> f) {
|
||||
return CalendarEvent(
|
||||
id: e.id,
|
||||
title: f['title'] as String? ?? e.title,
|
||||
startDt: _parseIso(f['start_dt']) ?? e.startDt,
|
||||
endDt: f.containsKey('end_dt') ? _parseIso(f['end_dt']) : e.endDt,
|
||||
allDay: f['all_day'] as bool? ?? e.allDay,
|
||||
description: f['description'] as String? ?? e.description,
|
||||
location: f['location'] as String? ?? e.location,
|
||||
color: f['color'] as String? ?? e.color,
|
||||
recurrence:
|
||||
f.containsKey('recurrence') ? f['recurrence'] as String? : e.recurrence,
|
||||
projectId: f.containsKey('project_id')
|
||||
? f['project_id'] as int?
|
||||
: e.projectId,
|
||||
reminderMinutes: f.containsKey('reminder_minutes')
|
||||
? f['reminder_minutes'] as int?
|
||||
: e.reminderMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
DateTime? _parseIso(dynamic raw) {
|
||||
if (raw is String && raw.isNotEmpty) return DateTime.tryParse(raw)?.toLocal();
|
||||
return null;
|
||||
}
|
||||
@@ -1,26 +1,164 @@
|
||||
import '../../core/exceptions.dart';
|
||||
import '../api/milestones_api.dart';
|
||||
import '../local/database.dart';
|
||||
import '../models/milestone.dart';
|
||||
|
||||
/// Milestones repository with read-through caching (Phase 1+2) and
|
||||
/// offline-write queueing (Phase 3). See `notes_repository.dart` for the
|
||||
/// full pattern.
|
||||
class MilestonesRepository {
|
||||
final MilestonesApi _api;
|
||||
const MilestonesRepository(this._api);
|
||||
final FabledDatabase _db;
|
||||
|
||||
Future<List<Milestone>> getAll(int projectId, {String? status}) =>
|
||||
_api.getAll(projectId, status: status);
|
||||
const MilestonesRepository(this._api, this._db);
|
||||
|
||||
Future<List<Milestone>> getAll(int projectId, {String? status}) async {
|
||||
try {
|
||||
final milestones = await _api.getAll(projectId, status: status);
|
||||
// Only mirror the full per-project list to cache on the unfiltered
|
||||
// fetch; a status filter would otherwise drop entries from cache.
|
||||
if (status == null) {
|
||||
await _db.replaceMilestonesForProject(projectId, milestones);
|
||||
} else {
|
||||
for (final m in milestones) {
|
||||
await _db.upsertMilestone(m);
|
||||
}
|
||||
}
|
||||
return milestones;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getMilestonesForProject(projectId);
|
||||
if (cached.isEmpty) rethrow;
|
||||
if (status != null) {
|
||||
return cached.where((m) => m.status == status).toList();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Milestone> create(
|
||||
int projectId, {
|
||||
required String title,
|
||||
String? description,
|
||||
int orderIndex = 0,
|
||||
}) =>
|
||||
_api.create(projectId,
|
||||
title: title, description: description, orderIndex: orderIndex);
|
||||
}) async {
|
||||
try {
|
||||
final milestone = await _api.create(
|
||||
projectId,
|
||||
title: title,
|
||||
description: description,
|
||||
orderIndex: orderIndex,
|
||||
);
|
||||
await _db.upsertMilestone(milestone);
|
||||
return milestone;
|
||||
} on NetworkException {
|
||||
final tempId = await _db.nextTempId();
|
||||
final now = DateTime.now();
|
||||
final optimistic = Milestone(
|
||||
id: tempId,
|
||||
projectId: projectId,
|
||||
title: title,
|
||||
description: description,
|
||||
status: 'active',
|
||||
orderIndex: orderIndex,
|
||||
total: 0,
|
||||
completed: 0,
|
||||
pct: 0.0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await _db.upsertMilestone(optimistic);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainMilestones,
|
||||
verb: kWriteVerbCreate,
|
||||
tempId: tempId,
|
||||
payload: {
|
||||
'project_id': projectId,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'order_index': orderIndex,
|
||||
},
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Milestone> update(
|
||||
int projectId, int milestoneId, Map<String, dynamic> fields) =>
|
||||
_api.update(projectId, milestoneId, fields);
|
||||
int projectId,
|
||||
int milestoneId,
|
||||
Map<String, dynamic> fields,
|
||||
) async {
|
||||
try {
|
||||
final updated = await _api.update(projectId, milestoneId, fields);
|
||||
await _db.upsertMilestone(updated);
|
||||
return updated;
|
||||
} on NetworkException {
|
||||
final cached = (await _db.getMilestonesForProject(projectId))
|
||||
.where((m) => m.id == milestoneId)
|
||||
.firstOrNull;
|
||||
if (cached == null) rethrow;
|
||||
final optimistic = _applyMilestoneFields(cached, fields);
|
||||
await _db.upsertMilestone(optimistic);
|
||||
if (milestoneId < 0) {
|
||||
final queued =
|
||||
await _db.findQueuedCreate(kSyncDomainMilestones, milestoneId);
|
||||
if (queued != null) {
|
||||
// Carry project_id through coalesced payload — replay needs it.
|
||||
final merged = <String, dynamic>{
|
||||
'project_id': projectId,
|
||||
...fields,
|
||||
};
|
||||
await _db.updatePendingPayload(queued.id, merged);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainMilestones,
|
||||
verb: kWriteVerbUpdate,
|
||||
targetId: milestoneId,
|
||||
payload: <String, dynamic>{'project_id': projectId, ...fields},
|
||||
baselineUpdatedAt: cached.updatedAt,
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(int projectId, int milestoneId) =>
|
||||
_api.delete(projectId, milestoneId);
|
||||
Future<void> delete(int projectId, int milestoneId) async {
|
||||
try {
|
||||
await _api.delete(projectId, milestoneId);
|
||||
await _db.deleteMilestone(milestoneId);
|
||||
} on NetworkException {
|
||||
if (milestoneId < 0) {
|
||||
final queued =
|
||||
await _db.findQueuedCreate(kSyncDomainMilestones, milestoneId);
|
||||
if (queued != null) await _db.deletePending(queued.id);
|
||||
await _db.deleteMilestone(milestoneId);
|
||||
return;
|
||||
}
|
||||
await _db.deleteMilestone(milestoneId);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainMilestones,
|
||||
verb: kWriteVerbDelete,
|
||||
targetId: milestoneId,
|
||||
payload: {'project_id': projectId},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Milestone _applyMilestoneFields(Milestone m, Map<String, dynamic> f) {
|
||||
return Milestone(
|
||||
id: m.id,
|
||||
projectId: m.projectId,
|
||||
title: f['title'] as String? ?? m.title,
|
||||
description: f.containsKey('description')
|
||||
? f['description'] as String?
|
||||
: m.description,
|
||||
status: f['status'] as String? ?? m.status,
|
||||
orderIndex: f['order_index'] as int? ?? m.orderIndex,
|
||||
total: m.total,
|
||||
completed: m.completed,
|
||||
pct: m.pct,
|
||||
createdAt: m.createdAt,
|
||||
updatedAt: m.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,53 @@
|
||||
import '../../core/exceptions.dart';
|
||||
import '../api/notes_api.dart';
|
||||
import '../local/database.dart';
|
||||
import '../models/note.dart';
|
||||
|
||||
/// Notes repository with read-through caching for Tier 2 offline support
|
||||
/// (Phase 1+2) and offline-write queueing (Phase 3).
|
||||
///
|
||||
/// Reads attempt the network first; on success the response is written to
|
||||
/// the local Drift cache. On `NetworkException` the read falls back to the
|
||||
/// cache (rethrowing if the cache is empty so the UI can show its
|
||||
/// fresh-install empty state).
|
||||
///
|
||||
/// Writes hit the server then sync the cache. On `NetworkException` the
|
||||
/// write is queued in `pending_writes` and applied optimistically to the
|
||||
/// cache (negative temp-id for creates, in-place update for edits, removal
|
||||
/// for deletes). Subsequent edits to a temp-id row are coalesced into the
|
||||
/// queued create — only one server call is made per offline-created row.
|
||||
///
|
||||
/// `AuthStatus.offline` is owned by `AuthNotifier.verify()` (a periodic
|
||||
/// heartbeat). This repository deliberately does not poke that state.
|
||||
class NotesRepository {
|
||||
final NotesApi _api;
|
||||
const NotesRepository(this._api);
|
||||
final FabledDatabase _db;
|
||||
|
||||
Future<List<Note>> getAll() => _api.getAll();
|
||||
Future<Note> getOne(int id) => _api.getOne(id);
|
||||
const NotesRepository(this._api, this._db);
|
||||
|
||||
Future<List<Note>> getAll() async {
|
||||
try {
|
||||
final notes = await _api.getAll();
|
||||
await _db.replaceAllNotes(notes);
|
||||
return notes;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getAllNotes();
|
||||
if (cached.isEmpty) rethrow;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Note> getOne(int id) async {
|
||||
try {
|
||||
final note = await _api.getOne(id);
|
||||
await _db.upsertNote(note);
|
||||
return note;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getNote(id);
|
||||
if (cached == null) rethrow;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Note> create(
|
||||
String title,
|
||||
@@ -14,9 +55,46 @@ class NotesRepository {
|
||||
List<String> tags = const [],
|
||||
int? projectId,
|
||||
String noteType = 'note',
|
||||
}) =>
|
||||
_api.create(title, body,
|
||||
tags: tags, projectId: projectId, noteType: noteType);
|
||||
}) async {
|
||||
try {
|
||||
final note = await _api.create(
|
||||
title,
|
||||
body,
|
||||
tags: tags,
|
||||
projectId: projectId,
|
||||
noteType: noteType,
|
||||
);
|
||||
await _db.upsertNote(note);
|
||||
return note;
|
||||
} on NetworkException {
|
||||
final tempId = await _db.nextTempId();
|
||||
final now = DateTime.now();
|
||||
final optimistic = Note(
|
||||
id: tempId,
|
||||
title: title,
|
||||
body: body,
|
||||
tags: tags,
|
||||
noteType: noteType,
|
||||
projectId: projectId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await _db.upsertNote(optimistic);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainNotes,
|
||||
verb: kWriteVerbCreate,
|
||||
tempId: tempId,
|
||||
payload: {
|
||||
'title': title,
|
||||
'body': body,
|
||||
'tags': tags,
|
||||
'project_id': projectId,
|
||||
'note_type': noteType,
|
||||
},
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Note> update(
|
||||
int id,
|
||||
@@ -26,12 +104,76 @@ class NotesRepository {
|
||||
int? projectId,
|
||||
bool clearProject = false,
|
||||
String noteType = 'note',
|
||||
}) =>
|
||||
_api.update(id, title, body,
|
||||
tags: tags,
|
||||
projectId: projectId,
|
||||
clearProject: clearProject,
|
||||
noteType: noteType);
|
||||
}) async {
|
||||
try {
|
||||
final updated = await _api.update(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
tags: tags,
|
||||
projectId: projectId,
|
||||
clearProject: clearProject,
|
||||
noteType: noteType,
|
||||
);
|
||||
await _db.upsertNote(updated);
|
||||
return updated;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getNote(id);
|
||||
if (cached == null) rethrow;
|
||||
final payload = <String, dynamic>{
|
||||
'title': title,
|
||||
'body': body,
|
||||
'tags': tags,
|
||||
'project_id': projectId,
|
||||
'clear_project': clearProject,
|
||||
'note_type': noteType,
|
||||
};
|
||||
final optimistic = cached.copyWith(
|
||||
title: title,
|
||||
body: body,
|
||||
tags: tags,
|
||||
noteType: noteType,
|
||||
projectId: clearProject ? null : projectId,
|
||||
);
|
||||
await _db.upsertNote(optimistic);
|
||||
// Edits to an offline-created row coalesce into the queued create.
|
||||
if (id < 0) {
|
||||
final queued = await _db.findQueuedCreate(kSyncDomainNotes, id);
|
||||
if (queued != null) {
|
||||
await _db.updatePendingPayload(queued.id, payload);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainNotes,
|
||||
verb: kWriteVerbUpdate,
|
||||
targetId: id,
|
||||
payload: payload,
|
||||
baselineUpdatedAt: cached.updatedAt,
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(int id) => _api.delete(id);
|
||||
Future<void> delete(int id) async {
|
||||
try {
|
||||
await _api.delete(id);
|
||||
await _db.deleteNote(id);
|
||||
} on NetworkException {
|
||||
// Deleting an offline-created row that never reached the server
|
||||
// just drops the queued create — no server call needed.
|
||||
if (id < 0) {
|
||||
final queued = await _db.findQueuedCreate(kSyncDomainNotes, id);
|
||||
if (queued != null) await _db.deletePending(queued.id);
|
||||
await _db.deleteNote(id);
|
||||
return;
|
||||
}
|
||||
await _db.deleteNote(id);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainNotes,
|
||||
verb: kWriteVerbDelete,
|
||||
targetId: id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,168 @@
|
||||
import '../../core/exceptions.dart';
|
||||
import '../api/projects_api.dart';
|
||||
import '../local/database.dart';
|
||||
import '../models/project.dart';
|
||||
|
||||
/// Projects repository with read-through caching (Phase 1+2) and offline-write
|
||||
/// queueing (Phase 3). See `notes_repository.dart` for the full pattern.
|
||||
///
|
||||
/// `getAll`'s sort/order/status query parameters are server-side filters; the
|
||||
/// cache stores the unfiltered list as last seen. Offline fallback returns
|
||||
/// the full cache regardless of the requested filters — close enough for a
|
||||
/// "view what you had" experience while disconnected.
|
||||
class ProjectsRepository {
|
||||
final ProjectsApi _api;
|
||||
const ProjectsRepository(this._api);
|
||||
final FabledDatabase _db;
|
||||
|
||||
const ProjectsRepository(this._api, this._db);
|
||||
|
||||
Future<List<Project>> getAll({
|
||||
String? status,
|
||||
String sort = 'updated_at',
|
||||
String order = 'desc',
|
||||
}) =>
|
||||
_api.getAll(status: status, sort: sort, order: order);
|
||||
Future<Project> getOne(int id) => _api.getOne(id);
|
||||
}) async {
|
||||
try {
|
||||
final projects =
|
||||
await _api.getAll(status: status, sort: sort, order: order);
|
||||
// Only refresh the cache on the unfiltered default fetch — otherwise a
|
||||
// status=archived call would clobber the active-projects cache.
|
||||
if (status == null) {
|
||||
await _db.replaceAllProjects(projects);
|
||||
} else {
|
||||
for (final p in projects) {
|
||||
await _db.upsertProject(p);
|
||||
}
|
||||
}
|
||||
return projects;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getAllProjects();
|
||||
if (cached.isEmpty) rethrow;
|
||||
if (status != null) {
|
||||
return cached.where((p) => p.status == status).toList();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Project> getOne(int id) async {
|
||||
try {
|
||||
final project = await _api.getOne(id);
|
||||
await _db.upsertProject(project);
|
||||
return project;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getProject(id);
|
||||
if (cached == null) rethrow;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Project> create({
|
||||
required String title,
|
||||
String? description,
|
||||
String? goal,
|
||||
String? color,
|
||||
String status = 'active',
|
||||
}) =>
|
||||
_api.create(
|
||||
title: title,
|
||||
description: description,
|
||||
goal: goal,
|
||||
color: color,
|
||||
status: status);
|
||||
Future<Project> update(int id, Map<String, dynamic> fields) =>
|
||||
_api.update(id, fields);
|
||||
Future<void> delete(int id) => _api.delete(id);
|
||||
}) async {
|
||||
try {
|
||||
final project = await _api.create(
|
||||
title: title,
|
||||
description: description,
|
||||
goal: goal,
|
||||
color: color,
|
||||
status: status,
|
||||
);
|
||||
await _db.upsertProject(project);
|
||||
return project;
|
||||
} on NetworkException {
|
||||
final tempId = await _db.nextTempId();
|
||||
final now = DateTime.now();
|
||||
final optimistic = Project(
|
||||
id: tempId,
|
||||
title: title,
|
||||
description: description,
|
||||
goal: goal,
|
||||
status: status,
|
||||
color: color,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await _db.upsertProject(optimistic);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainProjects,
|
||||
verb: kWriteVerbCreate,
|
||||
tempId: tempId,
|
||||
payload: {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'goal': goal,
|
||||
'color': color,
|
||||
'status': status,
|
||||
},
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Project> update(int id, Map<String, dynamic> fields) async {
|
||||
try {
|
||||
final updated = await _api.update(id, fields);
|
||||
await _db.upsertProject(updated);
|
||||
return updated;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getProject(id);
|
||||
if (cached == null) rethrow;
|
||||
final optimistic = _applyProjectFields(cached, fields);
|
||||
await _db.upsertProject(optimistic);
|
||||
if (id < 0) {
|
||||
final queued = await _db.findQueuedCreate(kSyncDomainProjects, id);
|
||||
if (queued != null) {
|
||||
await _db.updatePendingPayload(queued.id, fields);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainProjects,
|
||||
verb: kWriteVerbUpdate,
|
||||
targetId: id,
|
||||
payload: fields,
|
||||
baselineUpdatedAt: cached.updatedAt,
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
try {
|
||||
await _api.delete(id);
|
||||
await _db.deleteProject(id);
|
||||
} on NetworkException {
|
||||
if (id < 0) {
|
||||
final queued = await _db.findQueuedCreate(kSyncDomainProjects, id);
|
||||
if (queued != null) await _db.deletePending(queued.id);
|
||||
await _db.deleteProject(id);
|
||||
return;
|
||||
}
|
||||
await _db.deleteProject(id);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainProjects,
|
||||
verb: kWriteVerbDelete,
|
||||
targetId: id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Project _applyProjectFields(Project p, Map<String, dynamic> f) {
|
||||
return Project(
|
||||
id: p.id,
|
||||
title: f['title'] as String? ?? p.title,
|
||||
description:
|
||||
f.containsKey('description') ? f['description'] as String? : p.description,
|
||||
goal: f.containsKey('goal') ? f['goal'] as String? : p.goal,
|
||||
status: f['status'] as String? ?? p.status,
|
||||
color: f.containsKey('color') ? f['color'] as String? : p.color,
|
||||
autoSummary: p.autoSummary,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,63 @@
|
||||
import '../../core/exceptions.dart';
|
||||
import '../api/tasks_api.dart';
|
||||
import '../local/database.dart';
|
||||
import '../models/task.dart';
|
||||
|
||||
/// Tasks repository with read-through caching (Phase 1+2) and offline-write
|
||||
/// queueing (Phase 3). See `notes_repository.dart` for the full pattern.
|
||||
class TasksRepository {
|
||||
final TasksApi _api;
|
||||
const TasksRepository(this._api);
|
||||
final FabledDatabase _db;
|
||||
|
||||
Future<List<Task>> getAll() => _api.getAll();
|
||||
Future<Task> getOne(int id) => _api.getOne(id);
|
||||
const TasksRepository(this._api, this._db);
|
||||
|
||||
Future<List<Task>> getAll() async {
|
||||
try {
|
||||
final tasks = await _api.getAll();
|
||||
await _db.replaceAllTasks(tasks);
|
||||
return tasks;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getAllTasks();
|
||||
if (cached.isEmpty) rethrow;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Task> getOne(int id) async {
|
||||
try {
|
||||
final task = await _api.getOne(id);
|
||||
await _db.upsertTask(task);
|
||||
return task;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getTask(id);
|
||||
if (cached == null) rethrow;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Task>> getByProject(int projectId) async {
|
||||
try {
|
||||
final tasks = await _api.getByProject(projectId);
|
||||
for (final t in tasks) {
|
||||
await _db.upsertTask(t);
|
||||
}
|
||||
return tasks;
|
||||
} on NetworkException {
|
||||
return _db.getTasksByProject(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Task>> getSubTasks(int parentId) async {
|
||||
try {
|
||||
final tasks = await _api.getSubTasks(parentId);
|
||||
for (final t in tasks) {
|
||||
await _db.upsertTask(t);
|
||||
}
|
||||
return tasks;
|
||||
} on NetworkException {
|
||||
return _db.getSubTasks(parentId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Task> create({
|
||||
required String title,
|
||||
@@ -16,8 +67,9 @@ class TasksRepository {
|
||||
DateTime? dueDate,
|
||||
int? projectId,
|
||||
int? parentId,
|
||||
}) =>
|
||||
_api.create(
|
||||
}) async {
|
||||
try {
|
||||
final task = await _api.create(
|
||||
title: title,
|
||||
description: description,
|
||||
status: status,
|
||||
@@ -26,12 +78,119 @@ class TasksRepository {
|
||||
projectId: projectId,
|
||||
parentId: parentId,
|
||||
);
|
||||
await _db.upsertTask(task);
|
||||
return task;
|
||||
} on NetworkException {
|
||||
final tempId = await _db.nextTempId();
|
||||
final now = DateTime.now();
|
||||
final optimistic = Task(
|
||||
id: tempId,
|
||||
title: title,
|
||||
description: description,
|
||||
status: status,
|
||||
priority: priority,
|
||||
dueDate: dueDate,
|
||||
projectId: projectId,
|
||||
parentId: parentId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await _db.upsertTask(optimistic);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainTasks,
|
||||
verb: kWriteVerbCreate,
|
||||
tempId: tempId,
|
||||
payload: {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'status': status.value,
|
||||
'priority': priority.value,
|
||||
'due_date': dueDate?.toIso8601String(),
|
||||
'project_id': projectId,
|
||||
'parent_id': parentId,
|
||||
},
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Task>> getByProject(int projectId) => _api.getByProject(projectId);
|
||||
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
|
||||
Future<Task> update(int id, Map<String, dynamic> fields) async {
|
||||
try {
|
||||
final updated = await _api.update(id, fields);
|
||||
await _db.upsertTask(updated);
|
||||
return updated;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getTask(id);
|
||||
if (cached == null) rethrow;
|
||||
final optimistic = _applyTaskFields(cached, fields);
|
||||
await _db.upsertTask(optimistic);
|
||||
if (id < 0) {
|
||||
final queued = await _db.findQueuedCreate(kSyncDomainTasks, id);
|
||||
if (queued != null) {
|
||||
await _db.updatePendingPayload(queued.id, fields);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainTasks,
|
||||
verb: kWriteVerbUpdate,
|
||||
targetId: id,
|
||||
payload: fields,
|
||||
baselineUpdatedAt: cached.updatedAt,
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Task> update(int id, Map<String, dynamic> fields) =>
|
||||
_api.update(id, fields);
|
||||
|
||||
Future<void> delete(int id) => _api.delete(id);
|
||||
Future<void> delete(int id) async {
|
||||
try {
|
||||
await _api.delete(id);
|
||||
await _db.deleteTask(id);
|
||||
} on NetworkException {
|
||||
if (id < 0) {
|
||||
final queued = await _db.findQueuedCreate(kSyncDomainTasks, id);
|
||||
if (queued != null) await _db.deletePending(queued.id);
|
||||
await _db.deleteTask(id);
|
||||
return;
|
||||
}
|
||||
await _db.deleteTask(id);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainTasks,
|
||||
verb: kWriteVerbDelete,
|
||||
targetId: id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a partial-fields map (the same shape sent to the PUT endpoint)
|
||||
/// to a cached Task so the UI can show the edit immediately.
|
||||
Task _applyTaskFields(Task t, Map<String, dynamic> f) {
|
||||
return Task(
|
||||
id: t.id,
|
||||
title: f['title'] as String? ?? t.title,
|
||||
description:
|
||||
f.containsKey('body') ? f['body'] as String? : t.description,
|
||||
status: f.containsKey('status')
|
||||
? TaskStatusExtension.fromString(f['status'] as String?)
|
||||
: t.status,
|
||||
priority: f.containsKey('priority')
|
||||
? TaskPriorityExtension.fromString(f['priority'] as String?)
|
||||
: t.priority,
|
||||
dueDate: f.containsKey('due_date')
|
||||
? (f['due_date'] is String
|
||||
? DateTime.tryParse(f['due_date'] as String)
|
||||
: null)
|
||||
: t.dueDate,
|
||||
projectId: f.containsKey('project_id')
|
||||
? f['project_id'] as int?
|
||||
: t.projectId,
|
||||
milestoneId: f.containsKey('milestone_id')
|
||||
? f['milestone_id'] as int?
|
||||
: t.milestoneId,
|
||||
parentId:
|
||||
f.containsKey('parent_id') ? f['parent_id'] as int? : t.parentId,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../core/exceptions.dart';
|
||||
import '../api/events_api.dart';
|
||||
import '../api/milestones_api.dart';
|
||||
import '../api/notes_api.dart';
|
||||
import '../api/projects_api.dart';
|
||||
import '../api/tasks_api.dart';
|
||||
import '../local/database.dart';
|
||||
import '../models/note.dart';
|
||||
import '../models/project.dart';
|
||||
import '../models/task.dart';
|
||||
|
||||
/// Reasons a queued write was dropped during replay. Surfaced to the UI as
|
||||
/// a one-time SnackBar so the user knows their offline edit didn't land.
|
||||
enum QueueFailureReason { overwritten, missing, rejected }
|
||||
|
||||
class QueueFailure {
|
||||
final QueueFailureReason reason;
|
||||
final String domain;
|
||||
final String? title;
|
||||
final String? detail;
|
||||
const QueueFailure({
|
||||
required this.reason,
|
||||
required this.domain,
|
||||
this.title,
|
||||
this.detail,
|
||||
});
|
||||
|
||||
String get message {
|
||||
final label = title?.isNotEmpty == true ? '"$title"' : 'an offline edit';
|
||||
return switch (reason) {
|
||||
QueueFailureReason.overwritten =>
|
||||
'$label was overwritten by a newer change on the server.',
|
||||
QueueFailureReason.missing =>
|
||||
'$label was deleted on the server before your offline edit could be saved.',
|
||||
QueueFailureReason.rejected =>
|
||||
'Failed to save $label: ${detail ?? 'rejected by server.'}',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Drains the offline write queue (Phase 3). Owns the network → server
|
||||
/// dispatch logic for every queued op; repos write to `pending_writes`,
|
||||
/// this service replays them.
|
||||
///
|
||||
/// Replay semantics:
|
||||
/// - oldest-first, single in-flight `replay()` call
|
||||
/// - on `NetworkException` (or 5xx): leave op in place, stop the loop
|
||||
/// - on conflict (server `updated_at` newer than op baseline): drop +
|
||||
/// surface as `overwritten`
|
||||
/// - on 404: for delete → silent success; for update → drop + `missing`
|
||||
/// - on other 4xx: drop + `rejected`
|
||||
class WriteQueue {
|
||||
final FabledDatabase _db;
|
||||
final NotesApi _notesApi;
|
||||
final TasksApi _tasksApi;
|
||||
final ProjectsApi _projectsApi;
|
||||
final MilestonesApi _milestonesApi;
|
||||
final EventsApi _eventsApi;
|
||||
|
||||
final StreamController<QueueFailure> _failures =
|
||||
StreamController<QueueFailure>.broadcast();
|
||||
bool _replaying = false;
|
||||
|
||||
WriteQueue({
|
||||
required FabledDatabase db,
|
||||
required NotesApi notesApi,
|
||||
required TasksApi tasksApi,
|
||||
required ProjectsApi projectsApi,
|
||||
required MilestonesApi milestonesApi,
|
||||
required EventsApi eventsApi,
|
||||
}) : _db = db,
|
||||
_notesApi = notesApi,
|
||||
_tasksApi = tasksApi,
|
||||
_projectsApi = projectsApi,
|
||||
_milestonesApi = milestonesApi,
|
||||
_eventsApi = eventsApi;
|
||||
|
||||
Stream<QueueFailure> get failures => _failures.stream;
|
||||
|
||||
void dispose() => _failures.close();
|
||||
|
||||
/// Drain the queue. Reentrant calls are no-ops while a replay is in
|
||||
/// flight — the in-flight one will see new entries on its next iteration.
|
||||
Future<void> replay() async {
|
||||
if (_replaying) return;
|
||||
_replaying = true;
|
||||
try {
|
||||
while (true) {
|
||||
final ops = await _db.listPending();
|
||||
if (ops.isEmpty) break;
|
||||
final op = ops.first;
|
||||
final keepGoing = await _replayOne(op);
|
||||
if (!keepGoing) break;
|
||||
}
|
||||
} finally {
|
||||
_replaying = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _replayOne(PendingWrite op) async {
|
||||
final payload = jsonDecode(op.payloadJson) as Map<String, dynamic>;
|
||||
await _db.incrementPendingTries(op.id);
|
||||
try {
|
||||
switch (op.domain) {
|
||||
case kSyncDomainNotes:
|
||||
await _replayNote(op, payload);
|
||||
case kSyncDomainTasks:
|
||||
await _replayTask(op, payload);
|
||||
case kSyncDomainProjects:
|
||||
await _replayProject(op, payload);
|
||||
case kSyncDomainMilestones:
|
||||
await _replayMilestone(op, payload);
|
||||
case kSyncDomainEvents:
|
||||
await _replayEvent(op, payload);
|
||||
default:
|
||||
// Unknown domain — drop so we don't loop forever.
|
||||
await _db.deletePending(op.id);
|
||||
return true;
|
||||
}
|
||||
await _db.deletePending(op.id);
|
||||
return true;
|
||||
} on NetworkException catch (e) {
|
||||
// Still offline — leave op for next replay trigger.
|
||||
await _db.markPendingFailed(op.id, e.message);
|
||||
return false;
|
||||
} on ServerException catch (e) {
|
||||
if (e.statusCode >= 500) {
|
||||
// Likely transient — keep + stop the loop, retry next time.
|
||||
await _db.markPendingFailed(op.id, e.message);
|
||||
return false;
|
||||
}
|
||||
// 4xx — non-retryable. Drop + surface.
|
||||
await _db.deletePending(op.id);
|
||||
_failures.add(QueueFailure(
|
||||
reason: QueueFailureReason.rejected,
|
||||
domain: op.domain,
|
||||
title: payload['title'] as String?,
|
||||
detail: e.message,
|
||||
));
|
||||
return true;
|
||||
} on NotFoundException {
|
||||
// Target gone server-side. Update → tell user; delete → silent success.
|
||||
await _db.deletePending(op.id);
|
||||
if (op.verb != kWriteVerbDelete) {
|
||||
_failures.add(QueueFailure(
|
||||
reason: QueueFailureReason.missing,
|
||||
domain: op.domain,
|
||||
title: payload['title'] as String?,
|
||||
));
|
||||
}
|
||||
// Clean the cache so the optimistic row doesn't linger.
|
||||
await _evictCache(op.domain, op.targetId ?? op.tempId);
|
||||
return true;
|
||||
} on _OverwrittenSignal catch (s) {
|
||||
await _db.deletePending(op.id);
|
||||
_failures.add(QueueFailure(
|
||||
reason: QueueFailureReason.overwritten,
|
||||
domain: op.domain,
|
||||
title: s.title,
|
||||
));
|
||||
return true;
|
||||
} on AppException catch (e) {
|
||||
// Auth or other — surface and drop so the queue can drain.
|
||||
await _db.deletePending(op.id);
|
||||
_failures.add(QueueFailure(
|
||||
reason: QueueFailureReason.rejected,
|
||||
domain: op.domain,
|
||||
title: payload['title'] as String?,
|
||||
detail: e.message,
|
||||
));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _evictCache(String domain, int? id) async {
|
||||
if (id == null) return;
|
||||
switch (domain) {
|
||||
case kSyncDomainNotes:
|
||||
await _db.deleteNote(id);
|
||||
case kSyncDomainTasks:
|
||||
await _db.deleteTask(id);
|
||||
case kSyncDomainProjects:
|
||||
await _db.deleteProject(id);
|
||||
case kSyncDomainMilestones:
|
||||
await _db.deleteMilestone(id);
|
||||
case kSyncDomainEvents:
|
||||
await _db.deleteEvent(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _replayNote(PendingWrite op, Map<String, dynamic> p) async {
|
||||
switch (op.verb) {
|
||||
case kWriteVerbCreate:
|
||||
final note = await _notesApi.create(
|
||||
p['title'] as String? ?? '',
|
||||
p['body'] as String? ?? '',
|
||||
tags: _stringList(p['tags']),
|
||||
projectId: p['project_id'] as int?,
|
||||
noteType: p['note_type'] as String? ?? 'note',
|
||||
);
|
||||
if (op.tempId != null) await _db.deleteNote(op.tempId!);
|
||||
await _db.upsertNote(note);
|
||||
case kWriteVerbUpdate:
|
||||
await _conflictGuard<Note>(
|
||||
baseline: op.baselineUpdatedAt,
|
||||
fetch: () => _notesApi.getOne(op.targetId!),
|
||||
updatedAt: (n) => n.updatedAt,
|
||||
title: (n) => n.title,
|
||||
syncCache: (n) => _db.upsertNote(n),
|
||||
);
|
||||
final note = await _notesApi.update(
|
||||
op.targetId!,
|
||||
p['title'] as String? ?? '',
|
||||
p['body'] as String? ?? '',
|
||||
tags: _stringList(p['tags']),
|
||||
projectId: p['project_id'] as int?,
|
||||
clearProject: p['clear_project'] as bool? ?? false,
|
||||
noteType: p['note_type'] as String? ?? 'note',
|
||||
);
|
||||
await _db.upsertNote(note);
|
||||
case kWriteVerbDelete:
|
||||
try {
|
||||
await _notesApi.delete(op.targetId!);
|
||||
} on NotFoundException {/* already gone */}
|
||||
await _db.deleteNote(op.targetId!);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tasks ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _replayTask(PendingWrite op, Map<String, dynamic> p) async {
|
||||
switch (op.verb) {
|
||||
case kWriteVerbCreate:
|
||||
final task = await _tasksApi.create(
|
||||
title: p['title'] as String? ?? '',
|
||||
description: p['description'] as String?,
|
||||
status: TaskStatusExtension.fromString(p['status'] as String?),
|
||||
priority: TaskPriorityExtension.fromString(p['priority'] as String?),
|
||||
dueDate: _parseDate(p['due_date']),
|
||||
projectId: p['project_id'] as int?,
|
||||
parentId: p['parent_id'] as int?,
|
||||
);
|
||||
if (op.tempId != null) await _db.deleteTask(op.tempId!);
|
||||
await _db.upsertTask(task);
|
||||
case kWriteVerbUpdate:
|
||||
await _conflictGuard<Task>(
|
||||
baseline: op.baselineUpdatedAt,
|
||||
fetch: () => _tasksApi.getOne(op.targetId!),
|
||||
updatedAt: (t) => t.updatedAt,
|
||||
title: (t) => t.title,
|
||||
syncCache: (t) => _db.upsertTask(t),
|
||||
);
|
||||
final task = await _tasksApi.update(op.targetId!, p);
|
||||
await _db.upsertTask(task);
|
||||
case kWriteVerbDelete:
|
||||
try {
|
||||
await _tasksApi.delete(op.targetId!);
|
||||
} on NotFoundException {/* already gone */}
|
||||
await _db.deleteTask(op.targetId!);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Projects ───────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _replayProject(PendingWrite op, Map<String, dynamic> p) async {
|
||||
switch (op.verb) {
|
||||
case kWriteVerbCreate:
|
||||
final project = await _projectsApi.create(
|
||||
title: p['title'] as String? ?? '',
|
||||
description: p['description'] as String?,
|
||||
goal: p['goal'] as String?,
|
||||
color: p['color'] as String?,
|
||||
status: p['status'] as String? ?? 'active',
|
||||
);
|
||||
if (op.tempId != null) await _db.deleteProject(op.tempId!);
|
||||
await _db.upsertProject(project);
|
||||
case kWriteVerbUpdate:
|
||||
await _conflictGuard<Project>(
|
||||
baseline: op.baselineUpdatedAt,
|
||||
fetch: () => _projectsApi.getOne(op.targetId!),
|
||||
updatedAt: (proj) => proj.updatedAt,
|
||||
title: (proj) => proj.title,
|
||||
syncCache: (proj) => _db.upsertProject(proj),
|
||||
);
|
||||
final project = await _projectsApi.update(op.targetId!, p);
|
||||
await _db.upsertProject(project);
|
||||
case kWriteVerbDelete:
|
||||
try {
|
||||
await _projectsApi.delete(op.targetId!);
|
||||
} on NotFoundException {/* already gone */}
|
||||
await _db.deleteProject(op.targetId!);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Milestones ─────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _replayMilestone(
|
||||
PendingWrite op, Map<String, dynamic> p) async {
|
||||
final projectId = p['project_id'] as int;
|
||||
switch (op.verb) {
|
||||
case kWriteVerbCreate:
|
||||
final milestone = await _milestonesApi.create(
|
||||
projectId,
|
||||
title: p['title'] as String? ?? '',
|
||||
description: p['description'] as String?,
|
||||
orderIndex: p['order_index'] as int? ?? 0,
|
||||
);
|
||||
if (op.tempId != null) await _db.deleteMilestone(op.tempId!);
|
||||
await _db.upsertMilestone(milestone);
|
||||
case kWriteVerbUpdate:
|
||||
// Milestones API has no getOne — skip the conflict pre-check and
|
||||
// let the PUT apply unconditionally. If two clients fight over the
|
||||
// same milestone, last-writer-wins is acceptable here.
|
||||
final milestone = await _milestonesApi.update(
|
||||
projectId,
|
||||
op.targetId!,
|
||||
Map<String, dynamic>.from(p)..remove('project_id'),
|
||||
);
|
||||
await _db.upsertMilestone(milestone);
|
||||
case kWriteVerbDelete:
|
||||
try {
|
||||
await _milestonesApi.delete(projectId, op.targetId!);
|
||||
} on NotFoundException {/* already gone */}
|
||||
await _db.deleteMilestone(op.targetId!);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _replayEvent(PendingWrite op, Map<String, dynamic> p) async {
|
||||
switch (op.verb) {
|
||||
case kWriteVerbCreate:
|
||||
final event = await _eventsApi.createEvent(p);
|
||||
if (op.tempId != null) await _db.deleteEvent(op.tempId!);
|
||||
await _db.upsertEvent(event);
|
||||
case kWriteVerbUpdate:
|
||||
// Events API also has no getOne — skip the pre-check; last-writer-wins.
|
||||
final event = await _eventsApi.updateEvent(op.targetId!, p);
|
||||
await _db.upsertEvent(event);
|
||||
case kWriteVerbDelete:
|
||||
try {
|
||||
await _eventsApi.deleteEvent(op.targetId!);
|
||||
} on NotFoundException {/* already gone */}
|
||||
await _db.deleteEvent(op.targetId!);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Server-wins conflict guard. Fetch the target, compare its `updated_at`
|
||||
/// against the baseline captured when the user made the offline edit;
|
||||
/// if the server is newer, sync cache and signal the caller to drop.
|
||||
Future<void> _conflictGuard<T>({
|
||||
required DateTime? baseline,
|
||||
required Future<T> Function() fetch,
|
||||
required DateTime Function(T) updatedAt,
|
||||
required String Function(T) title,
|
||||
required Future<void> Function(T) syncCache,
|
||||
}) async {
|
||||
if (baseline == null) return;
|
||||
final server = await fetch();
|
||||
if (updatedAt(server).isAfter(baseline)) {
|
||||
await syncCache(server);
|
||||
throw _OverwrittenSignal(title(server));
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _stringList(dynamic raw) =>
|
||||
raw is List ? raw.map((e) => e.toString()).toList() : const [];
|
||||
|
||||
DateTime? _parseDate(dynamic raw) {
|
||||
if (raw is String && raw.isNotEmpty) return DateTime.tryParse(raw);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _OverwrittenSignal implements Exception {
|
||||
final String title;
|
||||
const _OverwrittenSignal(this.title);
|
||||
}
|
||||
@@ -15,14 +15,17 @@ import '../data/api/projects_api.dart';
|
||||
import '../data/api/quick_capture_api.dart';
|
||||
import '../data/api/settings_api.dart';
|
||||
import '../data/api/tasks_api.dart';
|
||||
import '../data/local/database.dart';
|
||||
import '../data/repositories/auth_repository.dart';
|
||||
import '../data/repositories/chat_repository.dart';
|
||||
import '../data/repositories/events_repository.dart';
|
||||
import '../data/repositories/knowledge_repository.dart';
|
||||
import '../data/repositories/voice_repository.dart';
|
||||
import '../data/repositories/milestones_repository.dart';
|
||||
import '../data/repositories/notes_repository.dart';
|
||||
import '../data/repositories/projects_repository.dart';
|
||||
import '../data/repositories/tasks_repository.dart';
|
||||
import '../data/repositories/write_queue.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
final cookieJarProvider = Provider<PersistCookieJar>((ref) {
|
||||
@@ -60,24 +63,45 @@ final projectsApiProvider = Provider<ProjectsApi>((ref) {
|
||||
return ProjectsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
/// Local SQLite cache used by repositories for read-through caching and
|
||||
/// offline fallback. Backed by Drift; lives for the lifetime of the
|
||||
/// ProviderScope and is closed automatically when the scope disposes.
|
||||
final fabledDatabaseProvider = Provider<FabledDatabase>((ref) {
|
||||
final db = FabledDatabase();
|
||||
ref.onDispose(db.close);
|
||||
return db;
|
||||
});
|
||||
|
||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||
return AuthRepository(ref.watch(authApiProvider));
|
||||
});
|
||||
|
||||
final notesRepositoryProvider = Provider<NotesRepository>((ref) {
|
||||
return NotesRepository(ref.watch(notesApiProvider));
|
||||
return NotesRepository(
|
||||
ref.watch(notesApiProvider),
|
||||
ref.watch(fabledDatabaseProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
|
||||
return TasksRepository(ref.watch(tasksApiProvider));
|
||||
return TasksRepository(
|
||||
ref.watch(tasksApiProvider),
|
||||
ref.watch(fabledDatabaseProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
||||
return ChatRepository(ref.watch(chatApiProvider));
|
||||
return ChatRepository(
|
||||
ref.watch(chatApiProvider),
|
||||
ref.watch(fabledDatabaseProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) {
|
||||
return ProjectsRepository(ref.watch(projectsApiProvider));
|
||||
return ProjectsRepository(
|
||||
ref.watch(projectsApiProvider),
|
||||
ref.watch(fabledDatabaseProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final milestonesApiProvider = Provider<MilestonesApi>((ref) {
|
||||
@@ -85,7 +109,10 @@ final milestonesApiProvider = Provider<MilestonesApi>((ref) {
|
||||
});
|
||||
|
||||
final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
|
||||
return MilestonesRepository(ref.watch(milestonesApiProvider));
|
||||
return MilestonesRepository(
|
||||
ref.watch(milestonesApiProvider),
|
||||
ref.watch(fabledDatabaseProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final knowledgeApiProvider = Provider<KnowledgeApi>((ref) {
|
||||
@@ -115,3 +142,51 @@ final voiceRepositoryProvider = Provider<VoiceRepository>((ref) {
|
||||
final eventsApiProvider = Provider<EventsApi>((ref) {
|
||||
return EventsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final eventsRepositoryProvider = Provider<EventsRepository>((ref) {
|
||||
return EventsRepository(
|
||||
ref.watch(eventsApiProvider),
|
||||
ref.watch(fabledDatabaseProvider),
|
||||
);
|
||||
});
|
||||
|
||||
/// Phase 3 — drains the offline write queue. Listen on
|
||||
/// `writeQueueFailuresProvider` to surface dropped ops to the user;
|
||||
/// `writeQueueDepthProvider` powers the OfflineBanner's "Retry (N)" affordance.
|
||||
final writeQueueProvider = Provider<WriteQueue>((ref) {
|
||||
final queue = WriteQueue(
|
||||
db: ref.watch(fabledDatabaseProvider),
|
||||
notesApi: ref.watch(notesApiProvider),
|
||||
tasksApi: ref.watch(tasksApiProvider),
|
||||
projectsApi: ref.watch(projectsApiProvider),
|
||||
milestonesApi: ref.watch(milestonesApiProvider),
|
||||
eventsApi: ref.watch(eventsApiProvider),
|
||||
);
|
||||
ref.onDispose(queue.dispose);
|
||||
return queue;
|
||||
});
|
||||
|
||||
final writeQueueDepthProvider = StreamProvider<int>((ref) {
|
||||
return ref.watch(fabledDatabaseProvider).watchQueueDepth();
|
||||
});
|
||||
|
||||
final writeQueueFailuresProvider = StreamProvider<QueueFailure>((ref) {
|
||||
return ref.watch(writeQueueProvider).failures;
|
||||
});
|
||||
|
||||
/// Per-domain set of ids with a queued write (target_id ∪ temp_id). Phase 4
|
||||
/// — used by `PendingSyncBadge` in list views to mark rows that are still
|
||||
/// in flight.
|
||||
final pendingNoteIdsProvider = StreamProvider<Set<int>>((ref) {
|
||||
return ref.watch(fabledDatabaseProvider).watchPendingIds(kSyncDomainNotes);
|
||||
});
|
||||
|
||||
final pendingTaskIdsProvider = StreamProvider<Set<int>>((ref) {
|
||||
return ref.watch(fabledDatabaseProvider).watchPendingIds(kSyncDomainTasks);
|
||||
});
|
||||
|
||||
final pendingProjectIdsProvider = StreamProvider<Set<int>>((ref) {
|
||||
return ref
|
||||
.watch(fabledDatabaseProvider)
|
||||
.watchPendingIds(kSyncDomainProjects);
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ class CalendarNotifier extends AsyncNotifier<CalendarState> {
|
||||
// Fetch current month ± 1 month as the initial window.
|
||||
final from = DateTime(now.year, now.month - 1, 1);
|
||||
final to = DateTime(now.year, now.month + 2, 0, 23, 59, 59);
|
||||
final events = await ref.watch(eventsApiProvider).getEvents(from, to);
|
||||
final events = await ref.watch(eventsRepositoryProvider).getEvents(from, to);
|
||||
return CalendarState(
|
||||
eventsByDay: _groupByDay(events),
|
||||
selectedDay: today,
|
||||
@@ -60,7 +60,7 @@ class CalendarNotifier extends AsyncNotifier<CalendarState> {
|
||||
Future<void> refresh() async {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final events = await ref.read(eventsApiProvider).getEvents(
|
||||
final events = await ref.read(eventsRepositoryProvider).getEvents(
|
||||
current.loadedRange.start,
|
||||
current.loadedRange.end,
|
||||
);
|
||||
@@ -93,7 +93,7 @@ class CalendarNotifier extends AsyncNotifier<CalendarState> {
|
||||
try {
|
||||
final from = DateTime(month.year, month.month, 1);
|
||||
final to = DateTime(month.year, month.month + 1, 0, 23, 59, 59);
|
||||
final events = await ref.read(eventsApiProvider).getEvents(from, to);
|
||||
final events = await ref.read(eventsRepositoryProvider).getEvents(from, to);
|
||||
final s = state.value!;
|
||||
final merged = Map<DateTime, List<CalendarEvent>>.from(s.eventsByDay);
|
||||
for (final e in events) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io' as io;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -119,7 +120,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
const SizedBox(height: 32),
|
||||
if (_oauthEnabled) ...[
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.login),
|
||||
icon: const Icon(LucideIcons.logIn),
|
||||
label: const Text('Sign in with SSO'),
|
||||
onPressed: _openOAuth,
|
||||
),
|
||||
@@ -155,8 +156,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_obscure
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off),
|
||||
? LucideIcons.eye
|
||||
: LucideIcons.eyeOff),
|
||||
onPressed: () =>
|
||||
setState(() => _obscure = !_obscure),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
|
||||
@@ -91,7 +92,7 @@ class CalendarScreen extends ConsumerWidget {
|
||||
notifier: notifier,
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.add),
|
||||
child: const Icon(LucideIcons.plus),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/theme.dart';
|
||||
import '../../data/models/calendar_event.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/calendar_provider.dart';
|
||||
@@ -118,12 +120,12 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
||||
'color': _color,
|
||||
'recurrence': rrule,
|
||||
};
|
||||
final api = ref.read(eventsApiProvider);
|
||||
final repo = ref.read(eventsRepositoryProvider);
|
||||
if (_isCreate) {
|
||||
final created = await api.createEvent(payload);
|
||||
final created = await repo.createEvent(payload);
|
||||
widget.notifier.addEvent(created);
|
||||
} else {
|
||||
final updated = await api.updateEvent(widget.event!.id, payload);
|
||||
final updated = await repo.updateEvent(widget.event!.id, payload);
|
||||
widget.notifier.updateEvent(updated);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
@@ -152,11 +154,10 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: Text(
|
||||
'Delete',
|
||||
style: TextStyle(
|
||||
color: Theme.of(dialogContext).colorScheme.error),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
|
||||
),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -164,7 +165,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
||||
if (confirmed != true || !mounted) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await ref.read(eventsApiProvider).deleteEvent(widget.event!.id);
|
||||
await ref.read(eventsRepositoryProvider).deleteEvent(widget.event!.id);
|
||||
widget.notifier.removeEvent(widget.event!.id, widget.event!.startDt);
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (_) {
|
||||
@@ -273,7 +274,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
||||
const Spacer(),
|
||||
if (!_isCreate)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
onPressed: _saving ? null : _delete,
|
||||
),
|
||||
@@ -303,7 +304,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
||||
// Start date
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.calendar_today_outlined),
|
||||
leading: const Icon(LucideIcons.calendarDays),
|
||||
title: Text(_fmtDate(_startDt)),
|
||||
subtitle: const Text('Start date'),
|
||||
onTap: _pickStartDate,
|
||||
@@ -313,7 +314,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
||||
if (!_allDay)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.access_time_outlined),
|
||||
leading: const Icon(LucideIcons.clock),
|
||||
title: Text(_fmtTime(_startDt)),
|
||||
subtitle: const Text('Start time'),
|
||||
onTap: _pickStartTime,
|
||||
@@ -322,14 +323,14 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
||||
// End date
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.event_outlined),
|
||||
leading: const Icon(LucideIcons.calendarCheck),
|
||||
title: Text(
|
||||
_endDt != null ? _fmtDate(_endDt!) : 'No end date'),
|
||||
subtitle: const Text('End date'),
|
||||
onTap: _pickEndDate,
|
||||
trailing: _endDt != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () => setState(() => _endDt = null),
|
||||
)
|
||||
: null,
|
||||
@@ -339,7 +340,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
||||
if (!_allDay && _endDt != null)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.access_time_outlined),
|
||||
leading: const Icon(LucideIcons.clock),
|
||||
title: Text(_fmtTime(_endDt!)),
|
||||
subtitle: const Text('End time'),
|
||||
onTap: _pickEndTime,
|
||||
@@ -351,14 +352,14 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
||||
if (_isCustomRrule)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.repeat_outlined),
|
||||
leading: const Icon(LucideIcons.repeat),
|
||||
title: const Text('Custom (read-only)'),
|
||||
subtitle: Text(widget.event!.recurrence ?? ''),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.repeat_outlined),
|
||||
const Icon(LucideIcons.repeat),
|
||||
const SizedBox(width: 16),
|
||||
DropdownButton<String?>(
|
||||
value: _recurrence,
|
||||
@@ -419,7 +420,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
||||
width: _color.isEmpty ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.block, size: 16),
|
||||
child: const Icon(LucideIcons.ban, size: 16),
|
||||
),
|
||||
),
|
||||
..._presetColors.map((hex) {
|
||||
@@ -451,10 +452,13 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Save button
|
||||
// Save button — Moss action-primary per Hybrid rule
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).extension<ActionColors>()!.primary,
|
||||
),
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/exceptions.dart';
|
||||
@@ -173,7 +174,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
: const Icon(LucideIcons.refreshCw),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -280,7 +281,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send),
|
||||
: const Icon(LucideIcons.send),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import 'chat_screen.dart';
|
||||
|
||||
@@ -51,6 +53,9 @@ class _ConversationsTabScreenState
|
||||
child: const Text('Cancel')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
|
||||
),
|
||||
child: const Text('Delete')),
|
||||
],
|
||||
),
|
||||
@@ -74,7 +79,7 @@ class _ConversationsTabScreenState
|
||||
title: Text('Chat', style: theme.textTheme.titleLarge),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
tooltip: 'New conversation',
|
||||
onPressed: _createConversation,
|
||||
),
|
||||
@@ -89,14 +94,14 @@ class _ConversationsTabScreenState
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline,
|
||||
Icon(LucideIcons.messageCircle,
|
||||
size: 48, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text('No conversations yet',
|
||||
style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: const Text('Start a conversation'),
|
||||
onPressed: _createConversation,
|
||||
),
|
||||
@@ -113,7 +118,7 @@ class _ConversationsTabScreenState
|
||||
final c = convs[i];
|
||||
final selected = isWide && c.id == _selectedConvId;
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.chat_bubble_outline),
|
||||
leading: const Icon(LucideIcons.messageCircle),
|
||||
title: Text(
|
||||
c.title.isEmpty ? 'New conversation' : c.title,
|
||||
maxLines: 1,
|
||||
@@ -124,7 +129,7 @@ class _ConversationsTabScreenState
|
||||
),
|
||||
selected: selected,
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
onPressed: () => _confirmDelete(c.id, c.title),
|
||||
),
|
||||
onTap: () => _openConversation(c.id),
|
||||
@@ -155,7 +160,7 @@ class _ConversationsTabScreenState
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline,
|
||||
Icon(LucideIcons.messageCircle,
|
||||
size: 48,
|
||||
color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../data/models/journal_day.dart';
|
||||
@@ -27,9 +28,9 @@ class JournalHistoryScreen extends ConsumerWidget {
|
||||
itemBuilder: (context, i) {
|
||||
final isoDate = days[i];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.menu_book_outlined),
|
||||
leading: const Icon(LucideIcons.bookOpen),
|
||||
title: Text(isoDate),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
trailing: const Icon(LucideIcons.chevronRight),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => _JournalDayDetailScreen(isoDate: isoDate),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/exceptions.dart';
|
||||
@@ -9,7 +10,6 @@ import '../../providers/journal_provider.dart';
|
||||
import '../../providers/voice_provider.dart';
|
||||
import '../../widgets/chat_message_bubble.dart';
|
||||
import '../../widgets/voice_mic_button.dart';
|
||||
import '../../widgets/weather_card.dart';
|
||||
import 'journal_history_screen.dart';
|
||||
|
||||
class JournalScreen extends ConsumerStatefulWidget {
|
||||
@@ -188,7 +188,7 @@ class _JournalScreenState extends ConsumerState<JournalScreen>
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh_outlined),
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
tooltip: 'Regenerate prep',
|
||||
onPressed: _refresh,
|
||||
),
|
||||
@@ -365,9 +365,10 @@ class _JournalScreenState extends ConsumerState<JournalScreen>
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a single journal message. For the daily-prep assistant message,
|
||||
/// also renders a WeatherCard above the bubble (weather lives in the
|
||||
/// nested metadata.sections.weather payload).
|
||||
/// Renders a single journal message. The daily-prep prose itself already
|
||||
/// covers weather ("Weather at home will reach a high of 15.9° ..."), so
|
||||
/// the journal screen leaves rendering to the message bubble — no separate
|
||||
/// weather card on top.
|
||||
class _JournalMessageItem extends StatelessWidget {
|
||||
final Message message;
|
||||
|
||||
@@ -375,33 +376,7 @@ class _JournalMessageItem extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final meta = message.metadata;
|
||||
final isAssistant = message.role == MessageRole.assistant;
|
||||
final isPrep = isAssistant &&
|
||||
meta != null &&
|
||||
meta['kind'] == 'daily_prep';
|
||||
|
||||
Map<String, dynamic>? weatherData;
|
||||
if (isPrep) {
|
||||
// The journal prep stores its structured data under metadata.sections.
|
||||
final sections = meta['sections'] as Map<String, dynamic>?;
|
||||
final weather = sections?['weather'];
|
||||
if (weather is List && weather.isNotEmpty) {
|
||||
// Show the first location's weather card. The widget expects a
|
||||
// single location dict; we pass the first one through.
|
||||
weatherData = weather.first as Map<String, dynamic>?;
|
||||
} else if (weather is Map) {
|
||||
weatherData = weather as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (weatherData != null) WeatherCard(weather: weatherData),
|
||||
ChatMessageBubble(message: message),
|
||||
],
|
||||
);
|
||||
return ChatMessageBubble(message: message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +401,7 @@ class _GradientSendButton extends StatelessWidget {
|
||||
: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)],
|
||||
colors: [Color(0xFF5B4A8A), Color(0xFF3F3560)],
|
||||
),
|
||||
color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
@@ -442,7 +417,7 @@ class _GradientSendButton extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.send,
|
||||
LucideIcons.send,
|
||||
color: disabled
|
||||
? scheme.onSurface.withValues(alpha: 0.38)
|
||||
: Colors.white,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -95,7 +96,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
||||
: const Text('Knowledge'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_searchActive ? Icons.close : Icons.search),
|
||||
icon: Icon(_searchActive ? LucideIcons.x : LucideIcons.search),
|
||||
onPressed: () {
|
||||
setState(() => _searchActive = !_searchActive);
|
||||
if (!_searchActive) {
|
||||
@@ -149,7 +150,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _onFabTapped(context),
|
||||
tooltip: 'New',
|
||||
child: const Icon(Icons.edit_outlined),
|
||||
child: const Icon(LucideIcons.pencil),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -259,7 +260,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_TypePickerRow(
|
||||
icon: Icons.description_outlined,
|
||||
icon: LucideIcons.fileText,
|
||||
label: 'Note',
|
||||
description: 'General note or document',
|
||||
onTap: () {
|
||||
@@ -268,7 +269,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
||||
},
|
||||
),
|
||||
_TypePickerRow(
|
||||
icon: Icons.person_outlined,
|
||||
icon: LucideIcons.user,
|
||||
label: 'Person',
|
||||
description: 'Contact, colleague, or reference person',
|
||||
onTap: () {
|
||||
@@ -277,7 +278,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
||||
},
|
||||
),
|
||||
_TypePickerRow(
|
||||
icon: Icons.place_outlined,
|
||||
icon: LucideIcons.mapPin,
|
||||
label: 'Place',
|
||||
description: 'Location, venue, or place of interest',
|
||||
onTap: () {
|
||||
@@ -286,7 +287,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
||||
},
|
||||
),
|
||||
_TypePickerRow(
|
||||
icon: Icons.checklist_outlined,
|
||||
icon: LucideIcons.listChecks,
|
||||
label: 'List',
|
||||
description: 'Checklist or structured list',
|
||||
onTap: () {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../data/local/database.dart';
|
||||
import '../../data/models/milestone.dart';
|
||||
import '../../data/models/project.dart';
|
||||
import '../../data/models/task.dart';
|
||||
@@ -10,6 +12,7 @@ import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/milestones_provider.dart';
|
||||
import '../../providers/projects_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
import '../../widgets/pending_sync_badge.dart';
|
||||
|
||||
class ProjectTasksScreen extends ConsumerStatefulWidget {
|
||||
final int projectId;
|
||||
@@ -55,11 +58,11 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||
}
|
||||
|
||||
Color _parseColor(String? hex) {
|
||||
if (hex == null || hex.isEmpty) return const Color(0xFF7C3AED);
|
||||
if (hex == null || hex.isEmpty) return const Color(0xFF5B4A8A);
|
||||
try {
|
||||
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
|
||||
} catch (_) {
|
||||
return const Color(0xFF7C3AED);
|
||||
return const Color(0xFF5B4A8A);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +99,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
icon: const Icon(LucideIcons.pencil),
|
||||
tooltip: 'Edit project',
|
||||
onPressed: () =>
|
||||
context.push('/projects/${widget.projectId}/edit'),
|
||||
@@ -133,7 +136,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_box_outlined,
|
||||
Icon(LucideIcons.checkSquare,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 12),
|
||||
@@ -302,9 +305,9 @@ class _TaskRow extends StatelessWidget {
|
||||
});
|
||||
|
||||
IconData get _statusIcon => switch (effectiveStatus) {
|
||||
TaskStatus.done => Icons.check_circle,
|
||||
TaskStatus.inProgress => Icons.timelapse,
|
||||
TaskStatus.todo => Icons.radio_button_unchecked,
|
||||
TaskStatus.done => LucideIcons.checkCircle2,
|
||||
TaskStatus.inProgress => LucideIcons.loader,
|
||||
TaskStatus.todo => LucideIcons.circle,
|
||||
};
|
||||
|
||||
Color _statusColor(BuildContext context) {
|
||||
@@ -345,18 +348,28 @@ class _TaskRow extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
task.title.isNotEmpty ? task.title : 'Untitled',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
decoration: effectiveStatus == TaskStatus.done
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
color: effectiveStatus == TaskStatus.done
|
||||
? theme.colorScheme.onSurfaceVariant
|
||||
: null,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
task.title.isNotEmpty ? task.title : 'Untitled',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
decoration: effectiveStatus == TaskStatus.done
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
color: effectiveStatus == TaskStatus.done
|
||||
? theme.colorScheme.onSurfaceVariant
|
||||
: null,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
PendingSyncBadge(
|
||||
domain: kSyncDomainTasks,
|
||||
id: task.id,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (task.dueDate != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../core/wikilink_syntax.dart';
|
||||
import '../../providers/notes_provider.dart';
|
||||
|
||||
@@ -44,13 +46,13 @@ class NoteDetailScreen extends ConsumerWidget {
|
||||
data: (note) => Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
icon: const Icon(LucideIcons.pencil),
|
||||
onPressed: () => context.push(
|
||||
Routes.noteEdit.replaceFirst(':id', '$noteId'),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
onPressed: () async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -65,6 +67,11 @@ class NoteDetailScreen extends ConsumerWidget {
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(dialogContext)
|
||||
.extension<ActionColors>()!
|
||||
.destructive,
|
||||
),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../core/wikilink_syntax.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/notes_provider.dart';
|
||||
@@ -85,6 +87,9 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
|
||||
),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
@@ -163,12 +168,12 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
actions: [
|
||||
if (widget.noteId != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
tooltip: 'Delete',
|
||||
onPressed: _delete,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(_preview ? Icons.edit : Icons.preview),
|
||||
icon: Icon(_preview ? LucideIcons.pencil : LucideIcons.eye),
|
||||
tooltip: _preview ? 'Edit' : 'Preview',
|
||||
onPressed: () => setState(() => _preview = !_preview),
|
||||
),
|
||||
@@ -179,7 +184,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.check),
|
||||
: const Icon(LucideIcons.check),
|
||||
onPressed: _saving ? null : _save,
|
||||
),
|
||||
],
|
||||
@@ -272,7 +277,7 @@ class _TagInput extends StatelessWidget {
|
||||
label: Text('#$tag', style: const TextStyle(fontSize: 12)),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
deleteIcon: const Icon(Icons.close, size: 14),
|
||||
deleteIcon: const Icon(LucideIcons.x, size: 14),
|
||||
onDeleted: () => onRemove(tag),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -105,7 +106,7 @@ class _ProjectEditScreenState extends ConsumerState<ProjectEditScreen> {
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.check),
|
||||
: const Icon(LucideIcons.check),
|
||||
onPressed: _saving ? null : _save,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../data/local/database.dart';
|
||||
import '../../data/models/project.dart';
|
||||
import '../../providers/projects_provider.dart';
|
||||
import '../../widgets/pending_sync_badge.dart';
|
||||
|
||||
class ProjectsScreen extends ConsumerWidget {
|
||||
const ProjectsScreen({super.key});
|
||||
@@ -30,7 +33,7 @@ class ProjectsScreen extends ConsumerWidget {
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => context.push('/projects/new'),
|
||||
tooltip: 'New project',
|
||||
child: const Icon(Icons.add),
|
||||
child: const Icon(LucideIcons.plus),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -49,7 +52,12 @@ class _ProjectCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(project.title),
|
||||
title: Row(
|
||||
children: [
|
||||
Flexible(child: Text(project.title)),
|
||||
PendingSyncBadge(domain: kSyncDomainProjects, id: project.id),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -24,28 +25,28 @@ class SettingsScreen extends ConsumerWidget {
|
||||
ListTile(
|
||||
title: const Text('Server URL'),
|
||||
subtitle: Text(serverUrl ?? 'Not configured'),
|
||||
leading: const Icon(Icons.dns),
|
||||
leading: const Icon(LucideIcons.server),
|
||||
onTap: () => context.go(Routes.setup),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: const Text('Appearance'),
|
||||
leading: const Icon(Icons.brightness_6),
|
||||
leading: const Icon(LucideIcons.sunMoon),
|
||||
trailing: SegmentedButton<ThemeMode>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: ThemeMode.system,
|
||||
icon: Icon(Icons.brightness_auto),
|
||||
icon: Icon(LucideIcons.contrast),
|
||||
tooltip: 'System',
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.light,
|
||||
icon: Icon(Icons.light_mode),
|
||||
icon: Icon(LucideIcons.sun),
|
||||
tooltip: 'Light',
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.dark,
|
||||
icon: Icon(Icons.dark_mode),
|
||||
icon: Icon(LucideIcons.moon),
|
||||
tooltip: 'Dark',
|
||||
),
|
||||
],
|
||||
@@ -58,7 +59,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
|
||||
// ── Updates ──────────────────────────────────────────────────────
|
||||
ListTile(
|
||||
leading: const Icon(Icons.update),
|
||||
leading: const Icon(LucideIcons.refreshCw),
|
||||
title: const Text('Update repository'),
|
||||
subtitle: Text(repoUrl?.isNotEmpty == true
|
||||
? repoUrl!
|
||||
@@ -66,7 +67,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
onTap: () => _editRepoUrl(context, ref, repoUrl),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
leading: const Icon(LucideIcons.info),
|
||||
title: const Text('App version'),
|
||||
subtitle: _versionSubtitle(update),
|
||||
trailing: update.status == UpdateStatus.checking
|
||||
@@ -98,7 +99,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
if (update.status == UpdateStatus.error)
|
||||
ListTile(
|
||||
leading:
|
||||
const Icon(Icons.error_outline, color: Colors.red),
|
||||
const Icon(LucideIcons.alertCircle, color: Colors.red),
|
||||
title: const Text('Update check failed'),
|
||||
subtitle: Text(
|
||||
update.errorMessage ?? 'Unknown error',
|
||||
@@ -110,7 +111,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: const Text('Sign Out'),
|
||||
leading: const Icon(Icons.logout),
|
||||
leading: const Icon(LucideIcons.logOut),
|
||||
onTap: () async {
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
if (context.mounted) context.go(Routes.login);
|
||||
@@ -184,7 +185,7 @@ class _UpdateTile extends ConsumerWidget {
|
||||
final isDownloading = update.status == UpdateStatus.downloading;
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.system_update, color: Colors.green),
|
||||
leading: const Icon(LucideIcons.downloadCloud, color: Colors.green),
|
||||
title: Text('v${update.latestVersion} available'),
|
||||
subtitle: isDownloading
|
||||
? Column(
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../data/models/task.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
@@ -111,6 +113,9 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
|
||||
),
|
||||
child: const Text('Delete')),
|
||||
],
|
||||
),
|
||||
@@ -190,7 +195,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
actions: [
|
||||
if (widget.taskId != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
onPressed: _delete,
|
||||
),
|
||||
IconButton(
|
||||
@@ -200,7 +205,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.check),
|
||||
: const Icon(LucideIcons.check),
|
||||
onPressed: _saving ? null : _save,
|
||||
),
|
||||
],
|
||||
@@ -272,10 +277,10 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
title: Text(_dueDate == null
|
||||
? 'No due date'
|
||||
: 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'),
|
||||
leading: const Icon(Icons.calendar_today),
|
||||
leading: const Icon(LucideIcons.calendarDays),
|
||||
trailing: _dueDate != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () =>
|
||||
setState(() => _dueDate = null),
|
||||
)
|
||||
@@ -331,7 +336,7 @@ class _SubTasksSection extends ConsumerWidget {
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
icon: const Icon(LucideIcons.plus, size: 16),
|
||||
label: const Text('Add'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary,
|
||||
|
||||
@@ -41,13 +41,16 @@ class ChatMessageBubble extends ConsumerWidget {
|
||||
maxWidth: min(MediaQuery.of(context).size.width * 0.82, 480),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
|
||||
// Illuminated Transcript pattern (mirrors web's ChatMessage.vue):
|
||||
// - User bubble: transparent bg, neutral Pewter border, only the
|
||||
// bottom-right corner clipped (the "from-me" tail).
|
||||
// - Assistant bubble: card surface, 2px accent left edge (the
|
||||
// "illuminated capital"), accent-tinted glow shadow + depth
|
||||
// shadow, only the bottom-left corner clipped.
|
||||
decoration: isUser
|
||||
? BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: scheme.primary.withValues(alpha: 0.35),
|
||||
width: 1,
|
||||
),
|
||||
border: Border.all(color: scheme.outline, width: 1),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
@@ -56,16 +59,28 @@ class ChatMessageBubble extends ConsumerWidget {
|
||||
),
|
||||
)
|
||||
: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
color: scheme.surface,
|
||||
border: Border(
|
||||
left: BorderSide(color: scheme.primary, width: 2),
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(4),
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(4),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: scheme.primary.withValues(alpha: 0.14),
|
||||
blurRadius: 28,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
|
||||
import '../data/models/message.dart';
|
||||
@@ -46,7 +47,7 @@ class _JournalPrepCardState extends State<JournalPrepCard> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.menu_book_outlined, size: 18, color: scheme.primary),
|
||||
Icon(LucideIcons.bookOpen, size: 18, color: scheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_todayLabel(),
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../data/local/database.dart';
|
||||
import '../data/models/knowledge_item.dart';
|
||||
import 'pending_sync_badge.dart';
|
||||
|
||||
class KnowledgeItemCard extends StatelessWidget {
|
||||
final KnowledgeItem item;
|
||||
const KnowledgeItemCard({super.key, required this.item});
|
||||
|
||||
String get _pendingDomain =>
|
||||
item.noteType == 'task' ? kSyncDomainTasks : kSyncDomainNotes;
|
||||
|
||||
IconData get _icon => switch (item.noteType) {
|
||||
'person' => Icons.person_outlined,
|
||||
'place' => Icons.place_outlined,
|
||||
'list' => Icons.checklist_outlined,
|
||||
'task' => Icons.task_alt_outlined,
|
||||
_ => Icons.description_outlined,
|
||||
'person' => LucideIcons.user,
|
||||
'place' => LucideIcons.mapPin,
|
||||
'list' => LucideIcons.listChecks,
|
||||
'task' => LucideIcons.checkCircle2,
|
||||
_ => LucideIcons.fileText,
|
||||
};
|
||||
|
||||
Color _statusColor(BuildContext context) {
|
||||
@@ -51,10 +57,17 @@ class KnowledgeItemCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Icon(_icon, color: _statusColor(context)),
|
||||
title: Text(
|
||||
item.title.isEmpty ? '(untitled)' : item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
title: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
item.title.isEmpty ? '(untitled)' : item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
PendingSyncBadge(domain: _pendingDomain, id: item.id),
|
||||
],
|
||||
),
|
||||
subtitle: _subtitle != null
|
||||
? Text(
|
||||
@@ -99,6 +112,7 @@ class KnowledgeItemCard extends StatelessWidget {
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
PendingSyncBadge(domain: _pendingDomain, id: item.id),
|
||||
],
|
||||
),
|
||||
if (_subtitle != null) ...[
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/api_client_provider.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
/// Sticky banner shown when the backend is unreachable. Surfaces a retry
|
||||
/// action and reserves space for future "last sync X min ago" messaging once
|
||||
/// Tier 2 offline caching lands.
|
||||
/// action plus a "last sync X ago" hint when there is cached content to
|
||||
/// fall back on (Tier 2 offline mode).
|
||||
class OfflineBanner extends ConsumerStatefulWidget {
|
||||
const OfflineBanner({super.key});
|
||||
|
||||
@@ -15,6 +19,40 @@ class OfflineBanner extends ConsumerStatefulWidget {
|
||||
|
||||
class _OfflineBannerState extends ConsumerState<OfflineBanner> {
|
||||
bool _retrying = false;
|
||||
DateTime? _lastSync;
|
||||
Timer? _refreshTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLastSync();
|
||||
// Refresh the relative-time string every 30s so "5 min ago" rolls
|
||||
// forward without the user having to interact with the banner.
|
||||
_refreshTimer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(_) => _loadLastSync(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadLastSync() async {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
// Most-recent sync across all cached domains so the hint reads as
|
||||
// "your data was current as of X" regardless of which screen the
|
||||
// user is looking at.
|
||||
final ts = await ref.read(fabledDatabaseProvider).getLatestSync();
|
||||
if (!mounted) return;
|
||||
setState(() => _lastSync = ts);
|
||||
} catch (_) {
|
||||
// Non-critical — banner just won't show the sync hint.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _retry() async {
|
||||
if (_retrying) return;
|
||||
@@ -26,12 +64,38 @@ class _OfflineBannerState extends ConsumerState<OfflineBanner> {
|
||||
}
|
||||
}
|
||||
|
||||
String? _relativeAgo(DateTime ts) {
|
||||
final diff = DateTime.now().difference(ts);
|
||||
if (diff.isNegative) return null;
|
||||
if (diff.inMinutes < 1) return 'just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes} min ago';
|
||||
if (diff.inHours < 24) {
|
||||
final h = diff.inHours;
|
||||
return '$h ${h == 1 ? 'hour' : 'hours'} ago';
|
||||
}
|
||||
final d = diff.inDays;
|
||||
return '$d ${d == 1 ? 'day' : 'days'} ago';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = ref.watch(authProvider);
|
||||
if (status != AuthStatus.offline) return const SizedBox.shrink();
|
||||
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final ago = _lastSync == null ? null : _relativeAgo(_lastSync!);
|
||||
// Pending queue depth — shown on the Retry button so the user knows
|
||||
// there's offline work waiting to land when they come back online.
|
||||
final pending = ref.watch(writeQueueDepthProvider).asData?.value ?? 0;
|
||||
|
||||
final baseMessage = ago == null
|
||||
? 'Offline — showing cached data.'
|
||||
: 'Offline — last sync $ago.';
|
||||
final message = pending > 0
|
||||
? '$baseMessage $pending pending change${pending == 1 ? '' : 's'}.'
|
||||
: baseMessage;
|
||||
final retryLabel = pending > 0 ? 'Retry ($pending)' : 'Retry';
|
||||
|
||||
return Material(
|
||||
color: scheme.errorContainer,
|
||||
child: SafeArea(
|
||||
@@ -40,12 +104,12 @@ class _OfflineBannerState extends ConsumerState<OfflineBanner> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_off_outlined,
|
||||
Icon(LucideIcons.cloudOff,
|
||||
size: 18, color: scheme.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Offline — showing cached data.',
|
||||
message,
|
||||
style: TextStyle(color: scheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
@@ -61,7 +125,7 @@ class _OfflineBannerState extends ConsumerState<OfflineBanner> {
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Retry'),
|
||||
: Text(retryLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/local/database.dart';
|
||||
import '../providers/api_client_provider.dart';
|
||||
|
||||
/// Small cloud-upload glyph shown next to a row whose id has a queued
|
||||
/// offline write (Phase 4). Renders nothing when the queue is empty for
|
||||
/// that id, so list views stay clean during normal online operation.
|
||||
class PendingSyncBadge extends ConsumerWidget {
|
||||
final String domain;
|
||||
final int id;
|
||||
final double size;
|
||||
|
||||
const PendingSyncBadge({
|
||||
super.key,
|
||||
required this.domain,
|
||||
required this.id,
|
||||
this.size = 14,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pending = switch (domain) {
|
||||
kSyncDomainNotes => ref.watch(pendingNoteIdsProvider).asData?.value,
|
||||
kSyncDomainTasks => ref.watch(pendingTaskIdsProvider).asData?.value,
|
||||
kSyncDomainProjects =>
|
||||
ref.watch(pendingProjectIdsProvider).asData?.value,
|
||||
_ => null,
|
||||
};
|
||||
if (pending == null || !pending.contains(id)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Tooltip(
|
||||
message: 'Pending sync — will save when online',
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Icon(
|
||||
LucideIcons.uploadCloud,
|
||||
size: size,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../core/constants.dart';
|
||||
@@ -45,24 +46,24 @@ const Map<String, String> _toolLabels = {
|
||||
};
|
||||
|
||||
IconData _iconFor(String fn) {
|
||||
if (fn.contains('note')) return Icons.sticky_note_2_outlined;
|
||||
if (fn.contains('task')) return Icons.check_circle_outline;
|
||||
if (fn.contains('note')) return LucideIcons.stickyNote;
|
||||
if (fn.contains('task')) return LucideIcons.checkCircle2;
|
||||
if (fn.contains('event') || fn.contains('calendar')) {
|
||||
return Icons.event_outlined;
|
||||
return LucideIcons.calendarCheck;
|
||||
}
|
||||
if (fn.contains('project')) return Icons.folder_outlined;
|
||||
if (fn.contains('milestone')) return Icons.flag_outlined;
|
||||
if (fn.contains('project')) return LucideIcons.folder;
|
||||
if (fn.contains('milestone')) return LucideIcons.flag;
|
||||
if (fn.contains('web') || fn.contains('research') || fn.contains('article')) {
|
||||
return Icons.public;
|
||||
return LucideIcons.globe;
|
||||
}
|
||||
if (fn.contains('image')) return Icons.image_outlined;
|
||||
if (fn.contains('image')) return LucideIcons.image;
|
||||
if (fn.contains('person') || fn.contains('profile')) {
|
||||
return Icons.person_outline;
|
||||
return LucideIcons.user;
|
||||
}
|
||||
if (fn.contains('place')) return Icons.place_outlined;
|
||||
if (fn.contains('rag') || fn.contains('scope')) return Icons.tune;
|
||||
if (fn.contains('calculate')) return Icons.calculate_outlined;
|
||||
return Icons.auto_awesome;
|
||||
if (fn.contains('place')) return LucideIcons.mapPin;
|
||||
if (fn.contains('rag') || fn.contains('scope')) return LucideIcons.sliders;
|
||||
if (fn.contains('calculate')) return LucideIcons.calculator;
|
||||
return LucideIcons.sparkles;
|
||||
}
|
||||
|
||||
/// Pull the destination route for this tool call from its `result` payload,
|
||||
@@ -161,7 +162,7 @@ class ToolCallChip extends StatelessWidget {
|
||||
),
|
||||
if (route != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.arrow_forward, size: 11, color: fg),
|
||||
Icon(LucideIcons.arrowRight, size: 11, color: fg),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
import '../providers/voice_provider.dart';
|
||||
|
||||
@@ -48,8 +49,8 @@ class VoiceMicButton extends StatelessWidget {
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
VoiceMode.playing => Icon(Icons.volume_up, color: iconColor, size: 20),
|
||||
_ => Icon(Icons.mic, color: iconColor, size: 20),
|
||||
VoiceMode.playing => Icon(LucideIcons.volume2, color: iconColor, size: 20),
|
||||
_ => Icon(LucideIcons.mic, color: iconColor, size: 20),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class WeatherCard extends StatelessWidget {
|
||||
final Map<String, dynamic>? weather;
|
||||
|
||||
const WeatherCard({super.key, required this.weather});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (weather == null) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: Text(
|
||||
'Weather data unavailable — will retry at next slot.',
|
||||
style: TextStyle(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final w = weather!;
|
||||
final location = w['location'] as String? ?? '';
|
||||
final currentTemp = w['current_temp'];
|
||||
final condition = w['condition'] as String? ?? '';
|
||||
final todayHigh = w['today_high'];
|
||||
final todayLow = w['today_low'];
|
||||
final yesterdayHigh = w['yesterday_high'];
|
||||
final fetchedAt = w['fetched_at'] as String?;
|
||||
final forecast = (w['forecast'] as List<dynamic>? ?? [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
|
||||
String? tempDelta;
|
||||
if (todayHigh != null && yesterdayHigh != null) {
|
||||
final diff = (todayHigh as num) - (yesterdayHigh as num);
|
||||
if (diff.abs() < 1) {
|
||||
tempDelta = 'Same as yesterday';
|
||||
} else {
|
||||
final dir = diff > 0 ? 'warmer' : 'cooler';
|
||||
tempDelta = '${diff.abs().round()}° $dir than yesterday';
|
||||
}
|
||||
}
|
||||
|
||||
String? fetchedLabel;
|
||||
if (fetchedAt != null) {
|
||||
try {
|
||||
final dt = DateTime.parse(fetchedAt).toLocal();
|
||||
final h = dt.hour % 12 == 0 ? 12 : dt.hour % 12;
|
||||
final m = dt.minute.toString().padLeft(2, '0');
|
||||
final period = dt.hour < 12 ? 'AM' : 'PM';
|
||||
fetchedLabel = '$h:$m $period';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header: location + fetched time
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
location,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
if (fetchedLabel != null)
|
||||
Text(
|
||||
'as of $fetchedLabel',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Current temp + condition
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
'$currentTemp°',
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: scheme.onSurface,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
condition,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Today high/low + delta
|
||||
if (todayHigh != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Today: $todayHigh° / $todayLow°'
|
||||
'${tempDelta != null ? ' · $tempDelta' : ''}',
|
||||
style: TextStyle(fontSize: 13, color: scheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
// Forecast strip
|
||||
if (forecast.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: forecast.map((day) {
|
||||
return SizedBox(
|
||||
width: 64,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
day['day'] as String? ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
day['condition'] as String? ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${day['high']}° / ${day['low']}°',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <flutter_timezone/flutter_timezone_plugin.h>
|
||||
#include <open_file_linux/open_file_linux_plugin.h>
|
||||
#include <record_linux/record_linux_plugin.h>
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
@@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) record_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
|
||||
record_linux_plugin_register_with_registrar(record_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
|
||||
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
|
||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_timezone
|
||||
open_file_linux
|
||||
record_linux
|
||||
sqlite3_flutter_libs
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import open_file_mac
|
||||
import package_info_plus
|
||||
import record_macos
|
||||
import shared_preferences_foundation
|
||||
import sqlite3_flutter_libs
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
@@ -24,5 +25,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
+153
-1
@@ -57,6 +57,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
build:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_config
|
||||
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
build_daemon:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_daemon
|
||||
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: "22fdcc3cfeb9d974d7408718c4be15ec5e9b1b350088f3a6c88f154e74dd700d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.14.1"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_collection
|
||||
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
built_value:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.5"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -65,6 +113,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: charcode
|
||||
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -153,6 +209,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.7"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -177,6 +241,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift
|
||||
sha256: "055c249d1f91be5a47fe447f88afc24c4ca6f4cd6c5ed66767b4797d48acc2e5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.32.1"
|
||||
drift_dev:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: drift_dev
|
||||
sha256: "88a9de3af8571518148a6d8a513b57779fd1e60a026d3ab8a481a878fba01d91"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.32.1"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -368,6 +448,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.2"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: graphs
|
||||
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -496,6 +584,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
lucide_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: lucide_icons
|
||||
sha256: ad24d0fd65707e48add30bebada7d90bff2a1bba0a72d6e9b19d44246b0e83c4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.257.0"
|
||||
markdown:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -649,7 +745,7 @@ packages:
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
path:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
@@ -800,6 +896,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
pubspec_parse:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pubspec_parse
|
||||
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
recase:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: recase
|
||||
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
record:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -981,6 +1093,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.3"
|
||||
source_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1005,6 +1125,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.1"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3_flutter_libs
|
||||
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.42"
|
||||
sqlparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlparser
|
||||
sha256: ab2b467425f1d4f3acfa5fd11a08226f7d6c26ff102c06be1807e1dff34e050b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.44.3"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1029,6 +1173,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_transform
|
||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -12,6 +12,7 @@ dependencies:
|
||||
sdk: flutter
|
||||
|
||||
cupertino_icons: ^1.0.8
|
||||
lucide_icons: ^0.257.0
|
||||
flutter_riverpod: ^3.3.1
|
||||
go_router: ^17.1.0
|
||||
dio: ^5.6.0
|
||||
@@ -33,11 +34,20 @@ dependencies:
|
||||
just_audio: ^0.9.39
|
||||
table_calendar: ^3.1.2
|
||||
|
||||
# Tier 2 offline mode — local SQL store via Drift (sqlite3 under the hood).
|
||||
# Drift was preferred over Hive in the design since the repo already mirrors
|
||||
# well-defined backend schemas (notes/tasks/projects/etc.) one-to-one.
|
||||
drift: ^2.20.0
|
||||
sqlite3_flutter_libs: ^0.5.24
|
||||
path: ^1.9.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_launcher_icons: ^0.14.3
|
||||
drift_dev: ^2.20.0
|
||||
build_runner: ^2.4.13
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:fabled_app/data/models/calendar_event.dart';
|
||||
import 'package:fabled_app/data/api/voice_api.dart';
|
||||
import 'package:fabled_app/data/repositories/write_queue.dart';
|
||||
import 'package:fabled_app/providers/voice_provider.dart';
|
||||
import 'package:fabled_app/data/models/knowledge_item.dart';
|
||||
import 'package:fabled_app/data/models/note.dart';
|
||||
@@ -207,4 +208,36 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('QueueFailure.message', () {
|
||||
test('overwritten with title quotes the title', () {
|
||||
final f = QueueFailure(
|
||||
reason: QueueFailureReason.overwritten,
|
||||
domain: 'notes',
|
||||
title: 'Grocery list',
|
||||
);
|
||||
expect(f.message, contains('"Grocery list"'));
|
||||
expect(f.message, contains('overwritten'));
|
||||
});
|
||||
|
||||
test('rejected without title falls back to generic phrasing', () {
|
||||
final f = QueueFailure(
|
||||
reason: QueueFailureReason.rejected,
|
||||
domain: 'tasks',
|
||||
detail: 'title required',
|
||||
);
|
||||
expect(f.message, contains('an offline edit'));
|
||||
expect(f.message, contains('title required'));
|
||||
});
|
||||
|
||||
test('missing communicates server-side deletion', () {
|
||||
final f = QueueFailure(
|
||||
reason: QueueFailureReason.missing,
|
||||
domain: 'projects',
|
||||
title: 'Q2 launch',
|
||||
);
|
||||
expect(f.message, contains('"Q2 launch"'));
|
||||
expect(f.message, contains('deleted on the server'));
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <flutter_timezone/flutter_timezone_plugin_c_api.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <record_windows/record_windows_plugin_c_api.h>
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
@@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_timezone
|
||||
permission_handler_windows
|
||||
record_windows
|
||||
sqlite3_flutter_libs
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user