feat(offline): tier 2 phase 2 — extend cache to tasks/projects/milestones/events/conversations
Phase 1 cached only notes. Phase 2 brings the read-through pattern to every domain the app reads in bulk: - Drift schema v2: 5 new cached_* tables + onUpgrade migration. Calendar events use range-scoped read/write (replaceEventsInRange) so disjoint month fetches don't clobber each other; milestones are per-project. - Wrap TasksRepository, ProjectsRepository, MilestonesRepository, and ChatRepository (conversation list only) with NetworkException fallback. Writes hit the API then sync the cache. - New EventsRepository wrapping EventsApi; calendar_provider and event_form_sheet repointed at the repository. - OfflineBanner now surfaces getLatestSync() — most-recent timestamp across all domains — so the hint reads sensibly regardless of screen. flutter analyze clean; existing tests pass. Phase 3 (offline write queue) and Phase 4 (read-only UI indicators) still to come. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+502
-40
@@ -7,16 +7,24 @@ import 'package:path/path.dart' as p;
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.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/note.dart';
|
||||||
|
import '../models/project.dart';
|
||||||
|
import '../models/task.dart';
|
||||||
|
|
||||||
part 'database.g.dart';
|
part 'database.g.dart';
|
||||||
|
|
||||||
/// Local cache of server-side Notes for offline reads.
|
// ── Tables ───────────────────────────────────────────────────────────────────
|
||||||
///
|
//
|
||||||
/// Mirrors the shape of [Note] one-to-one. Tags are stored as a JSON-encoded
|
// Each cached_* table mirrors the corresponding model 1:1. Tags / list fields
|
||||||
/// string because SQLite has no native list type and Drift's typed converters
|
// are stored as JSON-encoded text; SQLite lacks a native list type and
|
||||||
/// add ceremony we don't need for a simple read cache. `cachedAt` tracks when
|
// Drift's typed converters add ceremony we don't need for read-side caching.
|
||||||
/// the row was last written from a successful API response.
|
// `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 {
|
class CachedNotes extends Table {
|
||||||
IntColumn get id => integer()();
|
IntColumn get id => integer()();
|
||||||
TextColumn get title => text()();
|
TextColumn get title => text()();
|
||||||
@@ -34,9 +42,92 @@ class CachedNotes extends Table {
|
|||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-domain sync timestamp. Keyed by a short domain string ('notes',
|
class CachedTasks extends Table {
|
||||||
/// 'tasks', 'projects', 'events', etc.) so the OfflineBanner and any
|
IntColumn get id => integer()();
|
||||||
/// stale-while-revalidate logic can ask "when did we last fetch this".
|
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 {
|
class SyncMetadata extends Table {
|
||||||
TextColumn get domain => text()();
|
TextColumn get domain => text()();
|
||||||
DateTimeColumn get lastSyncedAt => dateTime()();
|
DateTimeColumn get lastSyncedAt => dateTime()();
|
||||||
@@ -46,14 +137,42 @@ class SyncMetadata extends Table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const String kSyncDomainNotes = 'notes';
|
const String kSyncDomainNotes = 'notes';
|
||||||
|
const String kSyncDomainTasks = 'tasks';
|
||||||
|
const String kSyncDomainProjects = 'projects';
|
||||||
|
const String kSyncDomainMilestones = 'milestones';
|
||||||
|
const String kSyncDomainEvents = 'events';
|
||||||
|
const String kSyncDomainConversations = 'conversations';
|
||||||
|
|
||||||
@DriftDatabase(tables: [CachedNotes, SyncMetadata])
|
@DriftDatabase(tables: [
|
||||||
|
CachedNotes,
|
||||||
|
CachedTasks,
|
||||||
|
CachedProjects,
|
||||||
|
CachedMilestones,
|
||||||
|
CachedCalendarEvents,
|
||||||
|
CachedConversations,
|
||||||
|
SyncMetadata,
|
||||||
|
])
|
||||||
class FabledDatabase extends _$FabledDatabase {
|
class FabledDatabase extends _$FabledDatabase {
|
||||||
FabledDatabase() : super(_openConnection());
|
FabledDatabase() : super(_openConnection());
|
||||||
FabledDatabase.forTesting(super.executor);
|
FabledDatabase.forTesting(super.executor);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 1;
|
int get schemaVersion => 2;
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ── Notes ────────────────────────────────────────────────────────────────
|
// ── Notes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -70,33 +189,237 @@ class FabledDatabase extends _$FabledDatabase {
|
|||||||
return row == null ? null : _noteFromRow(row);
|
return row == null ? null : _noteFromRow(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the entire notes cache with the latest server snapshot.
|
|
||||||
/// Used after a successful `getAll()` so the cache reflects deletions
|
|
||||||
/// (a row that's gone from the server should be gone locally too).
|
|
||||||
Future<void> replaceAllNotes(List<Note> notes) async {
|
Future<void> replaceAllNotes(List<Note> notes) async {
|
||||||
await transaction(() async {
|
await transaction(() async {
|
||||||
await delete(cachedNotes).go();
|
await delete(cachedNotes).go();
|
||||||
if (notes.isNotEmpty) {
|
if (notes.isNotEmpty) {
|
||||||
await batch((b) {
|
await batch((b) {
|
||||||
b.insertAll(
|
b.insertAll(cachedNotes, notes.map(_noteToCompanion).toList());
|
||||||
cachedNotes,
|
|
||||||
notes.map(_companionFromNote).toList(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await _setLastSyncIn(kSyncDomainNotes, DateTime.now());
|
await _setLastSyncIn(kSyncDomainNotes, DateTime.now());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upsert a single note (after a successful create / update / getOne).
|
|
||||||
Future<void> upsertNote(Note note) async {
|
Future<void> upsertNote(Note note) async {
|
||||||
await into(cachedNotes).insertOnConflictUpdate(_companionFromNote(note));
|
await into(cachedNotes).insertOnConflictUpdate(_noteToCompanion(note));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteNote(int id) async {
|
Future<void> deleteNote(int id) async {
|
||||||
await (delete(cachedNotes)..where((t) => t.id.equals(id))).go();
|
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 ────────────────────────────────────────────────────────
|
// ── Sync metadata ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<DateTime?> getLastSync(String domain) async {
|
Future<DateTime?> getLastSync(String domain) async {
|
||||||
@@ -106,6 +429,17 @@ class FabledDatabase extends _$FabledDatabase {
|
|||||||
return row?.lastSyncedAt;
|
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) {
|
Future<void> _setLastSyncIn(String domain, DateTime timestamp) {
|
||||||
return into(syncMetadata).insertOnConflictUpdate(
|
return into(syncMetadata).insertOnConflictUpdate(
|
||||||
SyncMetadataCompanion(
|
SyncMetadataCompanion(
|
||||||
@@ -116,28 +450,156 @@ class FabledDatabase extends _$FabledDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CachedNotesCompanion _companionFromNote(Note note) => CachedNotesCompanion(
|
// ── Row ↔ model converters ──────────────────────────────────────────────────
|
||||||
id: Value(note.id),
|
|
||||||
title: Value(note.title),
|
CachedNotesCompanion _noteToCompanion(Note n) => CachedNotesCompanion(
|
||||||
body: Value(note.body),
|
id: Value(n.id),
|
||||||
tagsJson: Value(jsonEncode(note.tags)),
|
title: Value(n.title),
|
||||||
noteType: Value(note.noteType),
|
body: Value(n.body),
|
||||||
projectId: Value(note.projectId),
|
tagsJson: Value(jsonEncode(n.tags)),
|
||||||
milestoneId: Value(note.milestoneId),
|
noteType: Value(n.noteType),
|
||||||
createdAt: Value(note.createdAt),
|
projectId: Value(n.projectId),
|
||||||
updatedAt: Value(note.updatedAt),
|
milestoneId: Value(n.milestoneId),
|
||||||
|
createdAt: Value(n.createdAt),
|
||||||
|
updatedAt: Value(n.updatedAt),
|
||||||
);
|
);
|
||||||
|
|
||||||
Note _noteFromRow(CachedNote row) => Note(
|
Note _noteFromRow(CachedNote r) => Note(
|
||||||
id: row.id,
|
id: r.id,
|
||||||
title: row.title,
|
title: r.title,
|
||||||
body: row.body,
|
body: r.body,
|
||||||
tags: (jsonDecode(row.tagsJson) as List<dynamic>).cast<String>(),
|
tags: (jsonDecode(r.tagsJson) as List<dynamic>).cast<String>(),
|
||||||
noteType: row.noteType,
|
noteType: r.noteType,
|
||||||
projectId: row.projectId,
|
projectId: r.projectId,
|
||||||
milestoneId: row.milestoneId,
|
milestoneId: r.milestoneId,
|
||||||
createdAt: row.createdAt,
|
createdAt: r.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
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() {
|
LazyDatabase _openConnection() {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,46 @@
|
|||||||
|
import '../../core/exceptions.dart';
|
||||||
import '../api/chat_api.dart';
|
import '../api/chat_api.dart';
|
||||||
export '../api/chat_api.dart'
|
export '../api/chat_api.dart'
|
||||||
show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate, ChatToolCall;
|
show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate, ChatToolCall;
|
||||||
|
import '../local/database.dart';
|
||||||
import '../models/conversation.dart';
|
import '../models/conversation.dart';
|
||||||
import '../models/message.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 {
|
class ChatRepository {
|
||||||
final ChatApi _api;
|
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) =>
|
Future<Conversation> createConversation(String title) =>
|
||||||
_api.createConversation(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) =>
|
Future<(Conversation, List<Message>)> getMessages(int conversationId) =>
|
||||||
_api.getMessages(conversationId);
|
_api.getMessages(conversationId);
|
||||||
|
|
||||||
Future<void> sendMessage(int conversationId, String content) =>
|
Future<void> sendMessage(int conversationId, String content) =>
|
||||||
_api.sendMessage(conversationId, content);
|
_api.sendMessage(conversationId, content);
|
||||||
|
|
||||||
Stream<ChatStreamEvent> streamGeneration(int conversationId) =>
|
Stream<ChatStreamEvent> streamGeneration(int conversationId) =>
|
||||||
_api.streamGeneration(conversationId);
|
_api.streamGeneration(conversationId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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 for Tier 2 offline
|
||||||
|
/// support. Range-scoped: each `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.
|
||||||
|
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 {
|
||||||
|
final event = await _api.createEvent(payload);
|
||||||
|
await _db.upsertEvent(event);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CalendarEvent> updateEvent(
|
||||||
|
int id, Map<String, dynamic> fields) async {
|
||||||
|
final updated = await _api.updateEvent(id, fields);
|
||||||
|
await _db.upsertEvent(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteEvent(int id) async {
|
||||||
|
await _api.deleteEvent(id);
|
||||||
|
await _db.deleteEvent(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,67 @@
|
|||||||
|
import '../../core/exceptions.dart';
|
||||||
import '../api/milestones_api.dart';
|
import '../api/milestones_api.dart';
|
||||||
|
import '../local/database.dart';
|
||||||
import '../models/milestone.dart';
|
import '../models/milestone.dart';
|
||||||
|
|
||||||
|
/// Milestones repository with read-through caching for Tier 2 offline support.
|
||||||
|
/// Mirrors the pattern in NotesRepository — see that file for full notes.
|
||||||
class MilestonesRepository {
|
class MilestonesRepository {
|
||||||
final MilestonesApi _api;
|
final MilestonesApi _api;
|
||||||
const MilestonesRepository(this._api);
|
final FabledDatabase _db;
|
||||||
|
|
||||||
Future<List<Milestone>> getAll(int projectId, {String? status}) =>
|
const MilestonesRepository(this._api, this._db);
|
||||||
_api.getAll(projectId, status: status);
|
|
||||||
|
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(
|
Future<Milestone> create(
|
||||||
int projectId, {
|
int projectId, {
|
||||||
required String title,
|
required String title,
|
||||||
String? description,
|
String? description,
|
||||||
int orderIndex = 0,
|
int orderIndex = 0,
|
||||||
}) =>
|
}) async {
|
||||||
_api.create(projectId,
|
final milestone = await _api.create(
|
||||||
title: title, description: description, orderIndex: orderIndex);
|
projectId,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
orderIndex: orderIndex,
|
||||||
|
);
|
||||||
|
await _db.upsertMilestone(milestone);
|
||||||
|
return milestone;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Milestone> update(
|
Future<Milestone> update(
|
||||||
int projectId, int milestoneId, Map<String, dynamic> fields) =>
|
int projectId,
|
||||||
_api.update(projectId, milestoneId, fields);
|
int milestoneId,
|
||||||
|
Map<String, dynamic> fields,
|
||||||
|
) async {
|
||||||
|
final updated = await _api.update(projectId, milestoneId, fields);
|
||||||
|
await _db.upsertMilestone(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> delete(int projectId, int milestoneId) =>
|
Future<void> delete(int projectId, int milestoneId) async {
|
||||||
_api.delete(projectId, milestoneId);
|
await _api.delete(projectId, milestoneId);
|
||||||
|
await _db.deleteMilestone(milestoneId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,9 +90,4 @@ class NotesRepository {
|
|||||||
await _api.delete(id);
|
await _api.delete(id);
|
||||||
await _db.deleteNote(id);
|
await _db.deleteNote(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Last successful `getAll` sync timestamp. Surfaced by the OfflineBanner
|
|
||||||
/// when the network is unreachable so the user knows how stale the
|
|
||||||
/// cached notes list is.
|
|
||||||
Future<DateTime?> lastSyncedAt() => _db.getLastSync(kSyncDomainNotes);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,87 @@
|
|||||||
|
import '../../core/exceptions.dart';
|
||||||
import '../api/projects_api.dart';
|
import '../api/projects_api.dart';
|
||||||
|
import '../local/database.dart';
|
||||||
import '../models/project.dart';
|
import '../models/project.dart';
|
||||||
|
|
||||||
|
/// Projects repository with read-through caching for Tier 2 offline support.
|
||||||
|
/// Mirrors the pattern in NotesRepository — see that file for full notes.
|
||||||
|
///
|
||||||
|
/// `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 {
|
class ProjectsRepository {
|
||||||
final ProjectsApi _api;
|
final ProjectsApi _api;
|
||||||
const ProjectsRepository(this._api);
|
final FabledDatabase _db;
|
||||||
|
|
||||||
|
const ProjectsRepository(this._api, this._db);
|
||||||
|
|
||||||
Future<List<Project>> getAll({
|
Future<List<Project>> getAll({
|
||||||
String? status,
|
String? status,
|
||||||
String sort = 'updated_at',
|
String sort = 'updated_at',
|
||||||
String order = 'desc',
|
String order = 'desc',
|
||||||
}) =>
|
}) async {
|
||||||
_api.getAll(status: status, sort: sort, order: order);
|
try {
|
||||||
Future<Project> getOne(int id) => _api.getOne(id);
|
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({
|
Future<Project> create({
|
||||||
required String title,
|
required String title,
|
||||||
String? description,
|
String? description,
|
||||||
String? goal,
|
String? goal,
|
||||||
String? color,
|
String? color,
|
||||||
String status = 'active',
|
String status = 'active',
|
||||||
}) =>
|
}) async {
|
||||||
_api.create(
|
final project = await _api.create(
|
||||||
title: title,
|
title: title,
|
||||||
description: description,
|
description: description,
|
||||||
goal: goal,
|
goal: goal,
|
||||||
color: color,
|
color: color,
|
||||||
status: status);
|
status: status,
|
||||||
Future<Project> update(int id, Map<String, dynamic> fields) =>
|
);
|
||||||
_api.update(id, fields);
|
await _db.upsertProject(project);
|
||||||
Future<void> delete(int id) => _api.delete(id);
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Project> update(int id, Map<String, dynamic> fields) async {
|
||||||
|
final updated = await _api.update(id, fields);
|
||||||
|
await _db.upsertProject(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(int id) async {
|
||||||
|
await _api.delete(id);
|
||||||
|
await _db.deleteProject(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,63 @@
|
|||||||
|
import '../../core/exceptions.dart';
|
||||||
import '../api/tasks_api.dart';
|
import '../api/tasks_api.dart';
|
||||||
|
import '../local/database.dart';
|
||||||
import '../models/task.dart';
|
import '../models/task.dart';
|
||||||
|
|
||||||
|
/// Tasks repository with read-through caching for Tier 2 offline support.
|
||||||
|
/// Mirrors the pattern in NotesRepository — see that file for full notes.
|
||||||
class TasksRepository {
|
class TasksRepository {
|
||||||
final TasksApi _api;
|
final TasksApi _api;
|
||||||
const TasksRepository(this._api);
|
final FabledDatabase _db;
|
||||||
|
|
||||||
Future<List<Task>> getAll() => _api.getAll();
|
const TasksRepository(this._api, this._db);
|
||||||
Future<Task> getOne(int id) => _api.getOne(id);
|
|
||||||
|
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({
|
Future<Task> create({
|
||||||
required String title,
|
required String title,
|
||||||
@@ -16,22 +67,28 @@ class TasksRepository {
|
|||||||
DateTime? dueDate,
|
DateTime? dueDate,
|
||||||
int? projectId,
|
int? projectId,
|
||||||
int? parentId,
|
int? parentId,
|
||||||
}) =>
|
}) async {
|
||||||
_api.create(
|
final task = await _api.create(
|
||||||
title: title,
|
title: title,
|
||||||
description: description,
|
description: description,
|
||||||
status: status,
|
status: status,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
dueDate: dueDate,
|
dueDate: dueDate,
|
||||||
projectId: projectId,
|
projectId: projectId,
|
||||||
parentId: parentId,
|
parentId: parentId,
|
||||||
);
|
);
|
||||||
|
await _db.upsertTask(task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<Task>> getByProject(int projectId) => _api.getByProject(projectId);
|
Future<Task> update(int id, Map<String, dynamic> fields) async {
|
||||||
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
|
final updated = await _api.update(id, fields);
|
||||||
|
await _db.upsertTask(updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Task> update(int id, Map<String, dynamic> fields) =>
|
Future<void> delete(int id) async {
|
||||||
_api.update(id, fields);
|
await _api.delete(id);
|
||||||
|
await _db.deleteTask(id);
|
||||||
Future<void> delete(int id) => _api.delete(id);
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import '../data/api/tasks_api.dart';
|
|||||||
import '../data/local/database.dart';
|
import '../data/local/database.dart';
|
||||||
import '../data/repositories/auth_repository.dart';
|
import '../data/repositories/auth_repository.dart';
|
||||||
import '../data/repositories/chat_repository.dart';
|
import '../data/repositories/chat_repository.dart';
|
||||||
|
import '../data/repositories/events_repository.dart';
|
||||||
import '../data/repositories/knowledge_repository.dart';
|
import '../data/repositories/knowledge_repository.dart';
|
||||||
import '../data/repositories/voice_repository.dart';
|
import '../data/repositories/voice_repository.dart';
|
||||||
import '../data/repositories/milestones_repository.dart';
|
import '../data/repositories/milestones_repository.dart';
|
||||||
@@ -82,15 +83,24 @@ final notesRepositoryProvider = Provider<NotesRepository>((ref) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
|
final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
|
||||||
return TasksRepository(ref.watch(tasksApiProvider));
|
return TasksRepository(
|
||||||
|
ref.watch(tasksApiProvider),
|
||||||
|
ref.watch(fabledDatabaseProvider),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
||||||
return ChatRepository(ref.watch(chatApiProvider));
|
return ChatRepository(
|
||||||
|
ref.watch(chatApiProvider),
|
||||||
|
ref.watch(fabledDatabaseProvider),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) {
|
final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) {
|
||||||
return ProjectsRepository(ref.watch(projectsApiProvider));
|
return ProjectsRepository(
|
||||||
|
ref.watch(projectsApiProvider),
|
||||||
|
ref.watch(fabledDatabaseProvider),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final milestonesApiProvider = Provider<MilestonesApi>((ref) {
|
final milestonesApiProvider = Provider<MilestonesApi>((ref) {
|
||||||
@@ -98,7 +108,10 @@ final milestonesApiProvider = Provider<MilestonesApi>((ref) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
|
final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
|
||||||
return MilestonesRepository(ref.watch(milestonesApiProvider));
|
return MilestonesRepository(
|
||||||
|
ref.watch(milestonesApiProvider),
|
||||||
|
ref.watch(fabledDatabaseProvider),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final knowledgeApiProvider = Provider<KnowledgeApi>((ref) {
|
final knowledgeApiProvider = Provider<KnowledgeApi>((ref) {
|
||||||
@@ -128,3 +141,10 @@ final voiceRepositoryProvider = Provider<VoiceRepository>((ref) {
|
|||||||
final eventsApiProvider = Provider<EventsApi>((ref) {
|
final eventsApiProvider = Provider<EventsApi>((ref) {
|
||||||
return EventsApi(ref.watch(dioProvider));
|
return EventsApi(ref.watch(dioProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final eventsRepositoryProvider = Provider<EventsRepository>((ref) {
|
||||||
|
return EventsRepository(
|
||||||
|
ref.watch(eventsApiProvider),
|
||||||
|
ref.watch(fabledDatabaseProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class CalendarNotifier extends AsyncNotifier<CalendarState> {
|
|||||||
// Fetch current month ± 1 month as the initial window.
|
// Fetch current month ± 1 month as the initial window.
|
||||||
final from = DateTime(now.year, now.month - 1, 1);
|
final from = DateTime(now.year, now.month - 1, 1);
|
||||||
final to = DateTime(now.year, now.month + 2, 0, 23, 59, 59);
|
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(
|
return CalendarState(
|
||||||
eventsByDay: _groupByDay(events),
|
eventsByDay: _groupByDay(events),
|
||||||
selectedDay: today,
|
selectedDay: today,
|
||||||
@@ -60,7 +60,7 @@ class CalendarNotifier extends AsyncNotifier<CalendarState> {
|
|||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
final current = state.value;
|
final current = state.value;
|
||||||
if (current == null) return;
|
if (current == null) return;
|
||||||
final events = await ref.read(eventsApiProvider).getEvents(
|
final events = await ref.read(eventsRepositoryProvider).getEvents(
|
||||||
current.loadedRange.start,
|
current.loadedRange.start,
|
||||||
current.loadedRange.end,
|
current.loadedRange.end,
|
||||||
);
|
);
|
||||||
@@ -93,7 +93,7 @@ class CalendarNotifier extends AsyncNotifier<CalendarState> {
|
|||||||
try {
|
try {
|
||||||
final from = DateTime(month.year, month.month, 1);
|
final from = DateTime(month.year, month.month, 1);
|
||||||
final to = DateTime(month.year, month.month + 1, 0, 23, 59, 59);
|
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 s = state.value!;
|
||||||
final merged = Map<DateTime, List<CalendarEvent>>.from(s.eventsByDay);
|
final merged = Map<DateTime, List<CalendarEvent>>.from(s.eventsByDay);
|
||||||
for (final e in events) {
|
for (final e in events) {
|
||||||
|
|||||||
@@ -120,12 +120,12 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
|||||||
'color': _color,
|
'color': _color,
|
||||||
'recurrence': rrule,
|
'recurrence': rrule,
|
||||||
};
|
};
|
||||||
final api = ref.read(eventsApiProvider);
|
final repo = ref.read(eventsRepositoryProvider);
|
||||||
if (_isCreate) {
|
if (_isCreate) {
|
||||||
final created = await api.createEvent(payload);
|
final created = await repo.createEvent(payload);
|
||||||
widget.notifier.addEvent(created);
|
widget.notifier.addEvent(created);
|
||||||
} else {
|
} else {
|
||||||
final updated = await api.updateEvent(widget.event!.id, payload);
|
final updated = await repo.updateEvent(widget.event!.id, payload);
|
||||||
widget.notifier.updateEvent(updated);
|
widget.notifier.updateEvent(updated);
|
||||||
}
|
}
|
||||||
if (mounted) Navigator.of(context).pop();
|
if (mounted) Navigator.of(context).pop();
|
||||||
@@ -165,7 +165,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
|||||||
if (confirmed != true || !mounted) return;
|
if (confirmed != true || !mounted) return;
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
try {
|
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);
|
widget.notifier.removeEvent(widget.event!.id, widget.event!.startDt);
|
||||||
if (mounted) Navigator.of(context).pop();
|
if (mounted) Navigator.of(context).pop();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ class _OfflineBannerState extends ConsumerState<OfflineBanner> {
|
|||||||
Future<void> _loadLastSync() async {
|
Future<void> _loadLastSync() async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
try {
|
try {
|
||||||
final ts =
|
// Most-recent sync across all cached domains so the hint reads as
|
||||||
await ref.read(notesRepositoryProvider).lastSyncedAt();
|
// "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;
|
if (!mounted) return;
|
||||||
setState(() => _lastSync = ts);
|
setState(() => _lastSync = ts);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|||||||
Reference in New Issue
Block a user