From b9e68e3bc8deb6e4f7777bba004480e54c275637 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Tue, 28 Apr 2026 19:14:28 -0400 Subject: [PATCH 1/5] =?UTF-8?q?feat(design):=20surface=20phase=20=E2=80=94?= =?UTF-8?q?=20Lucide=20icons,=20input=20radius,=20Illuminated=20Transcript?= =?UTF-8?q?,=20ActionColors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-screen application of the design system to the Flutter app. Mirrors the web's surface phase landed in FabledScribe v26.04.28.1. Foundation port shipped in 0f05f47; this is the surface work. Lucide icon migration - Added lucide_icons ^0.257.0 dependency - 107 Material Icons references → LucideIcons across 21 files. Drop-in IconData swap (Icon(LucideIcons.X) instead of Icon(Icons.x)). - Lucide import added to each touched file. Input border radius - theme.dart inputDecorationTheme borderRadius 24 → 8 in both light and dark themes. Doc says radius-md (8px) for inputs; previous pill shape was Material default that the doc deviates from. Illuminated Transcript pattern (ChatMessageBubble) - User bubble: accent-tinted border → neutral Pewter (scheme.outline). Asymmetric corner already correct (bottomRight 4px). - Assistant bubble: topLeft corner 4 → 16; only bottomLeft stays 4 (the "tail" effect, mirroring web's `border-bottom-left-radius: 4px`). Background switched from surfaceContainerHighest (Slate) to surface (Iron) per the doc spec "card surface". - Assistant bubble glow shadow added — accent-tinted blur (28px alpha 0.14) + depth shadow (8px alpha 0.4 black). Mirrors web's --color-bubble-asst-shadow. ActionColors wiring (Hybrid rule) - 5 'Delete' confirm buttons across notes / tasks / chat conversations / calendar event sheet → Oxblood action-destructive via the ActionColors ThemeExtension defined in the foundation port. Foreground for ghost/text variants, backgroundColor for filled. - Calendar event Save button → Moss action-primary. The first call site to wire ActionColors.primary; serves as the pattern for future Save reclassifications. - Other Save buttons (note edit, task edit, project edit, etc.) still flow through colorScheme.primary (dusty violet) and read as brand-moment. Reclassifying those is deferred — the wiring pattern is established and can be applied incrementally as files are touched. Indigo cleanup - 4 hardcoded #7C3AED / #5B21B6 literals → dusty-violet equivalents (#5B4A8A / #3F3560). Spots: project_tasks_screen color fallback (×2), journal_screen gradient. Verification - flutter analyze: No issues found What's deferred - Per-screen Save / Cancel reclassification beyond the calendar event Save button. Wiring pattern established; rollout opportunistic. - Long-form 1.7 line-height on assistant Markdown content (would require MarkdownStyleSheet work; minor). - Surface walk on Knowledge / Projects / Settings screens for any hardcoded styling that needs touch. Co-Authored-By: Claude Opus 4.7 --- lib/app.dart | 49 ++++++++++--------- lib/core/theme.dart | 12 ++--- lib/screens/auth/login_screen.dart | 7 +-- lib/screens/calendar/calendar_screen.dart | 3 +- lib/screens/calendar/event_form_sheet.dart | 32 ++++++------ lib/screens/chat/chat_screen.dart | 5 +- .../chat/conversations_tab_screen.dart | 17 ++++--- .../journal/journal_history_screen.dart | 5 +- lib/screens/journal/journal_screen.dart | 7 +-- lib/screens/knowledge/knowledge_screen.dart | 13 ++--- lib/screens/library/project_tasks_screen.dart | 15 +++--- lib/screens/notes/note_detail_screen.dart | 11 ++++- lib/screens/notes/note_edit_screen.dart | 13 +++-- lib/screens/projects/project_edit_screen.dart | 3 +- lib/screens/projects/projects_screen.dart | 3 +- lib/screens/settings/settings_screen.dart | 21 ++++---- lib/screens/tasks/task_edit_screen.dart | 15 ++++-- lib/widgets/chat_message_bubble.dart | 27 +++++++--- lib/widgets/journal_prep_card.dart | 3 +- lib/widgets/knowledge_item_card.dart | 11 +++-- lib/widgets/offline_banner.dart | 3 +- lib/widgets/tool_call_chip.dart | 27 +++++----- lib/widgets/voice_mic_button.dart | 5 +- pubspec.lock | 8 +++ pubspec.yaml | 1 + 25 files changed, 191 insertions(+), 125 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 5d0db2f..05c727a 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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'; @@ -296,7 +297,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 +305,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); @@ -370,28 +371,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 +433,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 +580,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 +592,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 +610,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), ), diff --git a/lib/core/theme.dart b/lib/core/theme.dart index 04a7c21..52c264c 100644 --- a/lib/core/theme.dart +++ b/lib/core/theme.dart @@ -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), ), ), diff --git a/lib/screens/auth/login_screen.dart b/lib/screens/auth/login_screen.dart index 832c7f6..2d46d24 100644 --- a/lib/screens/auth/login_screen.dart +++ b/lib/screens/auth/login_screen.dart @@ -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 { 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 { border: const OutlineInputBorder(), suffixIcon: IconButton( icon: Icon(_obscure - ? Icons.visibility - : Icons.visibility_off), + ? LucideIcons.eye + : LucideIcons.eyeOff), onPressed: () => setState(() => _obscure = !_obscure), ), diff --git a/lib/screens/calendar/calendar_screen.dart b/lib/screens/calendar/calendar_screen.dart index 9d9e628..d077f81 100644 --- a/lib/screens/calendar/calendar_screen.dart +++ b/lib/screens/calendar/calendar_screen.dart @@ -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, ); diff --git a/lib/screens/calendar/event_form_sheet.dart b/lib/screens/calendar/event_form_sheet.dart index 012bf13..2bb871e 100644 --- a/lib/screens/calendar/event_form_sheet.dart +++ b/lib/screens/calendar/event_form_sheet.dart @@ -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'; @@ -152,11 +154,10 @@ class _EventFormSheetState extends ConsumerState { ), 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()!.destructive, ), + child: const Text('Delete'), ), ], ), @@ -273,7 +274,7 @@ class _EventFormSheetState extends ConsumerState { 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 { // 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 { 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 { // 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 { 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 { 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( value: _recurrence, @@ -419,7 +420,7 @@ class _EventFormSheetState extends ConsumerState { 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 { 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()!.primary, + ), onPressed: _saving ? null : _save, child: _saving ? const SizedBox( diff --git a/lib/screens/chat/chat_screen.dart b/lib/screens/chat/chat_screen.dart index af341f5..bbb7e97 100644 --- a/lib/screens/chat/chat_screen.dart +++ b/lib/screens/chat/chat_screen.dart @@ -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 height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Icon(Icons.refresh), + : const Icon(LucideIcons.refreshCw), ), ], ), @@ -280,7 +281,7 @@ class _ChatScreenState extends ConsumerState child: CircularProgressIndicator(strokeWidth: 2), ) - : const Icon(Icons.send), + : const Icon(LucideIcons.send), ), ], ), diff --git a/lib/screens/chat/conversations_tab_screen.dart b/lib/screens/chat/conversations_tab_screen.dart index da7e1b9..773785b 100644 --- a/lib/screens/chat/conversations_tab_screen.dart +++ b/lib/screens/chat/conversations_tab_screen.dart @@ -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()!.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), diff --git a/lib/screens/journal/journal_history_screen.dart b/lib/screens/journal/journal_history_screen.dart index d88724b..983f8e8 100644 --- a/lib/screens/journal/journal_history_screen.dart +++ b/lib/screens/journal/journal_history_screen.dart @@ -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), diff --git a/lib/screens/journal/journal_screen.dart b/lib/screens/journal/journal_screen.dart index df46534..c1dab35 100644 --- a/lib/screens/journal/journal_screen.dart +++ b/lib/screens/journal/journal_screen.dart @@ -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'; @@ -188,7 +189,7 @@ class _JournalScreenState extends ConsumerState ) else IconButton( - icon: const Icon(Icons.refresh_outlined), + icon: const Icon(LucideIcons.refreshCw), tooltip: 'Regenerate prep', onPressed: _refresh, ), @@ -426,7 +427,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 +443,7 @@ class _GradientSendButton extends StatelessWidget { ), ) : Icon( - Icons.send, + LucideIcons.send, color: disabled ? scheme.onSurface.withValues(alpha: 0.38) : Colors.white, diff --git a/lib/screens/knowledge/knowledge_screen.dart b/lib/screens/knowledge/knowledge_screen.dart index 4a9d72b..2222beb 100644 --- a/lib/screens/knowledge/knowledge_screen.dart +++ b/lib/screens/knowledge/knowledge_screen.dart @@ -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 : 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 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 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 }, ), _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 }, ), _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 }, ), _TypePickerRow( - icon: Icons.checklist_outlined, + icon: LucideIcons.listChecks, label: 'List', description: 'Checklist or structured list', onTap: () { diff --git a/lib/screens/library/project_tasks_screen.dart b/lib/screens/library/project_tasks_screen.dart index 775715d..9dd73f3 100644 --- a/lib/screens/library/project_tasks_screen.dart +++ b/lib/screens/library/project_tasks_screen.dart @@ -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'; @@ -55,11 +56,11 @@ class _ProjectTasksScreenState extends ConsumerState { } 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 +97,7 @@ class _ProjectTasksScreenState extends ConsumerState { ), 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 +134,7 @@ class _ProjectTasksScreenState extends ConsumerState { 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 +303,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) { diff --git a/lib/screens/notes/note_detail_screen.dart b/lib/screens/notes/note_detail_screen.dart index 19dea9a..9fa659d 100644 --- a/lib/screens/notes/note_detail_screen.dart +++ b/lib/screens/notes/note_detail_screen.dart @@ -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( context: context, @@ -65,6 +67,11 @@ class NoteDetailScreen extends ConsumerWidget { TextButton( onPressed: () => Navigator.pop(dialogContext, true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(dialogContext) + .extension()! + .destructive, + ), child: const Text('Delete'), ), ], diff --git a/lib/screens/notes/note_edit_screen.dart b/lib/screens/notes/note_edit_screen.dart index e02477d..bb3851f 100644 --- a/lib/screens/notes/note_edit_screen.dart +++ b/lib/screens/notes/note_edit_screen.dart @@ -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 { ), TextButton( onPressed: () => Navigator.pop(dialogContext, true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(dialogContext).extension()!.destructive, + ), child: const Text('Delete'), ), ], @@ -163,12 +168,12 @@ class _NoteEditScreenState extends ConsumerState { 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 { 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), ), ), diff --git a/lib/screens/projects/project_edit_screen.dart b/lib/screens/projects/project_edit_screen.dart index ca5f3e3..4e085ba 100644 --- a/lib/screens/projects/project_edit_screen.dart +++ b/lib/screens/projects/project_edit_screen.dart @@ -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 { height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Icon(Icons.check), + : const Icon(LucideIcons.check), onPressed: _saving ? null : _save, ), ], diff --git a/lib/screens/projects/projects_screen.dart b/lib/screens/projects/projects_screen.dart index 2436fb9..0a9ecf0 100644 --- a/lib/screens/projects/projects_screen.dart +++ b/lib/screens/projects/projects_screen.dart @@ -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'; @@ -30,7 +31,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), ), ); } diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 0dcf546..1a115e0 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -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( 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( diff --git a/lib/screens/tasks/task_edit_screen.dart b/lib/screens/tasks/task_edit_screen.dart index cdab04e..0b1ed17 100644 --- a/lib/screens/tasks/task_edit_screen.dart +++ b/lib/screens/tasks/task_edit_screen.dart @@ -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 { child: const Text('Cancel')), TextButton( onPressed: () => Navigator.pop(dialogContext, true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(dialogContext).extension()!.destructive, + ), child: const Text('Delete')), ], ), @@ -190,7 +195,7 @@ class _TaskEditScreenState extends ConsumerState { 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 { 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 { 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, diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index bf000b0..488b1b7 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -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), diff --git a/lib/widgets/journal_prep_card.dart b/lib/widgets/journal_prep_card.dart index 0e44630..d8802a7 100644 --- a/lib/widgets/journal_prep_card.dart +++ b/lib/widgets/journal_prep_card.dart @@ -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 { 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(), diff --git a/lib/widgets/knowledge_item_card.dart b/lib/widgets/knowledge_item_card.dart index 9af1688..f1f8dda 100644 --- a/lib/widgets/knowledge_item_card.dart +++ b/lib/widgets/knowledge_item_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; import 'package:go_router/go_router.dart'; import '../data/models/knowledge_item.dart'; @@ -8,11 +9,11 @@ class KnowledgeItemCard extends StatelessWidget { const KnowledgeItemCard({super.key, required this.item}); 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) { diff --git a/lib/widgets/offline_banner.dart b/lib/widgets/offline_banner.dart index 2d9ee72..94137e1 100644 --- a/lib/widgets/offline_banner.dart +++ b/lib/widgets/offline_banner.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../providers/auth_provider.dart'; @@ -40,7 +41,7 @@ class _OfflineBannerState extends ConsumerState { 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( diff --git a/lib/widgets/tool_call_chip.dart b/lib/widgets/tool_call_chip.dart index ba61774..a801979 100644 --- a/lib/widgets/tool_call_chip.dart +++ b/lib/widgets/tool_call_chip.dart @@ -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 _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), ], ], ), diff --git a/lib/widgets/voice_mic_button.dart b/lib/widgets/voice_mic_button.dart index d59c550..bbbb509 100644 --- a/lib/widgets/voice_mic_button.dart +++ b/lib/widgets/voice_mic_button.dart @@ -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), }; } diff --git a/pubspec.lock b/pubspec.lock index 5c8f215..e7d0b33 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -496,6 +496,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: diff --git a/pubspec.yaml b/pubspec.yaml index a618b73..5a4fc9e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 From 6ef658558a07856cf439e4a2310d145d154f2ece Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Tue, 28 Apr 2026 21:08:06 -0400 Subject: [PATCH 2/5] =?UTF-8?q?feat(offline):=20tier=202=20phase=201=20?= =?UTF-8?q?=E2=80=94=20Drift=20cache=20+=20read-through=20for=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the tier 2 offline mode work tracked in Fable #147. Notes are the only domain wired so far; this PR establishes the shape and proves the round-trip end-to-end before extending to tasks / projects / events / etc. in phase 2. Local store - Adds drift ^2.20.0 + sqlite3_flutter_libs ^0.5.24 + path ^1.9.0 runtime deps and drift_dev / build_runner dev deps. - New lib/data/local/database.dart: FabledDatabase with CachedNotes (mirroring the Note model 1:1; tags stored as JSON-encoded text) and SyncMetadata (per-domain last_synced_at). Database file lives at $appDocs/fabled_cache.sqlite; opened lazily in a background isolate. - fabledDatabaseProvider on the existing api_client_provider; closes the DB when the ProviderScope disposes. Repository pattern - NotesRepository now takes (NotesApi, FabledDatabase). Reads attempt the network first; on success the response is written to the cache. On NetworkException reads fall back to the cache (rethrowing if it is empty so fresh-install offline still surfaces the network error). - Writes (create/update/delete) hit the server then sync the cache; offline write queueing is phase 3 and explicitly not wired here. - AuthStatus.offline stays owned by AuthNotifier.verify()'s heartbeat; this repo deliberately doesn't poke that state. OfflineBanner - Reads notesRepository.lastSyncedAt(); when present, swaps "Offline — showing cached data." for "Offline — last sync X min ago." A 30s timer refreshes the relative-time string while the banner is mounted. - Falls back to the generic message if no cache exists yet (fresh install offline). Verification - flutter analyze: No issues found Out of scope (later phases per the task body) - Tasks / projects / milestones / events / conversation list / journal day caching (phase 2 — same wrapper pattern, repeated) - Generic write queue with conflict resolution on updated_at (phase 3) - Read-only UI indicators on screens that depend on cached data (phase 4) - Full offline chat (out of scope; needs a local model) - RAG / search over cached content (out of scope) Co-Authored-By: Claude Opus 4.7 --- lib/data/local/database.dart | 152 ++ lib/data/local/database.g.dart | 1282 +++++++++++++++++ lib/data/repositories/notes_repository.dart | 87 +- lib/providers/api_client_provider.dart | 15 +- lib/widgets/offline_banner.dart | 59 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 146 +- pubspec.yaml | 9 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 12 files changed, 1743 insertions(+), 18 deletions(-) create mode 100644 lib/data/local/database.dart create mode 100644 lib/data/local/database.g.dart diff --git a/lib/data/local/database.dart b/lib/data/local/database.dart new file mode 100644 index 0000000..baf5030 --- /dev/null +++ b/lib/data/local/database.dart @@ -0,0 +1,152 @@ +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/note.dart'; + +part 'database.g.dart'; + +/// Local cache of server-side Notes for offline reads. +/// +/// Mirrors the shape of [Note] one-to-one. Tags are stored as a JSON-encoded +/// string because SQLite has no native list type and Drift's typed converters +/// add ceremony we don't need for a simple read cache. `cachedAt` tracks when +/// the row was last written from a successful API response. +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 get primaryKey => {id}; +} + +/// Per-domain sync timestamp. Keyed by a short domain string ('notes', +/// 'tasks', 'projects', 'events', etc.) so the OfflineBanner and any +/// stale-while-revalidate logic can ask "when did we last fetch this". +class SyncMetadata extends Table { + TextColumn get domain => text()(); + DateTimeColumn get lastSyncedAt => dateTime()(); + + @override + Set get primaryKey => {domain}; +} + +const String kSyncDomainNotes = 'notes'; + +@DriftDatabase(tables: [CachedNotes, SyncMetadata]) +class FabledDatabase extends _$FabledDatabase { + FabledDatabase() : super(_openConnection()); + FabledDatabase.forTesting(super.executor); + + @override + int get schemaVersion => 1; + + // ── Notes ──────────────────────────────────────────────────────────────── + + Future> getAllNotes() async { + final rows = await (select(cachedNotes) + ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])) + .get(); + return rows.map(_noteFromRow).toList(); + } + + Future getNote(int id) async { + final row = await (select(cachedNotes)..where((t) => t.id.equals(id))) + .getSingleOrNull(); + 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 replaceAllNotes(List notes) async { + await transaction(() async { + await delete(cachedNotes).go(); + if (notes.isNotEmpty) { + await batch((b) { + b.insertAll( + cachedNotes, + notes.map(_companionFromNote).toList(), + ); + }); + } + await _setLastSyncIn(kSyncDomainNotes, DateTime.now()); + }); + } + + /// Upsert a single note (after a successful create / update / getOne). + Future upsertNote(Note note) async { + await into(cachedNotes).insertOnConflictUpdate(_companionFromNote(note)); + } + + Future deleteNote(int id) async { + await (delete(cachedNotes)..where((t) => t.id.equals(id))).go(); + } + + // ── Sync metadata ──────────────────────────────────────────────────────── + + Future getLastSync(String domain) async { + final row = await (select(syncMetadata) + ..where((t) => t.domain.equals(domain))) + .getSingleOrNull(); + return row?.lastSyncedAt; + } + + Future _setLastSyncIn(String domain, DateTime timestamp) { + return into(syncMetadata).insertOnConflictUpdate( + SyncMetadataCompanion( + domain: Value(domain), + lastSyncedAt: Value(timestamp), + ), + ); + } +} + +CachedNotesCompanion _companionFromNote(Note note) => CachedNotesCompanion( + id: Value(note.id), + title: Value(note.title), + body: Value(note.body), + tagsJson: Value(jsonEncode(note.tags)), + noteType: Value(note.noteType), + projectId: Value(note.projectId), + milestoneId: Value(note.milestoneId), + createdAt: Value(note.createdAt), + updatedAt: Value(note.updatedAt), + ); + +Note _noteFromRow(CachedNote row) => Note( + id: row.id, + title: row.title, + body: row.body, + tags: (jsonDecode(row.tagsJson) as List).cast(), + noteType: row.noteType, + projectId: row.projectId, + milestoneId: row.milestoneId, + createdAt: row.createdAt, + updatedAt: row.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); + }); +} diff --git a/lib/data/local/database.g.dart b/lib/data/local/database.g.dart new file mode 100644 index 0000000..8c99cff --- /dev/null +++ b/lib/data/local/database.g.dart @@ -0,0 +1,1282 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class $CachedNotesTable extends CachedNotes + with TableInfo<$CachedNotesTable, CachedNote> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CachedNotesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _bodyMeta = const VerificationMeta('body'); + @override + late final GeneratedColumn body = GeneratedColumn( + 'body', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _tagsJsonMeta = const VerificationMeta( + 'tagsJson', + ); + @override + late final GeneratedColumn tagsJson = GeneratedColumn( + 'tags_json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('[]'), + ); + static const VerificationMeta _noteTypeMeta = const VerificationMeta( + 'noteType', + ); + @override + late final GeneratedColumn noteType = GeneratedColumn( + 'note_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('note'), + ); + static const VerificationMeta _projectIdMeta = const VerificationMeta( + 'projectId', + ); + @override + late final GeneratedColumn projectId = GeneratedColumn( + 'project_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _milestoneIdMeta = const VerificationMeta( + 'milestoneId', + ); + @override + late final GeneratedColumn milestoneId = GeneratedColumn( + 'milestone_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _cachedAtMeta = const VerificationMeta( + 'cachedAt', + ); + @override + late final GeneratedColumn cachedAt = GeneratedColumn( + 'cached_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + clientDefault: () => DateTime.now(), + ); + @override + List get $columns => [ + id, + title, + body, + tagsJson, + noteType, + projectId, + milestoneId, + createdAt, + updatedAt, + cachedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'cached_notes'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('body')) { + context.handle( + _bodyMeta, + body.isAcceptableOrUnknown(data['body']!, _bodyMeta), + ); + } else if (isInserting) { + context.missing(_bodyMeta); + } + if (data.containsKey('tags_json')) { + context.handle( + _tagsJsonMeta, + tagsJson.isAcceptableOrUnknown(data['tags_json']!, _tagsJsonMeta), + ); + } + if (data.containsKey('note_type')) { + context.handle( + _noteTypeMeta, + noteType.isAcceptableOrUnknown(data['note_type']!, _noteTypeMeta), + ); + } + if (data.containsKey('project_id')) { + context.handle( + _projectIdMeta, + projectId.isAcceptableOrUnknown(data['project_id']!, _projectIdMeta), + ); + } + if (data.containsKey('milestone_id')) { + context.handle( + _milestoneIdMeta, + milestoneId.isAcceptableOrUnknown( + data['milestone_id']!, + _milestoneIdMeta, + ), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + if (data.containsKey('cached_at')) { + context.handle( + _cachedAtMeta, + cachedAt.isAcceptableOrUnknown(data['cached_at']!, _cachedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + CachedNote map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CachedNote( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + body: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}body'], + )!, + tagsJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tags_json'], + )!, + noteType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}note_type'], + )!, + projectId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}project_id'], + ), + milestoneId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}milestone_id'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + cachedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}cached_at'], + )!, + ); + } + + @override + $CachedNotesTable createAlias(String alias) { + return $CachedNotesTable(attachedDatabase, alias); + } +} + +class CachedNote extends DataClass implements Insertable { + final int id; + final String title; + final String body; + final String tagsJson; + final String noteType; + final int? projectId; + final int? milestoneId; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime cachedAt; + const CachedNote({ + required this.id, + required this.title, + required this.body, + required this.tagsJson, + required this.noteType, + this.projectId, + this.milestoneId, + required this.createdAt, + required this.updatedAt, + required this.cachedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + map['body'] = Variable(body); + map['tags_json'] = Variable(tagsJson); + map['note_type'] = Variable(noteType); + if (!nullToAbsent || projectId != null) { + map['project_id'] = Variable(projectId); + } + if (!nullToAbsent || milestoneId != null) { + map['milestone_id'] = Variable(milestoneId); + } + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['cached_at'] = Variable(cachedAt); + return map; + } + + CachedNotesCompanion toCompanion(bool nullToAbsent) { + return CachedNotesCompanion( + id: Value(id), + title: Value(title), + body: Value(body), + tagsJson: Value(tagsJson), + noteType: Value(noteType), + projectId: projectId == null && nullToAbsent + ? const Value.absent() + : Value(projectId), + milestoneId: milestoneId == null && nullToAbsent + ? const Value.absent() + : Value(milestoneId), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + cachedAt: Value(cachedAt), + ); + } + + factory CachedNote.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CachedNote( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + body: serializer.fromJson(json['body']), + tagsJson: serializer.fromJson(json['tagsJson']), + noteType: serializer.fromJson(json['noteType']), + projectId: serializer.fromJson(json['projectId']), + milestoneId: serializer.fromJson(json['milestoneId']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + cachedAt: serializer.fromJson(json['cachedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'body': serializer.toJson(body), + 'tagsJson': serializer.toJson(tagsJson), + 'noteType': serializer.toJson(noteType), + 'projectId': serializer.toJson(projectId), + 'milestoneId': serializer.toJson(milestoneId), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'cachedAt': serializer.toJson(cachedAt), + }; + } + + CachedNote copyWith({ + int? id, + String? title, + String? body, + String? tagsJson, + String? noteType, + Value projectId = const Value.absent(), + Value milestoneId = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + DateTime? cachedAt, + }) => CachedNote( + id: id ?? this.id, + title: title ?? this.title, + body: body ?? this.body, + tagsJson: tagsJson ?? this.tagsJson, + noteType: noteType ?? this.noteType, + projectId: projectId.present ? projectId.value : this.projectId, + milestoneId: milestoneId.present ? milestoneId.value : this.milestoneId, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + cachedAt: cachedAt ?? this.cachedAt, + ); + CachedNote copyWithCompanion(CachedNotesCompanion data) { + return CachedNote( + id: data.id.present ? data.id.value : this.id, + title: data.title.present ? data.title.value : this.title, + body: data.body.present ? data.body.value : this.body, + tagsJson: data.tagsJson.present ? data.tagsJson.value : this.tagsJson, + noteType: data.noteType.present ? data.noteType.value : this.noteType, + projectId: data.projectId.present ? data.projectId.value : this.projectId, + milestoneId: data.milestoneId.present + ? data.milestoneId.value + : this.milestoneId, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + cachedAt: data.cachedAt.present ? data.cachedAt.value : this.cachedAt, + ); + } + + @override + String toString() { + return (StringBuffer('CachedNote(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('body: $body, ') + ..write('tagsJson: $tagsJson, ') + ..write('noteType: $noteType, ') + ..write('projectId: $projectId, ') + ..write('milestoneId: $milestoneId, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('cachedAt: $cachedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + title, + body, + tagsJson, + noteType, + projectId, + milestoneId, + createdAt, + updatedAt, + cachedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CachedNote && + other.id == this.id && + other.title == this.title && + other.body == this.body && + other.tagsJson == this.tagsJson && + other.noteType == this.noteType && + other.projectId == this.projectId && + other.milestoneId == this.milestoneId && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.cachedAt == this.cachedAt); +} + +class CachedNotesCompanion extends UpdateCompanion { + final Value id; + final Value title; + final Value body; + final Value tagsJson; + final Value noteType; + final Value projectId; + final Value milestoneId; + final Value createdAt; + final Value updatedAt; + final Value cachedAt; + const CachedNotesCompanion({ + this.id = const Value.absent(), + this.title = const Value.absent(), + this.body = const Value.absent(), + this.tagsJson = const Value.absent(), + this.noteType = const Value.absent(), + this.projectId = const Value.absent(), + this.milestoneId = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.cachedAt = const Value.absent(), + }); + CachedNotesCompanion.insert({ + this.id = const Value.absent(), + required String title, + required String body, + this.tagsJson = const Value.absent(), + this.noteType = const Value.absent(), + this.projectId = const Value.absent(), + this.milestoneId = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + this.cachedAt = const Value.absent(), + }) : title = Value(title), + body = Value(body), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? title, + Expression? body, + Expression? tagsJson, + Expression? noteType, + Expression? projectId, + Expression? milestoneId, + Expression? createdAt, + Expression? updatedAt, + Expression? cachedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (title != null) 'title': title, + if (body != null) 'body': body, + if (tagsJson != null) 'tags_json': tagsJson, + if (noteType != null) 'note_type': noteType, + if (projectId != null) 'project_id': projectId, + if (milestoneId != null) 'milestone_id': milestoneId, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (cachedAt != null) 'cached_at': cachedAt, + }); + } + + CachedNotesCompanion copyWith({ + Value? id, + Value? title, + Value? body, + Value? tagsJson, + Value? noteType, + Value? projectId, + Value? milestoneId, + Value? createdAt, + Value? updatedAt, + Value? cachedAt, + }) { + return CachedNotesCompanion( + id: id ?? this.id, + title: title ?? this.title, + body: body ?? this.body, + tagsJson: tagsJson ?? this.tagsJson, + noteType: noteType ?? this.noteType, + projectId: projectId ?? this.projectId, + milestoneId: milestoneId ?? this.milestoneId, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + cachedAt: cachedAt ?? this.cachedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (body.present) { + map['body'] = Variable(body.value); + } + if (tagsJson.present) { + map['tags_json'] = Variable(tagsJson.value); + } + if (noteType.present) { + map['note_type'] = Variable(noteType.value); + } + if (projectId.present) { + map['project_id'] = Variable(projectId.value); + } + if (milestoneId.present) { + map['milestone_id'] = Variable(milestoneId.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (cachedAt.present) { + map['cached_at'] = Variable(cachedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CachedNotesCompanion(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('body: $body, ') + ..write('tagsJson: $tagsJson, ') + ..write('noteType: $noteType, ') + ..write('projectId: $projectId, ') + ..write('milestoneId: $milestoneId, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('cachedAt: $cachedAt') + ..write(')')) + .toString(); + } +} + +class $SyncMetadataTable extends SyncMetadata + with TableInfo<$SyncMetadataTable, SyncMetadataData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SyncMetadataTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _domainMeta = const VerificationMeta('domain'); + @override + late final GeneratedColumn domain = GeneratedColumn( + 'domain', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _lastSyncedAtMeta = const VerificationMeta( + 'lastSyncedAt', + ); + @override + late final GeneratedColumn lastSyncedAt = GeneratedColumn( + 'last_synced_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [domain, lastSyncedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sync_metadata'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('domain')) { + context.handle( + _domainMeta, + domain.isAcceptableOrUnknown(data['domain']!, _domainMeta), + ); + } else if (isInserting) { + context.missing(_domainMeta); + } + if (data.containsKey('last_synced_at')) { + context.handle( + _lastSyncedAtMeta, + lastSyncedAt.isAcceptableOrUnknown( + data['last_synced_at']!, + _lastSyncedAtMeta, + ), + ); + } else if (isInserting) { + context.missing(_lastSyncedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {domain}; + @override + SyncMetadataData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SyncMetadataData( + domain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}domain'], + )!, + lastSyncedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_synced_at'], + )!, + ); + } + + @override + $SyncMetadataTable createAlias(String alias) { + return $SyncMetadataTable(attachedDatabase, alias); + } +} + +class SyncMetadataData extends DataClass + implements Insertable { + final String domain; + final DateTime lastSyncedAt; + const SyncMetadataData({required this.domain, required this.lastSyncedAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['domain'] = Variable(domain); + map['last_synced_at'] = Variable(lastSyncedAt); + return map; + } + + SyncMetadataCompanion toCompanion(bool nullToAbsent) { + return SyncMetadataCompanion( + domain: Value(domain), + lastSyncedAt: Value(lastSyncedAt), + ); + } + + factory SyncMetadataData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SyncMetadataData( + domain: serializer.fromJson(json['domain']), + lastSyncedAt: serializer.fromJson(json['lastSyncedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'domain': serializer.toJson(domain), + 'lastSyncedAt': serializer.toJson(lastSyncedAt), + }; + } + + SyncMetadataData copyWith({String? domain, DateTime? lastSyncedAt}) => + SyncMetadataData( + domain: domain ?? this.domain, + lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt, + ); + SyncMetadataData copyWithCompanion(SyncMetadataCompanion data) { + return SyncMetadataData( + domain: data.domain.present ? data.domain.value : this.domain, + lastSyncedAt: data.lastSyncedAt.present + ? data.lastSyncedAt.value + : this.lastSyncedAt, + ); + } + + @override + String toString() { + return (StringBuffer('SyncMetadataData(') + ..write('domain: $domain, ') + ..write('lastSyncedAt: $lastSyncedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(domain, lastSyncedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SyncMetadataData && + other.domain == this.domain && + other.lastSyncedAt == this.lastSyncedAt); +} + +class SyncMetadataCompanion extends UpdateCompanion { + final Value domain; + final Value lastSyncedAt; + final Value rowid; + const SyncMetadataCompanion({ + this.domain = const Value.absent(), + this.lastSyncedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + SyncMetadataCompanion.insert({ + required String domain, + required DateTime lastSyncedAt, + this.rowid = const Value.absent(), + }) : domain = Value(domain), + lastSyncedAt = Value(lastSyncedAt); + static Insertable custom({ + Expression? domain, + Expression? lastSyncedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (domain != null) 'domain': domain, + if (lastSyncedAt != null) 'last_synced_at': lastSyncedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + SyncMetadataCompanion copyWith({ + Value? domain, + Value? lastSyncedAt, + Value? rowid, + }) { + return SyncMetadataCompanion( + domain: domain ?? this.domain, + lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (domain.present) { + map['domain'] = Variable(domain.value); + } + if (lastSyncedAt.present) { + map['last_synced_at'] = Variable(lastSyncedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SyncMetadataCompanion(') + ..write('domain: $domain, ') + ..write('lastSyncedAt: $lastSyncedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$FabledDatabase extends GeneratedDatabase { + _$FabledDatabase(QueryExecutor e) : super(e); + $FabledDatabaseManager get managers => $FabledDatabaseManager(this); + late final $CachedNotesTable cachedNotes = $CachedNotesTable(this); + late final $SyncMetadataTable syncMetadata = $SyncMetadataTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + cachedNotes, + syncMetadata, + ]; +} + +typedef $$CachedNotesTableCreateCompanionBuilder = + CachedNotesCompanion Function({ + Value id, + required String title, + required String body, + Value tagsJson, + Value noteType, + Value projectId, + Value milestoneId, + required DateTime createdAt, + required DateTime updatedAt, + Value cachedAt, + }); +typedef $$CachedNotesTableUpdateCompanionBuilder = + CachedNotesCompanion Function({ + Value id, + Value title, + Value body, + Value tagsJson, + Value noteType, + Value projectId, + Value milestoneId, + Value createdAt, + Value updatedAt, + Value cachedAt, + }); + +class $$CachedNotesTableFilterComposer + extends Composer<_$FabledDatabase, $CachedNotesTable> { + $$CachedNotesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get body => $composableBuilder( + column: $table.body, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get tagsJson => $composableBuilder( + column: $table.tagsJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get noteType => $composableBuilder( + column: $table.noteType, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get projectId => $composableBuilder( + column: $table.projectId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get milestoneId => $composableBuilder( + column: $table.milestoneId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$CachedNotesTableOrderingComposer + extends Composer<_$FabledDatabase, $CachedNotesTable> { + $$CachedNotesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get body => $composableBuilder( + column: $table.body, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get tagsJson => $composableBuilder( + column: $table.tagsJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get noteType => $composableBuilder( + column: $table.noteType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get projectId => $composableBuilder( + column: $table.projectId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get milestoneId => $composableBuilder( + column: $table.milestoneId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$CachedNotesTableAnnotationComposer + extends Composer<_$FabledDatabase, $CachedNotesTable> { + $$CachedNotesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get body => + $composableBuilder(column: $table.body, builder: (column) => column); + + GeneratedColumn get tagsJson => + $composableBuilder(column: $table.tagsJson, builder: (column) => column); + + GeneratedColumn get noteType => + $composableBuilder(column: $table.noteType, builder: (column) => column); + + GeneratedColumn get projectId => + $composableBuilder(column: $table.projectId, builder: (column) => column); + + GeneratedColumn get milestoneId => $composableBuilder( + column: $table.milestoneId, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get cachedAt => + $composableBuilder(column: $table.cachedAt, builder: (column) => column); +} + +class $$CachedNotesTableTableManager + extends + RootTableManager< + _$FabledDatabase, + $CachedNotesTable, + CachedNote, + $$CachedNotesTableFilterComposer, + $$CachedNotesTableOrderingComposer, + $$CachedNotesTableAnnotationComposer, + $$CachedNotesTableCreateCompanionBuilder, + $$CachedNotesTableUpdateCompanionBuilder, + ( + CachedNote, + BaseReferences<_$FabledDatabase, $CachedNotesTable, CachedNote>, + ), + CachedNote, + PrefetchHooks Function() + > { + $$CachedNotesTableTableManager(_$FabledDatabase db, $CachedNotesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$CachedNotesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$CachedNotesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$CachedNotesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value title = const Value.absent(), + Value body = const Value.absent(), + Value tagsJson = const Value.absent(), + Value noteType = const Value.absent(), + Value projectId = const Value.absent(), + Value milestoneId = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value cachedAt = const Value.absent(), + }) => CachedNotesCompanion( + id: id, + title: title, + body: body, + tagsJson: tagsJson, + noteType: noteType, + projectId: projectId, + milestoneId: milestoneId, + createdAt: createdAt, + updatedAt: updatedAt, + cachedAt: cachedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String title, + required String body, + Value tagsJson = const Value.absent(), + Value noteType = const Value.absent(), + Value projectId = const Value.absent(), + Value milestoneId = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + Value cachedAt = const Value.absent(), + }) => CachedNotesCompanion.insert( + id: id, + title: title, + body: body, + tagsJson: tagsJson, + noteType: noteType, + projectId: projectId, + milestoneId: milestoneId, + createdAt: createdAt, + updatedAt: updatedAt, + cachedAt: cachedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$CachedNotesTableProcessedTableManager = + ProcessedTableManager< + _$FabledDatabase, + $CachedNotesTable, + CachedNote, + $$CachedNotesTableFilterComposer, + $$CachedNotesTableOrderingComposer, + $$CachedNotesTableAnnotationComposer, + $$CachedNotesTableCreateCompanionBuilder, + $$CachedNotesTableUpdateCompanionBuilder, + ( + CachedNote, + BaseReferences<_$FabledDatabase, $CachedNotesTable, CachedNote>, + ), + CachedNote, + PrefetchHooks Function() + >; +typedef $$SyncMetadataTableCreateCompanionBuilder = + SyncMetadataCompanion Function({ + required String domain, + required DateTime lastSyncedAt, + Value rowid, + }); +typedef $$SyncMetadataTableUpdateCompanionBuilder = + SyncMetadataCompanion Function({ + Value domain, + Value lastSyncedAt, + Value rowid, + }); + +class $$SyncMetadataTableFilterComposer + extends Composer<_$FabledDatabase, $SyncMetadataTable> { + $$SyncMetadataTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get domain => $composableBuilder( + column: $table.domain, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastSyncedAt => $composableBuilder( + column: $table.lastSyncedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$SyncMetadataTableOrderingComposer + extends Composer<_$FabledDatabase, $SyncMetadataTable> { + $$SyncMetadataTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get domain => $composableBuilder( + column: $table.domain, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastSyncedAt => $composableBuilder( + column: $table.lastSyncedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$SyncMetadataTableAnnotationComposer + extends Composer<_$FabledDatabase, $SyncMetadataTable> { + $$SyncMetadataTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get domain => + $composableBuilder(column: $table.domain, builder: (column) => column); + + GeneratedColumn get lastSyncedAt => $composableBuilder( + column: $table.lastSyncedAt, + builder: (column) => column, + ); +} + +class $$SyncMetadataTableTableManager + extends + RootTableManager< + _$FabledDatabase, + $SyncMetadataTable, + SyncMetadataData, + $$SyncMetadataTableFilterComposer, + $$SyncMetadataTableOrderingComposer, + $$SyncMetadataTableAnnotationComposer, + $$SyncMetadataTableCreateCompanionBuilder, + $$SyncMetadataTableUpdateCompanionBuilder, + ( + SyncMetadataData, + BaseReferences< + _$FabledDatabase, + $SyncMetadataTable, + SyncMetadataData + >, + ), + SyncMetadataData, + PrefetchHooks Function() + > { + $$SyncMetadataTableTableManager(_$FabledDatabase db, $SyncMetadataTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$SyncMetadataTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$SyncMetadataTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$SyncMetadataTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value domain = const Value.absent(), + Value lastSyncedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => SyncMetadataCompanion( + domain: domain, + lastSyncedAt: lastSyncedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String domain, + required DateTime lastSyncedAt, + Value rowid = const Value.absent(), + }) => SyncMetadataCompanion.insert( + domain: domain, + lastSyncedAt: lastSyncedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$SyncMetadataTableProcessedTableManager = + ProcessedTableManager< + _$FabledDatabase, + $SyncMetadataTable, + SyncMetadataData, + $$SyncMetadataTableFilterComposer, + $$SyncMetadataTableOrderingComposer, + $$SyncMetadataTableAnnotationComposer, + $$SyncMetadataTableCreateCompanionBuilder, + $$SyncMetadataTableUpdateCompanionBuilder, + ( + SyncMetadataData, + BaseReferences<_$FabledDatabase, $SyncMetadataTable, SyncMetadataData>, + ), + SyncMetadataData, + PrefetchHooks Function() + >; + +class $FabledDatabaseManager { + final _$FabledDatabase _db; + $FabledDatabaseManager(this._db); + $$CachedNotesTableTableManager get cachedNotes => + $$CachedNotesTableTableManager(_db, _db.cachedNotes); + $$SyncMetadataTableTableManager get syncMetadata => + $$SyncMetadataTableTableManager(_db, _db.syncMetadata); +} diff --git a/lib/data/repositories/notes_repository.dart b/lib/data/repositories/notes_repository.dart index 681d4f8..4744748 100644 --- a/lib/data/repositories/notes_repository.dart +++ b/lib/data/repositories/notes_repository.dart @@ -1,12 +1,50 @@ +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. +/// +/// 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 (create / update / delete) hit the +/// server then sync the cache; offline write queueing is Phase 3 work and +/// not yet wired here — write methods will throw on `NetworkException` in +/// the meantime. +/// +/// `AuthStatus.offline` is owned by `AuthNotifier.verify()` (a periodic +/// heartbeat). This repository deliberately does not poke that state — its +/// only job is to serve what's cached when the network is unreachable. class NotesRepository { final NotesApi _api; - const NotesRepository(this._api); + final FabledDatabase _db; - Future> getAll() => _api.getAll(); - Future getOne(int id) => _api.getOne(id); + const NotesRepository(this._api, this._db); + + Future> 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 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 create( String title, @@ -14,9 +52,17 @@ class NotesRepository { List tags = const [], int? projectId, String noteType = 'note', - }) => - _api.create(title, body, - tags: tags, projectId: projectId, noteType: noteType); + }) async { + final note = await _api.create( + title, + body, + tags: tags, + projectId: projectId, + noteType: noteType, + ); + await _db.upsertNote(note); + return note; + } Future update( int id, @@ -26,12 +72,27 @@ class NotesRepository { int? projectId, bool clearProject = false, String noteType = 'note', - }) => - _api.update(id, title, body, - tags: tags, - projectId: projectId, - clearProject: clearProject, - noteType: noteType); + }) async { + final updated = await _api.update( + id, + title, + body, + tags: tags, + projectId: projectId, + clearProject: clearProject, + noteType: noteType, + ); + await _db.upsertNote(updated); + return updated; + } - Future delete(int id) => _api.delete(id); + Future delete(int id) async { + await _api.delete(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 lastSyncedAt() => _db.getLastSync(kSyncDomainNotes); } diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart index 5b0a50e..6b194b3 100644 --- a/lib/providers/api_client_provider.dart +++ b/lib/providers/api_client_provider.dart @@ -15,6 +15,7 @@ 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/knowledge_repository.dart'; @@ -60,12 +61,24 @@ final projectsApiProvider = Provider((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((ref) { + final db = FabledDatabase(); + ref.onDispose(db.close); + return db; +}); + final authRepositoryProvider = Provider((ref) { return AuthRepository(ref.watch(authApiProvider)); }); final notesRepositoryProvider = Provider((ref) { - return NotesRepository(ref.watch(notesApiProvider)); + return NotesRepository( + ref.watch(notesApiProvider), + ref.watch(fabledDatabaseProvider), + ); }); final tasksRepositoryProvider = Provider((ref) { diff --git a/lib/widgets/offline_banner.dart b/lib/widgets/offline_banner.dart index 94137e1..8cfa6f1 100644 --- a/lib/widgets/offline_banner.dart +++ b/lib/widgets/offline_banner.dart @@ -1,12 +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}); @@ -16,6 +19,38 @@ class OfflineBanner extends ConsumerStatefulWidget { class _OfflineBannerState extends ConsumerState { 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 _loadLastSync() async { + if (!mounted) return; + try { + final ts = + await ref.read(notesRepositoryProvider).lastSyncedAt(); + if (!mounted) return; + setState(() => _lastSync = ts); + } catch (_) { + // Non-critical — banner just won't show the sync hint. + } + } Future _retry() async { if (_retrying) return; @@ -27,12 +62,30 @@ class _OfflineBannerState extends ConsumerState { } } + 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!); + final message = ago == null + ? 'Offline — showing cached data.' + : 'Offline — last sync $ago.'; + return Material( color: scheme.errorContainer, child: SafeArea( @@ -46,7 +99,7 @@ class _OfflineBannerState extends ConsumerState { const SizedBox(width: 8), Expanded( child: Text( - 'Offline — showing cached data.', + message, style: TextStyle(color: scheme.onErrorContainer), ), ), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f06a07c..1b65d65 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include 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); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 61405b3..81e44a2 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_timezone open_file_linux record_linux + sqlite3_flutter_libs url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 74ec380..8885969 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/pubspec.lock b/pubspec.lock index e7d0b33..c24a653 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: @@ -657,7 +745,7 @@ packages: source: hosted version: "3.2.1" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -808,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: @@ -989,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: @@ -1013,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: @@ -1037,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: diff --git a/pubspec.yaml b/pubspec.yaml index 5a4fc9e..7698e33 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,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 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index f1cac88..3d5668f 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include 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")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index c57f7ba..86c9845 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_timezone permission_handler_windows record_windows + sqlite3_flutter_libs url_launcher_windows ) From fe10067761f452d4c73f2441111387a4b84b522c Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Tue, 28 Apr 2026 21:22:06 -0400 Subject: [PATCH 3/5] =?UTF-8?q?feat(offline):=20tier=202=20phase=202=20?= =?UTF-8?q?=E2=80=94=20extend=20cache=20to=20tasks/projects/milestones/eve?= =?UTF-8?q?nts/conversations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/data/local/database.dart | 542 +- lib/data/local/database.g.dart | 4569 +++++++++++++++++ lib/data/repositories/chat_repository.dart | 31 +- lib/data/repositories/events_repository.dart | 45 + .../repositories/milestones_repository.dart | 61 +- lib/data/repositories/notes_repository.dart | 5 - .../repositories/projects_repository.dart | 84 +- lib/data/repositories/tasks_repository.dart | 95 +- lib/providers/api_client_provider.dart | 28 +- lib/providers/calendar_provider.dart | 6 +- lib/screens/calendar/event_form_sheet.dart | 8 +- lib/widgets/offline_banner.dart | 6 +- 12 files changed, 5376 insertions(+), 104 deletions(-) create mode 100644 lib/data/repositories/events_repository.dart diff --git a/lib/data/local/database.dart b/lib/data/local/database.dart index baf5030..de84957 100644 --- a/lib/data/local/database.dart +++ b/lib/data/local/database.dart @@ -7,16 +7,24 @@ 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'; -/// Local cache of server-side Notes for offline reads. -/// -/// Mirrors the shape of [Note] one-to-one. Tags are stored as a JSON-encoded -/// string because SQLite has no native list type and Drift's typed converters -/// add ceremony we don't need for a simple read cache. `cachedAt` tracks when -/// the row was last written from a successful API response. +// ── 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()(); @@ -34,9 +42,92 @@ class CachedNotes extends Table { Set get primaryKey => {id}; } -/// Per-domain sync timestamp. Keyed by a short domain string ('notes', -/// 'tasks', 'projects', 'events', etc.) so the OfflineBanner and any -/// stale-while-revalidate logic can ask "when did we last fetch this". +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 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 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 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 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 get primaryKey => {id}; +} + class SyncMetadata extends Table { TextColumn get domain => text()(); DateTimeColumn get lastSyncedAt => dateTime()(); @@ -46,14 +137,42 @@ class SyncMetadata extends Table { } 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 { FabledDatabase() : super(_openConnection()); FabledDatabase.forTesting(super.executor); @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 ──────────────────────────────────────────────────────────────── @@ -70,33 +189,237 @@ class FabledDatabase extends _$FabledDatabase { 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 replaceAllNotes(List notes) async { await transaction(() async { await delete(cachedNotes).go(); if (notes.isNotEmpty) { await batch((b) { - b.insertAll( - cachedNotes, - notes.map(_companionFromNote).toList(), - ); + b.insertAll(cachedNotes, notes.map(_noteToCompanion).toList()); }); } await _setLastSyncIn(kSyncDomainNotes, DateTime.now()); }); } - /// Upsert a single note (after a successful create / update / getOne). Future upsertNote(Note note) async { - await into(cachedNotes).insertOnConflictUpdate(_companionFromNote(note)); + await into(cachedNotes).insertOnConflictUpdate(_noteToCompanion(note)); } Future deleteNote(int id) async { await (delete(cachedNotes)..where((t) => t.id.equals(id))).go(); } + // ── Tasks ──────────────────────────────────────────────────────────────── + + Future> getAllTasks() async { + final rows = await (select(cachedTasks) + ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])) + .get(); + return rows.map(_taskFromRow).toList(); + } + + Future getTask(int id) async { + final row = await (select(cachedTasks)..where((t) => t.id.equals(id))) + .getSingleOrNull(); + return row == null ? null : _taskFromRow(row); + } + + Future> 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> 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 replaceAllTasks(List 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 upsertTask(Task task) async { + await into(cachedTasks).insertOnConflictUpdate(_taskToCompanion(task)); + } + + Future deleteTask(int id) async { + await (delete(cachedTasks)..where((t) => t.id.equals(id))).go(); + } + + // ── Projects ───────────────────────────────────────────────────────────── + + Future> getAllProjects() async { + final rows = await (select(cachedProjects) + ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])) + .get(); + return rows.map(_projectFromRow).toList(); + } + + Future getProject(int id) async { + final row = await (select(cachedProjects)..where((t) => t.id.equals(id))) + .getSingleOrNull(); + return row == null ? null : _projectFromRow(row); + } + + Future replaceAllProjects(List 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 upsertProject(Project project) async { + await into(cachedProjects) + .insertOnConflictUpdate(_projectToCompanion(project)); + } + + Future deleteProject(int id) async { + await (delete(cachedProjects)..where((t) => t.id.equals(id))).go(); + } + + // ── Milestones ─────────────────────────────────────────────────────────── + + Future> 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 replaceMilestonesForProject( + int projectId, + List 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 upsertMilestone(Milestone milestone) async { + await into(cachedMilestones) + .insertOnConflictUpdate(_milestoneToCompanion(milestone)); + } + + Future 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> 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 replaceEventsInRange( + DateTime from, + DateTime to, + List 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 upsertEvent(CalendarEvent event) async { + await into(cachedCalendarEvents) + .insertOnConflictUpdate(_eventToCompanion(event)); + } + + Future deleteEvent(int id) async { + await (delete(cachedCalendarEvents)..where((t) => t.id.equals(id))).go(); + } + + // ── Conversations (chat list — not messages) ───────────────────────────── + + Future> getAllConversations() async { + final rows = await (select(cachedConversations) + ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])) + .get(); + return rows.map(_conversationFromRow).toList(); + } + + Future replaceAllConversations( + List 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 deleteConversation(int id) async { + await (delete(cachedConversations)..where((t) => t.id.equals(id))).go(); + } + // ── Sync metadata ──────────────────────────────────────────────────────── Future getLastSync(String domain) async { @@ -106,6 +429,17 @@ class FabledDatabase extends _$FabledDatabase { 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 getLatestSync() async { + final row = await (select(syncMetadata) + ..orderBy([(t) => OrderingTerm.desc(t.lastSyncedAt)]) + ..limit(1)) + .getSingleOrNull(); + return row?.lastSyncedAt; + } + Future _setLastSyncIn(String domain, DateTime timestamp) { return into(syncMetadata).insertOnConflictUpdate( SyncMetadataCompanion( @@ -116,28 +450,156 @@ class FabledDatabase extends _$FabledDatabase { } } -CachedNotesCompanion _companionFromNote(Note note) => CachedNotesCompanion( - id: Value(note.id), - title: Value(note.title), - body: Value(note.body), - tagsJson: Value(jsonEncode(note.tags)), - noteType: Value(note.noteType), - projectId: Value(note.projectId), - milestoneId: Value(note.milestoneId), - createdAt: Value(note.createdAt), - updatedAt: Value(note.updatedAt), +// ── 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 row) => Note( - id: row.id, - title: row.title, - body: row.body, - tags: (jsonDecode(row.tagsJson) as List).cast(), - noteType: row.noteType, - projectId: row.projectId, - milestoneId: row.milestoneId, - createdAt: row.createdAt, - updatedAt: row.updatedAt, +Note _noteFromRow(CachedNote r) => Note( + id: r.id, + title: r.title, + body: r.body, + tags: (jsonDecode(r.tagsJson) as List).cast(), + 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() { diff --git a/lib/data/local/database.g.dart b/lib/data/local/database.g.dart index 8c99cff..2598bca 100644 --- a/lib/data/local/database.g.dart +++ b/lib/data/local/database.g.dart @@ -594,6 +594,3021 @@ class CachedNotesCompanion extends UpdateCompanion { } } +class $CachedTasksTable extends CachedTasks + with TableInfo<$CachedTasksTable, CachedTask> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CachedTasksTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _descriptionMeta = const VerificationMeta( + 'description', + ); + @override + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + @override + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _priorityMeta = const VerificationMeta( + 'priority', + ); + @override + late final GeneratedColumn priority = GeneratedColumn( + 'priority', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _dueDateMeta = const VerificationMeta( + 'dueDate', + ); + @override + late final GeneratedColumn dueDate = GeneratedColumn( + 'due_date', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _projectIdMeta = const VerificationMeta( + 'projectId', + ); + @override + late final GeneratedColumn projectId = GeneratedColumn( + 'project_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _milestoneIdMeta = const VerificationMeta( + 'milestoneId', + ); + @override + late final GeneratedColumn milestoneId = GeneratedColumn( + 'milestone_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _parentIdMeta = const VerificationMeta( + 'parentId', + ); + @override + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _cachedAtMeta = const VerificationMeta( + 'cachedAt', + ); + @override + late final GeneratedColumn cachedAt = GeneratedColumn( + 'cached_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + clientDefault: () => DateTime.now(), + ); + @override + List get $columns => [ + id, + title, + description, + status, + priority, + dueDate, + projectId, + milestoneId, + parentId, + createdAt, + updatedAt, + cachedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'cached_tasks'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('description')) { + context.handle( + _descriptionMeta, + description.isAcceptableOrUnknown( + data['description']!, + _descriptionMeta, + ), + ); + } + if (data.containsKey('status')) { + context.handle( + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), + ); + } else if (isInserting) { + context.missing(_statusMeta); + } + if (data.containsKey('priority')) { + context.handle( + _priorityMeta, + priority.isAcceptableOrUnknown(data['priority']!, _priorityMeta), + ); + } else if (isInserting) { + context.missing(_priorityMeta); + } + if (data.containsKey('due_date')) { + context.handle( + _dueDateMeta, + dueDate.isAcceptableOrUnknown(data['due_date']!, _dueDateMeta), + ); + } + if (data.containsKey('project_id')) { + context.handle( + _projectIdMeta, + projectId.isAcceptableOrUnknown(data['project_id']!, _projectIdMeta), + ); + } + if (data.containsKey('milestone_id')) { + context.handle( + _milestoneIdMeta, + milestoneId.isAcceptableOrUnknown( + data['milestone_id']!, + _milestoneIdMeta, + ), + ); + } + if (data.containsKey('parent_id')) { + context.handle( + _parentIdMeta, + parentId.isAcceptableOrUnknown(data['parent_id']!, _parentIdMeta), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + if (data.containsKey('cached_at')) { + context.handle( + _cachedAtMeta, + cachedAt.isAcceptableOrUnknown(data['cached_at']!, _cachedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + CachedTask map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CachedTask( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + ), + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + priority: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}priority'], + )!, + dueDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}due_date'], + ), + projectId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}project_id'], + ), + milestoneId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}milestone_id'], + ), + parentId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}parent_id'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + cachedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}cached_at'], + )!, + ); + } + + @override + $CachedTasksTable createAlias(String alias) { + return $CachedTasksTable(attachedDatabase, alias); + } +} + +class CachedTask extends DataClass implements Insertable { + final int id; + final String title; + final String? description; + final String status; + final String priority; + final DateTime? dueDate; + final int? projectId; + final int? milestoneId; + final int? parentId; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime cachedAt; + const CachedTask({ + required this.id, + required this.title, + this.description, + required this.status, + required this.priority, + this.dueDate, + this.projectId, + this.milestoneId, + this.parentId, + required this.createdAt, + required this.updatedAt, + required this.cachedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + if (!nullToAbsent || description != null) { + map['description'] = Variable(description); + } + map['status'] = Variable(status); + map['priority'] = Variable(priority); + if (!nullToAbsent || dueDate != null) { + map['due_date'] = Variable(dueDate); + } + if (!nullToAbsent || projectId != null) { + map['project_id'] = Variable(projectId); + } + if (!nullToAbsent || milestoneId != null) { + map['milestone_id'] = Variable(milestoneId); + } + if (!nullToAbsent || parentId != null) { + map['parent_id'] = Variable(parentId); + } + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['cached_at'] = Variable(cachedAt); + return map; + } + + CachedTasksCompanion toCompanion(bool nullToAbsent) { + return CachedTasksCompanion( + id: Value(id), + title: Value(title), + description: description == null && nullToAbsent + ? const Value.absent() + : Value(description), + status: Value(status), + priority: Value(priority), + dueDate: dueDate == null && nullToAbsent + ? const Value.absent() + : Value(dueDate), + projectId: projectId == null && nullToAbsent + ? const Value.absent() + : Value(projectId), + milestoneId: milestoneId == null && nullToAbsent + ? const Value.absent() + : Value(milestoneId), + parentId: parentId == null && nullToAbsent + ? const Value.absent() + : Value(parentId), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + cachedAt: Value(cachedAt), + ); + } + + factory CachedTask.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CachedTask( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + description: serializer.fromJson(json['description']), + status: serializer.fromJson(json['status']), + priority: serializer.fromJson(json['priority']), + dueDate: serializer.fromJson(json['dueDate']), + projectId: serializer.fromJson(json['projectId']), + milestoneId: serializer.fromJson(json['milestoneId']), + parentId: serializer.fromJson(json['parentId']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + cachedAt: serializer.fromJson(json['cachedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'description': serializer.toJson(description), + 'status': serializer.toJson(status), + 'priority': serializer.toJson(priority), + 'dueDate': serializer.toJson(dueDate), + 'projectId': serializer.toJson(projectId), + 'milestoneId': serializer.toJson(milestoneId), + 'parentId': serializer.toJson(parentId), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'cachedAt': serializer.toJson(cachedAt), + }; + } + + CachedTask copyWith({ + int? id, + String? title, + Value description = const Value.absent(), + String? status, + String? priority, + Value dueDate = const Value.absent(), + Value projectId = const Value.absent(), + Value milestoneId = const Value.absent(), + Value parentId = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + DateTime? cachedAt, + }) => CachedTask( + id: id ?? this.id, + title: title ?? this.title, + description: description.present ? description.value : this.description, + status: status ?? this.status, + priority: priority ?? this.priority, + dueDate: dueDate.present ? dueDate.value : this.dueDate, + projectId: projectId.present ? projectId.value : this.projectId, + milestoneId: milestoneId.present ? milestoneId.value : this.milestoneId, + parentId: parentId.present ? parentId.value : this.parentId, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + cachedAt: cachedAt ?? this.cachedAt, + ); + CachedTask copyWithCompanion(CachedTasksCompanion data) { + return CachedTask( + id: data.id.present ? data.id.value : this.id, + title: data.title.present ? data.title.value : this.title, + description: data.description.present + ? data.description.value + : this.description, + status: data.status.present ? data.status.value : this.status, + priority: data.priority.present ? data.priority.value : this.priority, + dueDate: data.dueDate.present ? data.dueDate.value : this.dueDate, + projectId: data.projectId.present ? data.projectId.value : this.projectId, + milestoneId: data.milestoneId.present + ? data.milestoneId.value + : this.milestoneId, + parentId: data.parentId.present ? data.parentId.value : this.parentId, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + cachedAt: data.cachedAt.present ? data.cachedAt.value : this.cachedAt, + ); + } + + @override + String toString() { + return (StringBuffer('CachedTask(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('description: $description, ') + ..write('status: $status, ') + ..write('priority: $priority, ') + ..write('dueDate: $dueDate, ') + ..write('projectId: $projectId, ') + ..write('milestoneId: $milestoneId, ') + ..write('parentId: $parentId, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('cachedAt: $cachedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + title, + description, + status, + priority, + dueDate, + projectId, + milestoneId, + parentId, + createdAt, + updatedAt, + cachedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CachedTask && + other.id == this.id && + other.title == this.title && + other.description == this.description && + other.status == this.status && + other.priority == this.priority && + other.dueDate == this.dueDate && + other.projectId == this.projectId && + other.milestoneId == this.milestoneId && + other.parentId == this.parentId && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.cachedAt == this.cachedAt); +} + +class CachedTasksCompanion extends UpdateCompanion { + final Value id; + final Value title; + final Value description; + final Value status; + final Value priority; + final Value dueDate; + final Value projectId; + final Value milestoneId; + final Value parentId; + final Value createdAt; + final Value updatedAt; + final Value cachedAt; + const CachedTasksCompanion({ + this.id = const Value.absent(), + this.title = const Value.absent(), + this.description = const Value.absent(), + this.status = const Value.absent(), + this.priority = const Value.absent(), + this.dueDate = const Value.absent(), + this.projectId = const Value.absent(), + this.milestoneId = const Value.absent(), + this.parentId = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.cachedAt = const Value.absent(), + }); + CachedTasksCompanion.insert({ + this.id = const Value.absent(), + required String title, + this.description = const Value.absent(), + required String status, + required String priority, + this.dueDate = const Value.absent(), + this.projectId = const Value.absent(), + this.milestoneId = const Value.absent(), + this.parentId = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + this.cachedAt = const Value.absent(), + }) : title = Value(title), + status = Value(status), + priority = Value(priority), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? title, + Expression? description, + Expression? status, + Expression? priority, + Expression? dueDate, + Expression? projectId, + Expression? milestoneId, + Expression? parentId, + Expression? createdAt, + Expression? updatedAt, + Expression? cachedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (status != null) 'status': status, + if (priority != null) 'priority': priority, + if (dueDate != null) 'due_date': dueDate, + if (projectId != null) 'project_id': projectId, + if (milestoneId != null) 'milestone_id': milestoneId, + if (parentId != null) 'parent_id': parentId, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (cachedAt != null) 'cached_at': cachedAt, + }); + } + + CachedTasksCompanion copyWith({ + Value? id, + Value? title, + Value? description, + Value? status, + Value? priority, + Value? dueDate, + Value? projectId, + Value? milestoneId, + Value? parentId, + Value? createdAt, + Value? updatedAt, + Value? cachedAt, + }) { + return CachedTasksCompanion( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + status: status ?? this.status, + priority: priority ?? this.priority, + dueDate: dueDate ?? this.dueDate, + projectId: projectId ?? this.projectId, + milestoneId: milestoneId ?? this.milestoneId, + parentId: parentId ?? this.parentId, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + cachedAt: cachedAt ?? this.cachedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (priority.present) { + map['priority'] = Variable(priority.value); + } + if (dueDate.present) { + map['due_date'] = Variable(dueDate.value); + } + if (projectId.present) { + map['project_id'] = Variable(projectId.value); + } + if (milestoneId.present) { + map['milestone_id'] = Variable(milestoneId.value); + } + if (parentId.present) { + map['parent_id'] = Variable(parentId.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (cachedAt.present) { + map['cached_at'] = Variable(cachedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CachedTasksCompanion(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('description: $description, ') + ..write('status: $status, ') + ..write('priority: $priority, ') + ..write('dueDate: $dueDate, ') + ..write('projectId: $projectId, ') + ..write('milestoneId: $milestoneId, ') + ..write('parentId: $parentId, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('cachedAt: $cachedAt') + ..write(')')) + .toString(); + } +} + +class $CachedProjectsTable extends CachedProjects + with TableInfo<$CachedProjectsTable, CachedProject> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CachedProjectsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _descriptionMeta = const VerificationMeta( + 'description', + ); + @override + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _goalMeta = const VerificationMeta('goal'); + @override + late final GeneratedColumn goal = GeneratedColumn( + 'goal', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + @override + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _colorMeta = const VerificationMeta('color'); + @override + late final GeneratedColumn color = GeneratedColumn( + 'color', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _autoSummaryMeta = const VerificationMeta( + 'autoSummary', + ); + @override + late final GeneratedColumn autoSummary = GeneratedColumn( + 'auto_summary', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _cachedAtMeta = const VerificationMeta( + 'cachedAt', + ); + @override + late final GeneratedColumn cachedAt = GeneratedColumn( + 'cached_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + clientDefault: () => DateTime.now(), + ); + @override + List get $columns => [ + id, + title, + description, + goal, + status, + color, + autoSummary, + createdAt, + updatedAt, + cachedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'cached_projects'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('description')) { + context.handle( + _descriptionMeta, + description.isAcceptableOrUnknown( + data['description']!, + _descriptionMeta, + ), + ); + } + if (data.containsKey('goal')) { + context.handle( + _goalMeta, + goal.isAcceptableOrUnknown(data['goal']!, _goalMeta), + ); + } + if (data.containsKey('status')) { + context.handle( + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), + ); + } else if (isInserting) { + context.missing(_statusMeta); + } + if (data.containsKey('color')) { + context.handle( + _colorMeta, + color.isAcceptableOrUnknown(data['color']!, _colorMeta), + ); + } + if (data.containsKey('auto_summary')) { + context.handle( + _autoSummaryMeta, + autoSummary.isAcceptableOrUnknown( + data['auto_summary']!, + _autoSummaryMeta, + ), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + if (data.containsKey('cached_at')) { + context.handle( + _cachedAtMeta, + cachedAt.isAcceptableOrUnknown(data['cached_at']!, _cachedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + CachedProject map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CachedProject( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + ), + goal: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}goal'], + ), + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + color: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}color'], + ), + autoSummary: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}auto_summary'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + cachedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}cached_at'], + )!, + ); + } + + @override + $CachedProjectsTable createAlias(String alias) { + return $CachedProjectsTable(attachedDatabase, alias); + } +} + +class CachedProject extends DataClass implements Insertable { + final int id; + final String title; + final String? description; + final String? goal; + final String status; + final String? color; + final String? autoSummary; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime cachedAt; + const CachedProject({ + required this.id, + required this.title, + this.description, + this.goal, + required this.status, + this.color, + this.autoSummary, + required this.createdAt, + required this.updatedAt, + required this.cachedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + if (!nullToAbsent || description != null) { + map['description'] = Variable(description); + } + if (!nullToAbsent || goal != null) { + map['goal'] = Variable(goal); + } + map['status'] = Variable(status); + if (!nullToAbsent || color != null) { + map['color'] = Variable(color); + } + if (!nullToAbsent || autoSummary != null) { + map['auto_summary'] = Variable(autoSummary); + } + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['cached_at'] = Variable(cachedAt); + return map; + } + + CachedProjectsCompanion toCompanion(bool nullToAbsent) { + return CachedProjectsCompanion( + id: Value(id), + title: Value(title), + description: description == null && nullToAbsent + ? const Value.absent() + : Value(description), + goal: goal == null && nullToAbsent ? const Value.absent() : Value(goal), + status: Value(status), + color: color == null && nullToAbsent + ? const Value.absent() + : Value(color), + autoSummary: autoSummary == null && nullToAbsent + ? const Value.absent() + : Value(autoSummary), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + cachedAt: Value(cachedAt), + ); + } + + factory CachedProject.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CachedProject( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + description: serializer.fromJson(json['description']), + goal: serializer.fromJson(json['goal']), + status: serializer.fromJson(json['status']), + color: serializer.fromJson(json['color']), + autoSummary: serializer.fromJson(json['autoSummary']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + cachedAt: serializer.fromJson(json['cachedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'description': serializer.toJson(description), + 'goal': serializer.toJson(goal), + 'status': serializer.toJson(status), + 'color': serializer.toJson(color), + 'autoSummary': serializer.toJson(autoSummary), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'cachedAt': serializer.toJson(cachedAt), + }; + } + + CachedProject copyWith({ + int? id, + String? title, + Value description = const Value.absent(), + Value goal = const Value.absent(), + String? status, + Value color = const Value.absent(), + Value autoSummary = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + DateTime? cachedAt, + }) => CachedProject( + id: id ?? this.id, + title: title ?? this.title, + description: description.present ? description.value : this.description, + goal: goal.present ? goal.value : this.goal, + status: status ?? this.status, + color: color.present ? color.value : this.color, + autoSummary: autoSummary.present ? autoSummary.value : this.autoSummary, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + cachedAt: cachedAt ?? this.cachedAt, + ); + CachedProject copyWithCompanion(CachedProjectsCompanion data) { + return CachedProject( + id: data.id.present ? data.id.value : this.id, + title: data.title.present ? data.title.value : this.title, + description: data.description.present + ? data.description.value + : this.description, + goal: data.goal.present ? data.goal.value : this.goal, + status: data.status.present ? data.status.value : this.status, + color: data.color.present ? data.color.value : this.color, + autoSummary: data.autoSummary.present + ? data.autoSummary.value + : this.autoSummary, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + cachedAt: data.cachedAt.present ? data.cachedAt.value : this.cachedAt, + ); + } + + @override + String toString() { + return (StringBuffer('CachedProject(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('description: $description, ') + ..write('goal: $goal, ') + ..write('status: $status, ') + ..write('color: $color, ') + ..write('autoSummary: $autoSummary, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('cachedAt: $cachedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + title, + description, + goal, + status, + color, + autoSummary, + createdAt, + updatedAt, + cachedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CachedProject && + other.id == this.id && + other.title == this.title && + other.description == this.description && + other.goal == this.goal && + other.status == this.status && + other.color == this.color && + other.autoSummary == this.autoSummary && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.cachedAt == this.cachedAt); +} + +class CachedProjectsCompanion extends UpdateCompanion { + final Value id; + final Value title; + final Value description; + final Value goal; + final Value status; + final Value color; + final Value autoSummary; + final Value createdAt; + final Value updatedAt; + final Value cachedAt; + const CachedProjectsCompanion({ + this.id = const Value.absent(), + this.title = const Value.absent(), + this.description = const Value.absent(), + this.goal = const Value.absent(), + this.status = const Value.absent(), + this.color = const Value.absent(), + this.autoSummary = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.cachedAt = const Value.absent(), + }); + CachedProjectsCompanion.insert({ + this.id = const Value.absent(), + required String title, + this.description = const Value.absent(), + this.goal = const Value.absent(), + required String status, + this.color = const Value.absent(), + this.autoSummary = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + this.cachedAt = const Value.absent(), + }) : title = Value(title), + status = Value(status), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? title, + Expression? description, + Expression? goal, + Expression? status, + Expression? color, + Expression? autoSummary, + Expression? createdAt, + Expression? updatedAt, + Expression? cachedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (goal != null) 'goal': goal, + if (status != null) 'status': status, + if (color != null) 'color': color, + if (autoSummary != null) 'auto_summary': autoSummary, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (cachedAt != null) 'cached_at': cachedAt, + }); + } + + CachedProjectsCompanion copyWith({ + Value? id, + Value? title, + Value? description, + Value? goal, + Value? status, + Value? color, + Value? autoSummary, + Value? createdAt, + Value? updatedAt, + Value? cachedAt, + }) { + return CachedProjectsCompanion( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + goal: goal ?? this.goal, + status: status ?? this.status, + color: color ?? this.color, + autoSummary: autoSummary ?? this.autoSummary, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + cachedAt: cachedAt ?? this.cachedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (goal.present) { + map['goal'] = Variable(goal.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (autoSummary.present) { + map['auto_summary'] = Variable(autoSummary.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (cachedAt.present) { + map['cached_at'] = Variable(cachedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CachedProjectsCompanion(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('description: $description, ') + ..write('goal: $goal, ') + ..write('status: $status, ') + ..write('color: $color, ') + ..write('autoSummary: $autoSummary, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('cachedAt: $cachedAt') + ..write(')')) + .toString(); + } +} + +class $CachedMilestonesTable extends CachedMilestones + with TableInfo<$CachedMilestonesTable, CachedMilestone> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CachedMilestonesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _projectIdMeta = const VerificationMeta( + 'projectId', + ); + @override + late final GeneratedColumn projectId = GeneratedColumn( + 'project_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _descriptionMeta = const VerificationMeta( + 'description', + ); + @override + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + @override + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _orderIndexMeta = const VerificationMeta( + 'orderIndex', + ); + @override + late final GeneratedColumn orderIndex = GeneratedColumn( + 'order_index', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _totalMeta = const VerificationMeta('total'); + @override + late final GeneratedColumn total = GeneratedColumn( + 'total', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _completedMeta = const VerificationMeta( + 'completed', + ); + @override + late final GeneratedColumn completed = GeneratedColumn( + 'completed', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _pctMeta = const VerificationMeta('pct'); + @override + late final GeneratedColumn pct = GeneratedColumn( + 'pct', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: false, + defaultValue: const Constant(0.0), + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _cachedAtMeta = const VerificationMeta( + 'cachedAt', + ); + @override + late final GeneratedColumn cachedAt = GeneratedColumn( + 'cached_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + clientDefault: () => DateTime.now(), + ); + @override + List get $columns => [ + id, + projectId, + title, + description, + status, + orderIndex, + total, + completed, + pct, + createdAt, + updatedAt, + cachedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'cached_milestones'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('project_id')) { + context.handle( + _projectIdMeta, + projectId.isAcceptableOrUnknown(data['project_id']!, _projectIdMeta), + ); + } else if (isInserting) { + context.missing(_projectIdMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('description')) { + context.handle( + _descriptionMeta, + description.isAcceptableOrUnknown( + data['description']!, + _descriptionMeta, + ), + ); + } + if (data.containsKey('status')) { + context.handle( + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), + ); + } else if (isInserting) { + context.missing(_statusMeta); + } + if (data.containsKey('order_index')) { + context.handle( + _orderIndexMeta, + orderIndex.isAcceptableOrUnknown(data['order_index']!, _orderIndexMeta), + ); + } + if (data.containsKey('total')) { + context.handle( + _totalMeta, + total.isAcceptableOrUnknown(data['total']!, _totalMeta), + ); + } + if (data.containsKey('completed')) { + context.handle( + _completedMeta, + completed.isAcceptableOrUnknown(data['completed']!, _completedMeta), + ); + } + if (data.containsKey('pct')) { + context.handle( + _pctMeta, + pct.isAcceptableOrUnknown(data['pct']!, _pctMeta), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + if (data.containsKey('cached_at')) { + context.handle( + _cachedAtMeta, + cachedAt.isAcceptableOrUnknown(data['cached_at']!, _cachedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + CachedMilestone map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CachedMilestone( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + projectId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}project_id'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + ), + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + orderIndex: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}order_index'], + )!, + total: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}total'], + )!, + completed: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}completed'], + )!, + pct: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}pct'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + cachedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}cached_at'], + )!, + ); + } + + @override + $CachedMilestonesTable createAlias(String alias) { + return $CachedMilestonesTable(attachedDatabase, alias); + } +} + +class CachedMilestone extends DataClass implements Insertable { + final int id; + final int projectId; + final String title; + final String? description; + final String status; + final int orderIndex; + final int total; + final int completed; + final double pct; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime cachedAt; + const CachedMilestone({ + required this.id, + required this.projectId, + required this.title, + this.description, + required this.status, + required this.orderIndex, + required this.total, + required this.completed, + required this.pct, + required this.createdAt, + required this.updatedAt, + required this.cachedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['project_id'] = Variable(projectId); + map['title'] = Variable(title); + if (!nullToAbsent || description != null) { + map['description'] = Variable(description); + } + map['status'] = Variable(status); + map['order_index'] = Variable(orderIndex); + map['total'] = Variable(total); + map['completed'] = Variable(completed); + map['pct'] = Variable(pct); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['cached_at'] = Variable(cachedAt); + return map; + } + + CachedMilestonesCompanion toCompanion(bool nullToAbsent) { + return CachedMilestonesCompanion( + id: Value(id), + projectId: Value(projectId), + title: Value(title), + description: description == null && nullToAbsent + ? const Value.absent() + : Value(description), + status: Value(status), + orderIndex: Value(orderIndex), + total: Value(total), + completed: Value(completed), + pct: Value(pct), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + cachedAt: Value(cachedAt), + ); + } + + factory CachedMilestone.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CachedMilestone( + id: serializer.fromJson(json['id']), + projectId: serializer.fromJson(json['projectId']), + title: serializer.fromJson(json['title']), + description: serializer.fromJson(json['description']), + status: serializer.fromJson(json['status']), + orderIndex: serializer.fromJson(json['orderIndex']), + total: serializer.fromJson(json['total']), + completed: serializer.fromJson(json['completed']), + pct: serializer.fromJson(json['pct']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + cachedAt: serializer.fromJson(json['cachedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'projectId': serializer.toJson(projectId), + 'title': serializer.toJson(title), + 'description': serializer.toJson(description), + 'status': serializer.toJson(status), + 'orderIndex': serializer.toJson(orderIndex), + 'total': serializer.toJson(total), + 'completed': serializer.toJson(completed), + 'pct': serializer.toJson(pct), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'cachedAt': serializer.toJson(cachedAt), + }; + } + + CachedMilestone copyWith({ + int? id, + int? projectId, + String? title, + Value description = const Value.absent(), + String? status, + int? orderIndex, + int? total, + int? completed, + double? pct, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? cachedAt, + }) => CachedMilestone( + id: id ?? this.id, + projectId: projectId ?? this.projectId, + title: title ?? this.title, + description: description.present ? description.value : this.description, + status: status ?? this.status, + orderIndex: orderIndex ?? this.orderIndex, + total: total ?? this.total, + completed: completed ?? this.completed, + pct: pct ?? this.pct, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + cachedAt: cachedAt ?? this.cachedAt, + ); + CachedMilestone copyWithCompanion(CachedMilestonesCompanion data) { + return CachedMilestone( + id: data.id.present ? data.id.value : this.id, + projectId: data.projectId.present ? data.projectId.value : this.projectId, + title: data.title.present ? data.title.value : this.title, + description: data.description.present + ? data.description.value + : this.description, + status: data.status.present ? data.status.value : this.status, + orderIndex: data.orderIndex.present + ? data.orderIndex.value + : this.orderIndex, + total: data.total.present ? data.total.value : this.total, + completed: data.completed.present ? data.completed.value : this.completed, + pct: data.pct.present ? data.pct.value : this.pct, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + cachedAt: data.cachedAt.present ? data.cachedAt.value : this.cachedAt, + ); + } + + @override + String toString() { + return (StringBuffer('CachedMilestone(') + ..write('id: $id, ') + ..write('projectId: $projectId, ') + ..write('title: $title, ') + ..write('description: $description, ') + ..write('status: $status, ') + ..write('orderIndex: $orderIndex, ') + ..write('total: $total, ') + ..write('completed: $completed, ') + ..write('pct: $pct, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('cachedAt: $cachedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + projectId, + title, + description, + status, + orderIndex, + total, + completed, + pct, + createdAt, + updatedAt, + cachedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CachedMilestone && + other.id == this.id && + other.projectId == this.projectId && + other.title == this.title && + other.description == this.description && + other.status == this.status && + other.orderIndex == this.orderIndex && + other.total == this.total && + other.completed == this.completed && + other.pct == this.pct && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.cachedAt == this.cachedAt); +} + +class CachedMilestonesCompanion extends UpdateCompanion { + final Value id; + final Value projectId; + final Value title; + final Value description; + final Value status; + final Value orderIndex; + final Value total; + final Value completed; + final Value pct; + final Value createdAt; + final Value updatedAt; + final Value cachedAt; + const CachedMilestonesCompanion({ + this.id = const Value.absent(), + this.projectId = const Value.absent(), + this.title = const Value.absent(), + this.description = const Value.absent(), + this.status = const Value.absent(), + this.orderIndex = const Value.absent(), + this.total = const Value.absent(), + this.completed = const Value.absent(), + this.pct = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.cachedAt = const Value.absent(), + }); + CachedMilestonesCompanion.insert({ + this.id = const Value.absent(), + required int projectId, + required String title, + this.description = const Value.absent(), + required String status, + this.orderIndex = const Value.absent(), + this.total = const Value.absent(), + this.completed = const Value.absent(), + this.pct = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + this.cachedAt = const Value.absent(), + }) : projectId = Value(projectId), + title = Value(title), + status = Value(status), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? projectId, + Expression? title, + Expression? description, + Expression? status, + Expression? orderIndex, + Expression? total, + Expression? completed, + Expression? pct, + Expression? createdAt, + Expression? updatedAt, + Expression? cachedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (projectId != null) 'project_id': projectId, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (status != null) 'status': status, + if (orderIndex != null) 'order_index': orderIndex, + if (total != null) 'total': total, + if (completed != null) 'completed': completed, + if (pct != null) 'pct': pct, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (cachedAt != null) 'cached_at': cachedAt, + }); + } + + CachedMilestonesCompanion copyWith({ + Value? id, + Value? projectId, + Value? title, + Value? description, + Value? status, + Value? orderIndex, + Value? total, + Value? completed, + Value? pct, + Value? createdAt, + Value? updatedAt, + Value? cachedAt, + }) { + return CachedMilestonesCompanion( + id: id ?? this.id, + projectId: projectId ?? this.projectId, + title: title ?? this.title, + description: description ?? this.description, + status: status ?? this.status, + orderIndex: orderIndex ?? this.orderIndex, + total: total ?? this.total, + completed: completed ?? this.completed, + pct: pct ?? this.pct, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + cachedAt: cachedAt ?? this.cachedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (projectId.present) { + map['project_id'] = Variable(projectId.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (orderIndex.present) { + map['order_index'] = Variable(orderIndex.value); + } + if (total.present) { + map['total'] = Variable(total.value); + } + if (completed.present) { + map['completed'] = Variable(completed.value); + } + if (pct.present) { + map['pct'] = Variable(pct.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (cachedAt.present) { + map['cached_at'] = Variable(cachedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CachedMilestonesCompanion(') + ..write('id: $id, ') + ..write('projectId: $projectId, ') + ..write('title: $title, ') + ..write('description: $description, ') + ..write('status: $status, ') + ..write('orderIndex: $orderIndex, ') + ..write('total: $total, ') + ..write('completed: $completed, ') + ..write('pct: $pct, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('cachedAt: $cachedAt') + ..write(')')) + .toString(); + } +} + +class $CachedCalendarEventsTable extends CachedCalendarEvents + with TableInfo<$CachedCalendarEventsTable, CachedCalendarEvent> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CachedCalendarEventsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _startDtMeta = const VerificationMeta( + 'startDt', + ); + @override + late final GeneratedColumn startDt = GeneratedColumn( + 'start_dt', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _endDtMeta = const VerificationMeta('endDt'); + @override + late final GeneratedColumn endDt = GeneratedColumn( + 'end_dt', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _allDayMeta = const VerificationMeta('allDay'); + @override + late final GeneratedColumn allDay = GeneratedColumn( + 'all_day', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("all_day" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _descriptionMeta = const VerificationMeta( + 'description', + ); + @override + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant(''), + ); + static const VerificationMeta _locationMeta = const VerificationMeta( + 'location', + ); + @override + late final GeneratedColumn location = GeneratedColumn( + 'location', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant(''), + ); + static const VerificationMeta _colorMeta = const VerificationMeta('color'); + @override + late final GeneratedColumn color = GeneratedColumn( + 'color', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant(''), + ); + static const VerificationMeta _recurrenceMeta = const VerificationMeta( + 'recurrence', + ); + @override + late final GeneratedColumn recurrence = GeneratedColumn( + 'recurrence', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _projectIdMeta = const VerificationMeta( + 'projectId', + ); + @override + late final GeneratedColumn projectId = GeneratedColumn( + 'project_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _reminderMinutesMeta = const VerificationMeta( + 'reminderMinutes', + ); + @override + late final GeneratedColumn reminderMinutes = GeneratedColumn( + 'reminder_minutes', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _cachedAtMeta = const VerificationMeta( + 'cachedAt', + ); + @override + late final GeneratedColumn cachedAt = GeneratedColumn( + 'cached_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + clientDefault: () => DateTime.now(), + ); + @override + List get $columns => [ + id, + title, + startDt, + endDt, + allDay, + description, + location, + color, + recurrence, + projectId, + reminderMinutes, + cachedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'cached_calendar_events'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('start_dt')) { + context.handle( + _startDtMeta, + startDt.isAcceptableOrUnknown(data['start_dt']!, _startDtMeta), + ); + } else if (isInserting) { + context.missing(_startDtMeta); + } + if (data.containsKey('end_dt')) { + context.handle( + _endDtMeta, + endDt.isAcceptableOrUnknown(data['end_dt']!, _endDtMeta), + ); + } + if (data.containsKey('all_day')) { + context.handle( + _allDayMeta, + allDay.isAcceptableOrUnknown(data['all_day']!, _allDayMeta), + ); + } + if (data.containsKey('description')) { + context.handle( + _descriptionMeta, + description.isAcceptableOrUnknown( + data['description']!, + _descriptionMeta, + ), + ); + } + if (data.containsKey('location')) { + context.handle( + _locationMeta, + location.isAcceptableOrUnknown(data['location']!, _locationMeta), + ); + } + if (data.containsKey('color')) { + context.handle( + _colorMeta, + color.isAcceptableOrUnknown(data['color']!, _colorMeta), + ); + } + if (data.containsKey('recurrence')) { + context.handle( + _recurrenceMeta, + recurrence.isAcceptableOrUnknown(data['recurrence']!, _recurrenceMeta), + ); + } + if (data.containsKey('project_id')) { + context.handle( + _projectIdMeta, + projectId.isAcceptableOrUnknown(data['project_id']!, _projectIdMeta), + ); + } + if (data.containsKey('reminder_minutes')) { + context.handle( + _reminderMinutesMeta, + reminderMinutes.isAcceptableOrUnknown( + data['reminder_minutes']!, + _reminderMinutesMeta, + ), + ); + } + if (data.containsKey('cached_at')) { + context.handle( + _cachedAtMeta, + cachedAt.isAcceptableOrUnknown(data['cached_at']!, _cachedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + CachedCalendarEvent map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CachedCalendarEvent( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + startDt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}start_dt'], + )!, + endDt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}end_dt'], + ), + allDay: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}all_day'], + )!, + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + )!, + location: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}location'], + )!, + color: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}color'], + )!, + recurrence: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}recurrence'], + ), + projectId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}project_id'], + ), + reminderMinutes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}reminder_minutes'], + ), + cachedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}cached_at'], + )!, + ); + } + + @override + $CachedCalendarEventsTable createAlias(String alias) { + return $CachedCalendarEventsTable(attachedDatabase, alias); + } +} + +class CachedCalendarEvent extends DataClass + implements Insertable { + final int id; + final String title; + final DateTime startDt; + final DateTime? endDt; + final bool allDay; + final String description; + final String location; + final String color; + final String? recurrence; + final int? projectId; + final int? reminderMinutes; + final DateTime cachedAt; + const CachedCalendarEvent({ + required this.id, + required this.title, + required this.startDt, + this.endDt, + required this.allDay, + required this.description, + required this.location, + required this.color, + this.recurrence, + this.projectId, + this.reminderMinutes, + required this.cachedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + map['start_dt'] = Variable(startDt); + if (!nullToAbsent || endDt != null) { + map['end_dt'] = Variable(endDt); + } + map['all_day'] = Variable(allDay); + map['description'] = Variable(description); + map['location'] = Variable(location); + map['color'] = Variable(color); + if (!nullToAbsent || recurrence != null) { + map['recurrence'] = Variable(recurrence); + } + if (!nullToAbsent || projectId != null) { + map['project_id'] = Variable(projectId); + } + if (!nullToAbsent || reminderMinutes != null) { + map['reminder_minutes'] = Variable(reminderMinutes); + } + map['cached_at'] = Variable(cachedAt); + return map; + } + + CachedCalendarEventsCompanion toCompanion(bool nullToAbsent) { + return CachedCalendarEventsCompanion( + id: Value(id), + title: Value(title), + startDt: Value(startDt), + endDt: endDt == null && nullToAbsent + ? const Value.absent() + : Value(endDt), + allDay: Value(allDay), + description: Value(description), + location: Value(location), + color: Value(color), + recurrence: recurrence == null && nullToAbsent + ? const Value.absent() + : Value(recurrence), + projectId: projectId == null && nullToAbsent + ? const Value.absent() + : Value(projectId), + reminderMinutes: reminderMinutes == null && nullToAbsent + ? const Value.absent() + : Value(reminderMinutes), + cachedAt: Value(cachedAt), + ); + } + + factory CachedCalendarEvent.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CachedCalendarEvent( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + startDt: serializer.fromJson(json['startDt']), + endDt: serializer.fromJson(json['endDt']), + allDay: serializer.fromJson(json['allDay']), + description: serializer.fromJson(json['description']), + location: serializer.fromJson(json['location']), + color: serializer.fromJson(json['color']), + recurrence: serializer.fromJson(json['recurrence']), + projectId: serializer.fromJson(json['projectId']), + reminderMinutes: serializer.fromJson(json['reminderMinutes']), + cachedAt: serializer.fromJson(json['cachedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'startDt': serializer.toJson(startDt), + 'endDt': serializer.toJson(endDt), + 'allDay': serializer.toJson(allDay), + 'description': serializer.toJson(description), + 'location': serializer.toJson(location), + 'color': serializer.toJson(color), + 'recurrence': serializer.toJson(recurrence), + 'projectId': serializer.toJson(projectId), + 'reminderMinutes': serializer.toJson(reminderMinutes), + 'cachedAt': serializer.toJson(cachedAt), + }; + } + + CachedCalendarEvent copyWith({ + int? id, + String? title, + DateTime? startDt, + Value endDt = const Value.absent(), + bool? allDay, + String? description, + String? location, + String? color, + Value recurrence = const Value.absent(), + Value projectId = const Value.absent(), + Value reminderMinutes = const Value.absent(), + DateTime? cachedAt, + }) => CachedCalendarEvent( + id: id ?? this.id, + title: title ?? this.title, + startDt: startDt ?? this.startDt, + endDt: endDt.present ? endDt.value : this.endDt, + allDay: allDay ?? this.allDay, + description: description ?? this.description, + location: location ?? this.location, + color: color ?? this.color, + recurrence: recurrence.present ? recurrence.value : this.recurrence, + projectId: projectId.present ? projectId.value : this.projectId, + reminderMinutes: reminderMinutes.present + ? reminderMinutes.value + : this.reminderMinutes, + cachedAt: cachedAt ?? this.cachedAt, + ); + CachedCalendarEvent copyWithCompanion(CachedCalendarEventsCompanion data) { + return CachedCalendarEvent( + id: data.id.present ? data.id.value : this.id, + title: data.title.present ? data.title.value : this.title, + startDt: data.startDt.present ? data.startDt.value : this.startDt, + endDt: data.endDt.present ? data.endDt.value : this.endDt, + allDay: data.allDay.present ? data.allDay.value : this.allDay, + description: data.description.present + ? data.description.value + : this.description, + location: data.location.present ? data.location.value : this.location, + color: data.color.present ? data.color.value : this.color, + recurrence: data.recurrence.present + ? data.recurrence.value + : this.recurrence, + projectId: data.projectId.present ? data.projectId.value : this.projectId, + reminderMinutes: data.reminderMinutes.present + ? data.reminderMinutes.value + : this.reminderMinutes, + cachedAt: data.cachedAt.present ? data.cachedAt.value : this.cachedAt, + ); + } + + @override + String toString() { + return (StringBuffer('CachedCalendarEvent(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('startDt: $startDt, ') + ..write('endDt: $endDt, ') + ..write('allDay: $allDay, ') + ..write('description: $description, ') + ..write('location: $location, ') + ..write('color: $color, ') + ..write('recurrence: $recurrence, ') + ..write('projectId: $projectId, ') + ..write('reminderMinutes: $reminderMinutes, ') + ..write('cachedAt: $cachedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + title, + startDt, + endDt, + allDay, + description, + location, + color, + recurrence, + projectId, + reminderMinutes, + cachedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CachedCalendarEvent && + other.id == this.id && + other.title == this.title && + other.startDt == this.startDt && + other.endDt == this.endDt && + other.allDay == this.allDay && + other.description == this.description && + other.location == this.location && + other.color == this.color && + other.recurrence == this.recurrence && + other.projectId == this.projectId && + other.reminderMinutes == this.reminderMinutes && + other.cachedAt == this.cachedAt); +} + +class CachedCalendarEventsCompanion + extends UpdateCompanion { + final Value id; + final Value title; + final Value startDt; + final Value endDt; + final Value allDay; + final Value description; + final Value location; + final Value color; + final Value recurrence; + final Value projectId; + final Value reminderMinutes; + final Value cachedAt; + const CachedCalendarEventsCompanion({ + this.id = const Value.absent(), + this.title = const Value.absent(), + this.startDt = const Value.absent(), + this.endDt = const Value.absent(), + this.allDay = const Value.absent(), + this.description = const Value.absent(), + this.location = const Value.absent(), + this.color = const Value.absent(), + this.recurrence = const Value.absent(), + this.projectId = const Value.absent(), + this.reminderMinutes = const Value.absent(), + this.cachedAt = const Value.absent(), + }); + CachedCalendarEventsCompanion.insert({ + this.id = const Value.absent(), + required String title, + required DateTime startDt, + this.endDt = const Value.absent(), + this.allDay = const Value.absent(), + this.description = const Value.absent(), + this.location = const Value.absent(), + this.color = const Value.absent(), + this.recurrence = const Value.absent(), + this.projectId = const Value.absent(), + this.reminderMinutes = const Value.absent(), + this.cachedAt = const Value.absent(), + }) : title = Value(title), + startDt = Value(startDt); + static Insertable custom({ + Expression? id, + Expression? title, + Expression? startDt, + Expression? endDt, + Expression? allDay, + Expression? description, + Expression? location, + Expression? color, + Expression? recurrence, + Expression? projectId, + Expression? reminderMinutes, + Expression? cachedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (title != null) 'title': title, + if (startDt != null) 'start_dt': startDt, + if (endDt != null) 'end_dt': endDt, + if (allDay != null) 'all_day': allDay, + if (description != null) 'description': description, + if (location != null) 'location': location, + if (color != null) 'color': color, + if (recurrence != null) 'recurrence': recurrence, + if (projectId != null) 'project_id': projectId, + if (reminderMinutes != null) 'reminder_minutes': reminderMinutes, + if (cachedAt != null) 'cached_at': cachedAt, + }); + } + + CachedCalendarEventsCompanion copyWith({ + Value? id, + Value? title, + Value? startDt, + Value? endDt, + Value? allDay, + Value? description, + Value? location, + Value? color, + Value? recurrence, + Value? projectId, + Value? reminderMinutes, + Value? cachedAt, + }) { + return CachedCalendarEventsCompanion( + id: id ?? this.id, + title: title ?? this.title, + startDt: startDt ?? this.startDt, + endDt: endDt ?? this.endDt, + allDay: allDay ?? this.allDay, + description: description ?? this.description, + location: location ?? this.location, + color: color ?? this.color, + recurrence: recurrence ?? this.recurrence, + projectId: projectId ?? this.projectId, + reminderMinutes: reminderMinutes ?? this.reminderMinutes, + cachedAt: cachedAt ?? this.cachedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (startDt.present) { + map['start_dt'] = Variable(startDt.value); + } + if (endDt.present) { + map['end_dt'] = Variable(endDt.value); + } + if (allDay.present) { + map['all_day'] = Variable(allDay.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (location.present) { + map['location'] = Variable(location.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (recurrence.present) { + map['recurrence'] = Variable(recurrence.value); + } + if (projectId.present) { + map['project_id'] = Variable(projectId.value); + } + if (reminderMinutes.present) { + map['reminder_minutes'] = Variable(reminderMinutes.value); + } + if (cachedAt.present) { + map['cached_at'] = Variable(cachedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CachedCalendarEventsCompanion(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('startDt: $startDt, ') + ..write('endDt: $endDt, ') + ..write('allDay: $allDay, ') + ..write('description: $description, ') + ..write('location: $location, ') + ..write('color: $color, ') + ..write('recurrence: $recurrence, ') + ..write('projectId: $projectId, ') + ..write('reminderMinutes: $reminderMinutes, ') + ..write('cachedAt: $cachedAt') + ..write(')')) + .toString(); + } +} + +class $CachedConversationsTable extends CachedConversations + with TableInfo<$CachedConversationsTable, CachedConversation> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CachedConversationsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _cachedAtMeta = const VerificationMeta( + 'cachedAt', + ); + @override + late final GeneratedColumn cachedAt = GeneratedColumn( + 'cached_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + clientDefault: () => DateTime.now(), + ); + @override + List get $columns => [ + id, + title, + createdAt, + updatedAt, + cachedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'cached_conversations'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + if (data.containsKey('cached_at')) { + context.handle( + _cachedAtMeta, + cachedAt.isAcceptableOrUnknown(data['cached_at']!, _cachedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + CachedConversation map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CachedConversation( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + cachedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}cached_at'], + )!, + ); + } + + @override + $CachedConversationsTable createAlias(String alias) { + return $CachedConversationsTable(attachedDatabase, alias); + } +} + +class CachedConversation extends DataClass + implements Insertable { + final int id; + final String title; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime cachedAt; + const CachedConversation({ + required this.id, + required this.title, + required this.createdAt, + required this.updatedAt, + required this.cachedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['cached_at'] = Variable(cachedAt); + return map; + } + + CachedConversationsCompanion toCompanion(bool nullToAbsent) { + return CachedConversationsCompanion( + id: Value(id), + title: Value(title), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + cachedAt: Value(cachedAt), + ); + } + + factory CachedConversation.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CachedConversation( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + cachedAt: serializer.fromJson(json['cachedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'cachedAt': serializer.toJson(cachedAt), + }; + } + + CachedConversation copyWith({ + int? id, + String? title, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? cachedAt, + }) => CachedConversation( + id: id ?? this.id, + title: title ?? this.title, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + cachedAt: cachedAt ?? this.cachedAt, + ); + CachedConversation copyWithCompanion(CachedConversationsCompanion data) { + return CachedConversation( + id: data.id.present ? data.id.value : this.id, + title: data.title.present ? data.title.value : this.title, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + cachedAt: data.cachedAt.present ? data.cachedAt.value : this.cachedAt, + ); + } + + @override + String toString() { + return (StringBuffer('CachedConversation(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('cachedAt: $cachedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, title, createdAt, updatedAt, cachedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CachedConversation && + other.id == this.id && + other.title == this.title && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.cachedAt == this.cachedAt); +} + +class CachedConversationsCompanion extends UpdateCompanion { + final Value id; + final Value title; + final Value createdAt; + final Value updatedAt; + final Value cachedAt; + const CachedConversationsCompanion({ + this.id = const Value.absent(), + this.title = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.cachedAt = const Value.absent(), + }); + CachedConversationsCompanion.insert({ + this.id = const Value.absent(), + required String title, + required DateTime createdAt, + required DateTime updatedAt, + this.cachedAt = const Value.absent(), + }) : title = Value(title), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? title, + Expression? createdAt, + Expression? updatedAt, + Expression? cachedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (title != null) 'title': title, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (cachedAt != null) 'cached_at': cachedAt, + }); + } + + CachedConversationsCompanion copyWith({ + Value? id, + Value? title, + Value? createdAt, + Value? updatedAt, + Value? cachedAt, + }) { + return CachedConversationsCompanion( + id: id ?? this.id, + title: title ?? this.title, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + cachedAt: cachedAt ?? this.cachedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (cachedAt.present) { + map['cached_at'] = Variable(cachedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CachedConversationsCompanion(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('cachedAt: $cachedAt') + ..write(')')) + .toString(); + } +} + class $SyncMetadataTable extends SyncMetadata with TableInfo<$SyncMetadataTable, SyncMetadataData> { @override @@ -820,6 +3835,15 @@ abstract class _$FabledDatabase extends GeneratedDatabase { _$FabledDatabase(QueryExecutor e) : super(e); $FabledDatabaseManager get managers => $FabledDatabaseManager(this); late final $CachedNotesTable cachedNotes = $CachedNotesTable(this); + late final $CachedTasksTable cachedTasks = $CachedTasksTable(this); + late final $CachedProjectsTable cachedProjects = $CachedProjectsTable(this); + late final $CachedMilestonesTable cachedMilestones = $CachedMilestonesTable( + this, + ); + late final $CachedCalendarEventsTable cachedCalendarEvents = + $CachedCalendarEventsTable(this); + late final $CachedConversationsTable cachedConversations = + $CachedConversationsTable(this); late final $SyncMetadataTable syncMetadata = $SyncMetadataTable(this); @override Iterable> get allTables => @@ -827,6 +3851,11 @@ abstract class _$FabledDatabase extends GeneratedDatabase { @override List get allSchemaEntities => [ cachedNotes, + cachedTasks, + cachedProjects, + cachedMilestones, + cachedCalendarEvents, + cachedConversations, syncMetadata, ]; } @@ -1122,6 +4151,1536 @@ typedef $$CachedNotesTableProcessedTableManager = CachedNote, PrefetchHooks Function() >; +typedef $$CachedTasksTableCreateCompanionBuilder = + CachedTasksCompanion Function({ + Value id, + required String title, + Value description, + required String status, + required String priority, + Value dueDate, + Value projectId, + Value milestoneId, + Value parentId, + required DateTime createdAt, + required DateTime updatedAt, + Value cachedAt, + }); +typedef $$CachedTasksTableUpdateCompanionBuilder = + CachedTasksCompanion Function({ + Value id, + Value title, + Value description, + Value status, + Value priority, + Value dueDate, + Value projectId, + Value milestoneId, + Value parentId, + Value createdAt, + Value updatedAt, + Value cachedAt, + }); + +class $$CachedTasksTableFilterComposer + extends Composer<_$FabledDatabase, $CachedTasksTable> { + $$CachedTasksTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get priority => $composableBuilder( + column: $table.priority, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get dueDate => $composableBuilder( + column: $table.dueDate, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get projectId => $composableBuilder( + column: $table.projectId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get milestoneId => $composableBuilder( + column: $table.milestoneId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get parentId => $composableBuilder( + column: $table.parentId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$CachedTasksTableOrderingComposer + extends Composer<_$FabledDatabase, $CachedTasksTable> { + $$CachedTasksTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get priority => $composableBuilder( + column: $table.priority, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get dueDate => $composableBuilder( + column: $table.dueDate, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get projectId => $composableBuilder( + column: $table.projectId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get milestoneId => $composableBuilder( + column: $table.milestoneId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get parentId => $composableBuilder( + column: $table.parentId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$CachedTasksTableAnnotationComposer + extends Composer<_$FabledDatabase, $CachedTasksTable> { + $$CachedTasksTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, + builder: (column) => column, + ); + + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get priority => + $composableBuilder(column: $table.priority, builder: (column) => column); + + GeneratedColumn get dueDate => + $composableBuilder(column: $table.dueDate, builder: (column) => column); + + GeneratedColumn get projectId => + $composableBuilder(column: $table.projectId, builder: (column) => column); + + GeneratedColumn get milestoneId => $composableBuilder( + column: $table.milestoneId, + builder: (column) => column, + ); + + GeneratedColumn get parentId => + $composableBuilder(column: $table.parentId, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get cachedAt => + $composableBuilder(column: $table.cachedAt, builder: (column) => column); +} + +class $$CachedTasksTableTableManager + extends + RootTableManager< + _$FabledDatabase, + $CachedTasksTable, + CachedTask, + $$CachedTasksTableFilterComposer, + $$CachedTasksTableOrderingComposer, + $$CachedTasksTableAnnotationComposer, + $$CachedTasksTableCreateCompanionBuilder, + $$CachedTasksTableUpdateCompanionBuilder, + ( + CachedTask, + BaseReferences<_$FabledDatabase, $CachedTasksTable, CachedTask>, + ), + CachedTask, + PrefetchHooks Function() + > { + $$CachedTasksTableTableManager(_$FabledDatabase db, $CachedTasksTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$CachedTasksTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$CachedTasksTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$CachedTasksTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value title = const Value.absent(), + Value description = const Value.absent(), + Value status = const Value.absent(), + Value priority = const Value.absent(), + Value dueDate = const Value.absent(), + Value projectId = const Value.absent(), + Value milestoneId = const Value.absent(), + Value parentId = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value cachedAt = const Value.absent(), + }) => CachedTasksCompanion( + id: id, + title: title, + description: description, + status: status, + priority: priority, + dueDate: dueDate, + projectId: projectId, + milestoneId: milestoneId, + parentId: parentId, + createdAt: createdAt, + updatedAt: updatedAt, + cachedAt: cachedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String title, + Value description = const Value.absent(), + required String status, + required String priority, + Value dueDate = const Value.absent(), + Value projectId = const Value.absent(), + Value milestoneId = const Value.absent(), + Value parentId = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + Value cachedAt = const Value.absent(), + }) => CachedTasksCompanion.insert( + id: id, + title: title, + description: description, + status: status, + priority: priority, + dueDate: dueDate, + projectId: projectId, + milestoneId: milestoneId, + parentId: parentId, + createdAt: createdAt, + updatedAt: updatedAt, + cachedAt: cachedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$CachedTasksTableProcessedTableManager = + ProcessedTableManager< + _$FabledDatabase, + $CachedTasksTable, + CachedTask, + $$CachedTasksTableFilterComposer, + $$CachedTasksTableOrderingComposer, + $$CachedTasksTableAnnotationComposer, + $$CachedTasksTableCreateCompanionBuilder, + $$CachedTasksTableUpdateCompanionBuilder, + ( + CachedTask, + BaseReferences<_$FabledDatabase, $CachedTasksTable, CachedTask>, + ), + CachedTask, + PrefetchHooks Function() + >; +typedef $$CachedProjectsTableCreateCompanionBuilder = + CachedProjectsCompanion Function({ + Value id, + required String title, + Value description, + Value goal, + required String status, + Value color, + Value autoSummary, + required DateTime createdAt, + required DateTime updatedAt, + Value cachedAt, + }); +typedef $$CachedProjectsTableUpdateCompanionBuilder = + CachedProjectsCompanion Function({ + Value id, + Value title, + Value description, + Value goal, + Value status, + Value color, + Value autoSummary, + Value createdAt, + Value updatedAt, + Value cachedAt, + }); + +class $$CachedProjectsTableFilterComposer + extends Composer<_$FabledDatabase, $CachedProjectsTable> { + $$CachedProjectsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get goal => $composableBuilder( + column: $table.goal, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get color => $composableBuilder( + column: $table.color, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get autoSummary => $composableBuilder( + column: $table.autoSummary, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$CachedProjectsTableOrderingComposer + extends Composer<_$FabledDatabase, $CachedProjectsTable> { + $$CachedProjectsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get goal => $composableBuilder( + column: $table.goal, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get color => $composableBuilder( + column: $table.color, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get autoSummary => $composableBuilder( + column: $table.autoSummary, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$CachedProjectsTableAnnotationComposer + extends Composer<_$FabledDatabase, $CachedProjectsTable> { + $$CachedProjectsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, + builder: (column) => column, + ); + + GeneratedColumn get goal => + $composableBuilder(column: $table.goal, builder: (column) => column); + + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get color => + $composableBuilder(column: $table.color, builder: (column) => column); + + GeneratedColumn get autoSummary => $composableBuilder( + column: $table.autoSummary, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get cachedAt => + $composableBuilder(column: $table.cachedAt, builder: (column) => column); +} + +class $$CachedProjectsTableTableManager + extends + RootTableManager< + _$FabledDatabase, + $CachedProjectsTable, + CachedProject, + $$CachedProjectsTableFilterComposer, + $$CachedProjectsTableOrderingComposer, + $$CachedProjectsTableAnnotationComposer, + $$CachedProjectsTableCreateCompanionBuilder, + $$CachedProjectsTableUpdateCompanionBuilder, + ( + CachedProject, + BaseReferences< + _$FabledDatabase, + $CachedProjectsTable, + CachedProject + >, + ), + CachedProject, + PrefetchHooks Function() + > { + $$CachedProjectsTableTableManager( + _$FabledDatabase db, + $CachedProjectsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$CachedProjectsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$CachedProjectsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$CachedProjectsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value title = const Value.absent(), + Value description = const Value.absent(), + Value goal = const Value.absent(), + Value status = const Value.absent(), + Value color = const Value.absent(), + Value autoSummary = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value cachedAt = const Value.absent(), + }) => CachedProjectsCompanion( + id: id, + title: title, + description: description, + goal: goal, + status: status, + color: color, + autoSummary: autoSummary, + createdAt: createdAt, + updatedAt: updatedAt, + cachedAt: cachedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String title, + Value description = const Value.absent(), + Value goal = const Value.absent(), + required String status, + Value color = const Value.absent(), + Value autoSummary = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + Value cachedAt = const Value.absent(), + }) => CachedProjectsCompanion.insert( + id: id, + title: title, + description: description, + goal: goal, + status: status, + color: color, + autoSummary: autoSummary, + createdAt: createdAt, + updatedAt: updatedAt, + cachedAt: cachedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$CachedProjectsTableProcessedTableManager = + ProcessedTableManager< + _$FabledDatabase, + $CachedProjectsTable, + CachedProject, + $$CachedProjectsTableFilterComposer, + $$CachedProjectsTableOrderingComposer, + $$CachedProjectsTableAnnotationComposer, + $$CachedProjectsTableCreateCompanionBuilder, + $$CachedProjectsTableUpdateCompanionBuilder, + ( + CachedProject, + BaseReferences<_$FabledDatabase, $CachedProjectsTable, CachedProject>, + ), + CachedProject, + PrefetchHooks Function() + >; +typedef $$CachedMilestonesTableCreateCompanionBuilder = + CachedMilestonesCompanion Function({ + Value id, + required int projectId, + required String title, + Value description, + required String status, + Value orderIndex, + Value total, + Value completed, + Value pct, + required DateTime createdAt, + required DateTime updatedAt, + Value cachedAt, + }); +typedef $$CachedMilestonesTableUpdateCompanionBuilder = + CachedMilestonesCompanion Function({ + Value id, + Value projectId, + Value title, + Value description, + Value status, + Value orderIndex, + Value total, + Value completed, + Value pct, + Value createdAt, + Value updatedAt, + Value cachedAt, + }); + +class $$CachedMilestonesTableFilterComposer + extends Composer<_$FabledDatabase, $CachedMilestonesTable> { + $$CachedMilestonesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get projectId => $composableBuilder( + column: $table.projectId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get orderIndex => $composableBuilder( + column: $table.orderIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get total => $composableBuilder( + column: $table.total, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get completed => $composableBuilder( + column: $table.completed, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get pct => $composableBuilder( + column: $table.pct, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$CachedMilestonesTableOrderingComposer + extends Composer<_$FabledDatabase, $CachedMilestonesTable> { + $$CachedMilestonesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get projectId => $composableBuilder( + column: $table.projectId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get orderIndex => $composableBuilder( + column: $table.orderIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get total => $composableBuilder( + column: $table.total, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get completed => $composableBuilder( + column: $table.completed, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get pct => $composableBuilder( + column: $table.pct, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$CachedMilestonesTableAnnotationComposer + extends Composer<_$FabledDatabase, $CachedMilestonesTable> { + $$CachedMilestonesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get projectId => + $composableBuilder(column: $table.projectId, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, + builder: (column) => column, + ); + + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get orderIndex => $composableBuilder( + column: $table.orderIndex, + builder: (column) => column, + ); + + GeneratedColumn get total => + $composableBuilder(column: $table.total, builder: (column) => column); + + GeneratedColumn get completed => + $composableBuilder(column: $table.completed, builder: (column) => column); + + GeneratedColumn get pct => + $composableBuilder(column: $table.pct, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get cachedAt => + $composableBuilder(column: $table.cachedAt, builder: (column) => column); +} + +class $$CachedMilestonesTableTableManager + extends + RootTableManager< + _$FabledDatabase, + $CachedMilestonesTable, + CachedMilestone, + $$CachedMilestonesTableFilterComposer, + $$CachedMilestonesTableOrderingComposer, + $$CachedMilestonesTableAnnotationComposer, + $$CachedMilestonesTableCreateCompanionBuilder, + $$CachedMilestonesTableUpdateCompanionBuilder, + ( + CachedMilestone, + BaseReferences< + _$FabledDatabase, + $CachedMilestonesTable, + CachedMilestone + >, + ), + CachedMilestone, + PrefetchHooks Function() + > { + $$CachedMilestonesTableTableManager( + _$FabledDatabase db, + $CachedMilestonesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$CachedMilestonesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$CachedMilestonesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$CachedMilestonesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value projectId = const Value.absent(), + Value title = const Value.absent(), + Value description = const Value.absent(), + Value status = const Value.absent(), + Value orderIndex = const Value.absent(), + Value total = const Value.absent(), + Value completed = const Value.absent(), + Value pct = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value cachedAt = const Value.absent(), + }) => CachedMilestonesCompanion( + id: id, + projectId: projectId, + title: title, + description: description, + status: status, + orderIndex: orderIndex, + total: total, + completed: completed, + pct: pct, + createdAt: createdAt, + updatedAt: updatedAt, + cachedAt: cachedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required int projectId, + required String title, + Value description = const Value.absent(), + required String status, + Value orderIndex = const Value.absent(), + Value total = const Value.absent(), + Value completed = const Value.absent(), + Value pct = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + Value cachedAt = const Value.absent(), + }) => CachedMilestonesCompanion.insert( + id: id, + projectId: projectId, + title: title, + description: description, + status: status, + orderIndex: orderIndex, + total: total, + completed: completed, + pct: pct, + createdAt: createdAt, + updatedAt: updatedAt, + cachedAt: cachedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$CachedMilestonesTableProcessedTableManager = + ProcessedTableManager< + _$FabledDatabase, + $CachedMilestonesTable, + CachedMilestone, + $$CachedMilestonesTableFilterComposer, + $$CachedMilestonesTableOrderingComposer, + $$CachedMilestonesTableAnnotationComposer, + $$CachedMilestonesTableCreateCompanionBuilder, + $$CachedMilestonesTableUpdateCompanionBuilder, + ( + CachedMilestone, + BaseReferences< + _$FabledDatabase, + $CachedMilestonesTable, + CachedMilestone + >, + ), + CachedMilestone, + PrefetchHooks Function() + >; +typedef $$CachedCalendarEventsTableCreateCompanionBuilder = + CachedCalendarEventsCompanion Function({ + Value id, + required String title, + required DateTime startDt, + Value endDt, + Value allDay, + Value description, + Value location, + Value color, + Value recurrence, + Value projectId, + Value reminderMinutes, + Value cachedAt, + }); +typedef $$CachedCalendarEventsTableUpdateCompanionBuilder = + CachedCalendarEventsCompanion Function({ + Value id, + Value title, + Value startDt, + Value endDt, + Value allDay, + Value description, + Value location, + Value color, + Value recurrence, + Value projectId, + Value reminderMinutes, + Value cachedAt, + }); + +class $$CachedCalendarEventsTableFilterComposer + extends Composer<_$FabledDatabase, $CachedCalendarEventsTable> { + $$CachedCalendarEventsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get startDt => $composableBuilder( + column: $table.startDt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get endDt => $composableBuilder( + column: $table.endDt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get allDay => $composableBuilder( + column: $table.allDay, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get location => $composableBuilder( + column: $table.location, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get color => $composableBuilder( + column: $table.color, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get recurrence => $composableBuilder( + column: $table.recurrence, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get projectId => $composableBuilder( + column: $table.projectId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get reminderMinutes => $composableBuilder( + column: $table.reminderMinutes, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$CachedCalendarEventsTableOrderingComposer + extends Composer<_$FabledDatabase, $CachedCalendarEventsTable> { + $$CachedCalendarEventsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get startDt => $composableBuilder( + column: $table.startDt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get endDt => $composableBuilder( + column: $table.endDt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get allDay => $composableBuilder( + column: $table.allDay, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get location => $composableBuilder( + column: $table.location, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get color => $composableBuilder( + column: $table.color, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get recurrence => $composableBuilder( + column: $table.recurrence, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get projectId => $composableBuilder( + column: $table.projectId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get reminderMinutes => $composableBuilder( + column: $table.reminderMinutes, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$CachedCalendarEventsTableAnnotationComposer + extends Composer<_$FabledDatabase, $CachedCalendarEventsTable> { + $$CachedCalendarEventsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get startDt => + $composableBuilder(column: $table.startDt, builder: (column) => column); + + GeneratedColumn get endDt => + $composableBuilder(column: $table.endDt, builder: (column) => column); + + GeneratedColumn get allDay => + $composableBuilder(column: $table.allDay, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, + builder: (column) => column, + ); + + GeneratedColumn get location => + $composableBuilder(column: $table.location, builder: (column) => column); + + GeneratedColumn get color => + $composableBuilder(column: $table.color, builder: (column) => column); + + GeneratedColumn get recurrence => $composableBuilder( + column: $table.recurrence, + builder: (column) => column, + ); + + GeneratedColumn get projectId => + $composableBuilder(column: $table.projectId, builder: (column) => column); + + GeneratedColumn get reminderMinutes => $composableBuilder( + column: $table.reminderMinutes, + builder: (column) => column, + ); + + GeneratedColumn get cachedAt => + $composableBuilder(column: $table.cachedAt, builder: (column) => column); +} + +class $$CachedCalendarEventsTableTableManager + extends + RootTableManager< + _$FabledDatabase, + $CachedCalendarEventsTable, + CachedCalendarEvent, + $$CachedCalendarEventsTableFilterComposer, + $$CachedCalendarEventsTableOrderingComposer, + $$CachedCalendarEventsTableAnnotationComposer, + $$CachedCalendarEventsTableCreateCompanionBuilder, + $$CachedCalendarEventsTableUpdateCompanionBuilder, + ( + CachedCalendarEvent, + BaseReferences< + _$FabledDatabase, + $CachedCalendarEventsTable, + CachedCalendarEvent + >, + ), + CachedCalendarEvent, + PrefetchHooks Function() + > { + $$CachedCalendarEventsTableTableManager( + _$FabledDatabase db, + $CachedCalendarEventsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$CachedCalendarEventsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$CachedCalendarEventsTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$CachedCalendarEventsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value title = const Value.absent(), + Value startDt = const Value.absent(), + Value endDt = const Value.absent(), + Value allDay = const Value.absent(), + Value description = const Value.absent(), + Value location = const Value.absent(), + Value color = const Value.absent(), + Value recurrence = const Value.absent(), + Value projectId = const Value.absent(), + Value reminderMinutes = const Value.absent(), + Value cachedAt = const Value.absent(), + }) => CachedCalendarEventsCompanion( + id: id, + title: title, + startDt: startDt, + endDt: endDt, + allDay: allDay, + description: description, + location: location, + color: color, + recurrence: recurrence, + projectId: projectId, + reminderMinutes: reminderMinutes, + cachedAt: cachedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String title, + required DateTime startDt, + Value endDt = const Value.absent(), + Value allDay = const Value.absent(), + Value description = const Value.absent(), + Value location = const Value.absent(), + Value color = const Value.absent(), + Value recurrence = const Value.absent(), + Value projectId = const Value.absent(), + Value reminderMinutes = const Value.absent(), + Value cachedAt = const Value.absent(), + }) => CachedCalendarEventsCompanion.insert( + id: id, + title: title, + startDt: startDt, + endDt: endDt, + allDay: allDay, + description: description, + location: location, + color: color, + recurrence: recurrence, + projectId: projectId, + reminderMinutes: reminderMinutes, + cachedAt: cachedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$CachedCalendarEventsTableProcessedTableManager = + ProcessedTableManager< + _$FabledDatabase, + $CachedCalendarEventsTable, + CachedCalendarEvent, + $$CachedCalendarEventsTableFilterComposer, + $$CachedCalendarEventsTableOrderingComposer, + $$CachedCalendarEventsTableAnnotationComposer, + $$CachedCalendarEventsTableCreateCompanionBuilder, + $$CachedCalendarEventsTableUpdateCompanionBuilder, + ( + CachedCalendarEvent, + BaseReferences< + _$FabledDatabase, + $CachedCalendarEventsTable, + CachedCalendarEvent + >, + ), + CachedCalendarEvent, + PrefetchHooks Function() + >; +typedef $$CachedConversationsTableCreateCompanionBuilder = + CachedConversationsCompanion Function({ + Value id, + required String title, + required DateTime createdAt, + required DateTime updatedAt, + Value cachedAt, + }); +typedef $$CachedConversationsTableUpdateCompanionBuilder = + CachedConversationsCompanion Function({ + Value id, + Value title, + Value createdAt, + Value updatedAt, + Value cachedAt, + }); + +class $$CachedConversationsTableFilterComposer + extends Composer<_$FabledDatabase, $CachedConversationsTable> { + $$CachedConversationsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$CachedConversationsTableOrderingComposer + extends Composer<_$FabledDatabase, $CachedConversationsTable> { + $$CachedConversationsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$CachedConversationsTableAnnotationComposer + extends Composer<_$FabledDatabase, $CachedConversationsTable> { + $$CachedConversationsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get cachedAt => + $composableBuilder(column: $table.cachedAt, builder: (column) => column); +} + +class $$CachedConversationsTableTableManager + extends + RootTableManager< + _$FabledDatabase, + $CachedConversationsTable, + CachedConversation, + $$CachedConversationsTableFilterComposer, + $$CachedConversationsTableOrderingComposer, + $$CachedConversationsTableAnnotationComposer, + $$CachedConversationsTableCreateCompanionBuilder, + $$CachedConversationsTableUpdateCompanionBuilder, + ( + CachedConversation, + BaseReferences< + _$FabledDatabase, + $CachedConversationsTable, + CachedConversation + >, + ), + CachedConversation, + PrefetchHooks Function() + > { + $$CachedConversationsTableTableManager( + _$FabledDatabase db, + $CachedConversationsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$CachedConversationsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$CachedConversationsTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$CachedConversationsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value title = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value cachedAt = const Value.absent(), + }) => CachedConversationsCompanion( + id: id, + title: title, + createdAt: createdAt, + updatedAt: updatedAt, + cachedAt: cachedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String title, + required DateTime createdAt, + required DateTime updatedAt, + Value cachedAt = const Value.absent(), + }) => CachedConversationsCompanion.insert( + id: id, + title: title, + createdAt: createdAt, + updatedAt: updatedAt, + cachedAt: cachedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$CachedConversationsTableProcessedTableManager = + ProcessedTableManager< + _$FabledDatabase, + $CachedConversationsTable, + CachedConversation, + $$CachedConversationsTableFilterComposer, + $$CachedConversationsTableOrderingComposer, + $$CachedConversationsTableAnnotationComposer, + $$CachedConversationsTableCreateCompanionBuilder, + $$CachedConversationsTableUpdateCompanionBuilder, + ( + CachedConversation, + BaseReferences< + _$FabledDatabase, + $CachedConversationsTable, + CachedConversation + >, + ), + CachedConversation, + PrefetchHooks Function() + >; typedef $$SyncMetadataTableCreateCompanionBuilder = SyncMetadataCompanion Function({ required String domain, @@ -1277,6 +5836,16 @@ class $FabledDatabaseManager { $FabledDatabaseManager(this._db); $$CachedNotesTableTableManager get cachedNotes => $$CachedNotesTableTableManager(_db, _db.cachedNotes); + $$CachedTasksTableTableManager get cachedTasks => + $$CachedTasksTableTableManager(_db, _db.cachedTasks); + $$CachedProjectsTableTableManager get cachedProjects => + $$CachedProjectsTableTableManager(_db, _db.cachedProjects); + $$CachedMilestonesTableTableManager get cachedMilestones => + $$CachedMilestonesTableTableManager(_db, _db.cachedMilestones); + $$CachedCalendarEventsTableTableManager get cachedCalendarEvents => + $$CachedCalendarEventsTableTableManager(_db, _db.cachedCalendarEvents); + $$CachedConversationsTableTableManager get cachedConversations => + $$CachedConversationsTableTableManager(_db, _db.cachedConversations); $$SyncMetadataTableTableManager get syncMetadata => $$SyncMetadataTableTableManager(_db, _db.syncMetadata); } diff --git a/lib/data/repositories/chat_repository.dart b/lib/data/repositories/chat_repository.dart index 8e0bc10..7223779 100644 --- a/lib/data/repositories/chat_repository.dart +++ b/lib/data/repositories/chat_repository.dart @@ -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> 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> getConversations() => _api.getConversations(); Future createConversation(String title) => _api.createConversation(title); - Future deleteConversation(int id) => _api.deleteConversation(id); + + Future deleteConversation(int id) async { + await _api.deleteConversation(id); + await _db.deleteConversation(id); + } + Future<(Conversation, List)> getMessages(int conversationId) => _api.getMessages(conversationId); + Future sendMessage(int conversationId, String content) => _api.sendMessage(conversationId, content); + Stream streamGeneration(int conversationId) => _api.streamGeneration(conversationId); } diff --git a/lib/data/repositories/events_repository.dart b/lib/data/repositories/events_repository.dart new file mode 100644 index 0000000..41dd3a4 --- /dev/null +++ b/lib/data/repositories/events_repository.dart @@ -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> 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 createEvent(Map payload) async { + final event = await _api.createEvent(payload); + await _db.upsertEvent(event); + return event; + } + + Future updateEvent( + int id, Map fields) async { + final updated = await _api.updateEvent(id, fields); + await _db.upsertEvent(updated); + return updated; + } + + Future deleteEvent(int id) async { + await _api.deleteEvent(id); + await _db.deleteEvent(id); + } +} diff --git a/lib/data/repositories/milestones_repository.dart b/lib/data/repositories/milestones_repository.dart index 7b14b77..c9eb364 100644 --- a/lib/data/repositories/milestones_repository.dart +++ b/lib/data/repositories/milestones_repository.dart @@ -1,26 +1,67 @@ +import '../../core/exceptions.dart'; import '../api/milestones_api.dart'; +import '../local/database.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 { final MilestonesApi _api; - const MilestonesRepository(this._api); + final FabledDatabase _db; - Future> getAll(int projectId, {String? status}) => - _api.getAll(projectId, status: status); + const MilestonesRepository(this._api, this._db); + + Future> 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 create( int projectId, { required String title, String? description, int orderIndex = 0, - }) => - _api.create(projectId, - title: title, description: description, orderIndex: orderIndex); + }) async { + final milestone = await _api.create( + projectId, + title: title, + description: description, + orderIndex: orderIndex, + ); + await _db.upsertMilestone(milestone); + return milestone; + } Future update( - int projectId, int milestoneId, Map fields) => - _api.update(projectId, milestoneId, fields); + int projectId, + int milestoneId, + Map fields, + ) async { + final updated = await _api.update(projectId, milestoneId, fields); + await _db.upsertMilestone(updated); + return updated; + } - Future delete(int projectId, int milestoneId) => - _api.delete(projectId, milestoneId); + Future delete(int projectId, int milestoneId) async { + await _api.delete(projectId, milestoneId); + await _db.deleteMilestone(milestoneId); + } } diff --git a/lib/data/repositories/notes_repository.dart b/lib/data/repositories/notes_repository.dart index 4744748..41042ac 100644 --- a/lib/data/repositories/notes_repository.dart +++ b/lib/data/repositories/notes_repository.dart @@ -90,9 +90,4 @@ class NotesRepository { await _api.delete(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 lastSyncedAt() => _db.getLastSync(kSyncDomainNotes); } diff --git a/lib/data/repositories/projects_repository.dart b/lib/data/repositories/projects_repository.dart index ca93578..e6b617d 100644 --- a/lib/data/repositories/projects_repository.dart +++ b/lib/data/repositories/projects_repository.dart @@ -1,31 +1,87 @@ +import '../../core/exceptions.dart'; import '../api/projects_api.dart'; +import '../local/database.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 { final ProjectsApi _api; - const ProjectsRepository(this._api); + final FabledDatabase _db; + + const ProjectsRepository(this._api, this._db); Future> getAll({ String? status, String sort = 'updated_at', String order = 'desc', - }) => - _api.getAll(status: status, sort: sort, order: order); - Future 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 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 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 update(int id, Map fields) => - _api.update(id, fields); - Future delete(int id) => _api.delete(id); + }) async { + final project = await _api.create( + title: title, + description: description, + goal: goal, + color: color, + status: status, + ); + await _db.upsertProject(project); + return project; + } + + Future update(int id, Map fields) async { + final updated = await _api.update(id, fields); + await _db.upsertProject(updated); + return updated; + } + + Future delete(int id) async { + await _api.delete(id); + await _db.deleteProject(id); + } } diff --git a/lib/data/repositories/tasks_repository.dart b/lib/data/repositories/tasks_repository.dart index c8f3abe..3bb7771 100644 --- a/lib/data/repositories/tasks_repository.dart +++ b/lib/data/repositories/tasks_repository.dart @@ -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 for Tier 2 offline support. +/// Mirrors the pattern in NotesRepository — see that file for full notes. class TasksRepository { final TasksApi _api; - const TasksRepository(this._api); + final FabledDatabase _db; - Future> getAll() => _api.getAll(); - Future getOne(int id) => _api.getOne(id); + const TasksRepository(this._api, this._db); + + Future> 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 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> 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> 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 create({ required String title, @@ -16,22 +67,28 @@ class TasksRepository { DateTime? dueDate, int? projectId, int? parentId, - }) => - _api.create( - title: title, - description: description, - status: status, - priority: priority, - dueDate: dueDate, - projectId: projectId, - parentId: parentId, - ); + }) async { + final task = await _api.create( + title: title, + description: description, + status: status, + priority: priority, + dueDate: dueDate, + projectId: projectId, + parentId: parentId, + ); + await _db.upsertTask(task); + return task; + } - Future> getByProject(int projectId) => _api.getByProject(projectId); - Future> getSubTasks(int parentId) => _api.getSubTasks(parentId); + Future update(int id, Map fields) async { + final updated = await _api.update(id, fields); + await _db.upsertTask(updated); + return updated; + } - Future update(int id, Map fields) => - _api.update(id, fields); - - Future delete(int id) => _api.delete(id); + Future delete(int id) async { + await _api.delete(id); + await _db.deleteTask(id); + } } diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart index 6b194b3..1e5ada6 100644 --- a/lib/providers/api_client_provider.dart +++ b/lib/providers/api_client_provider.dart @@ -18,6 +18,7 @@ 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'; @@ -82,15 +83,24 @@ final notesRepositoryProvider = Provider((ref) { }); final tasksRepositoryProvider = Provider((ref) { - return TasksRepository(ref.watch(tasksApiProvider)); + return TasksRepository( + ref.watch(tasksApiProvider), + ref.watch(fabledDatabaseProvider), + ); }); final chatRepositoryProvider = Provider((ref) { - return ChatRepository(ref.watch(chatApiProvider)); + return ChatRepository( + ref.watch(chatApiProvider), + ref.watch(fabledDatabaseProvider), + ); }); final projectsRepositoryProvider = Provider((ref) { - return ProjectsRepository(ref.watch(projectsApiProvider)); + return ProjectsRepository( + ref.watch(projectsApiProvider), + ref.watch(fabledDatabaseProvider), + ); }); final milestonesApiProvider = Provider((ref) { @@ -98,7 +108,10 @@ final milestonesApiProvider = Provider((ref) { }); final milestonesRepositoryProvider = Provider((ref) { - return MilestonesRepository(ref.watch(milestonesApiProvider)); + return MilestonesRepository( + ref.watch(milestonesApiProvider), + ref.watch(fabledDatabaseProvider), + ); }); final knowledgeApiProvider = Provider((ref) { @@ -128,3 +141,10 @@ final voiceRepositoryProvider = Provider((ref) { final eventsApiProvider = Provider((ref) { return EventsApi(ref.watch(dioProvider)); }); + +final eventsRepositoryProvider = Provider((ref) { + return EventsRepository( + ref.watch(eventsApiProvider), + ref.watch(fabledDatabaseProvider), + ); +}); diff --git a/lib/providers/calendar_provider.dart b/lib/providers/calendar_provider.dart index 3afc96c..98b0e9d 100644 --- a/lib/providers/calendar_provider.dart +++ b/lib/providers/calendar_provider.dart @@ -47,7 +47,7 @@ class CalendarNotifier extends AsyncNotifier { // 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 { Future 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 { 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>.from(s.eventsByDay); for (final e in events) { diff --git a/lib/screens/calendar/event_form_sheet.dart b/lib/screens/calendar/event_form_sheet.dart index 2bb871e..2090628 100644 --- a/lib/screens/calendar/event_form_sheet.dart +++ b/lib/screens/calendar/event_form_sheet.dart @@ -120,12 +120,12 @@ class _EventFormSheetState extends ConsumerState { '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(); @@ -165,7 +165,7 @@ class _EventFormSheetState extends ConsumerState { 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 (_) { diff --git a/lib/widgets/offline_banner.dart b/lib/widgets/offline_banner.dart index 8cfa6f1..a53e97b 100644 --- a/lib/widgets/offline_banner.dart +++ b/lib/widgets/offline_banner.dart @@ -43,8 +43,10 @@ class _OfflineBannerState extends ConsumerState { Future _loadLastSync() async { if (!mounted) return; try { - final ts = - await ref.read(notesRepositoryProvider).lastSyncedAt(); + // 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 (_) { From 7df7b5ff85525cab0b4bc2fc3d120b7b31f10996 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Tue, 28 Apr 2026 22:08:40 -0400 Subject: [PATCH 4/5] =?UTF-8?q?feat(offline):=20tier=202=20phase=203=20?= =?UTF-8?q?=E2=80=94=20write=20queue=20with=20optimistic=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offline writes now queue + apply optimistically instead of throwing. On reconnect, the queue drains automatically; conflicts and rejections surface as one-time SnackBars so the user knows their edit didn't land. - Drift schema v3: `pending_writes` table (verb + payload + baseline + tries + last_error). Negative ids serve as cache placeholders for offline-created rows until replay assigns the server id. - Each write repository (notes, tasks, projects, milestones, events) now catches NetworkException, applies the change to cache (with a fresh `nextTempId()` for creates), and enqueues the API call. - Edits to a still-queued offline-created row coalesce into the original create payload — only one server call per row, in order. - Deletes of still-queued offline-created rows drop the queue entry and the cache row; no server call ever happens. - New WriteQueue service drains the queue oldest-first. Server-wins on `updated_at` baseline check (notes/tasks/projects); last-writer-wins for milestones/events (no getOne available). 4xx → drop + surface as `rejected`; conflict → drop + `overwritten`; 404 on update → drop + `missing`; 5xx/network → keep + retry next online cycle. - Replay fires on AuthStatus → authenticated transitions (cold-start, came-back-online, login). - OfflineBanner shows "Retry (N)" with the pending-writes count. - Distinct ServerException added so 4xx no longer masquerade as NetworkException — the queue can drop them instead of looping. flutter analyze clean; 21 tests pass (3 new for QueueFailure messaging). Phase 4 (read-only UI indicators on cached/temp rows) still ahead. Co-Authored-By: Claude Opus 4.7 --- lib/app.dart | 29 + lib/core/exceptions.dart | 9 + lib/data/api/api_client.dart | 16 + lib/data/local/database.dart | 127 ++- lib/data/local/database.g.dart | 902 ++++++++++++++++++ lib/data/repositories/events_repository.dart | 123 ++- .../repositories/milestones_repository.dart | 127 ++- lib/data/repositories/notes_repository.dart | 144 ++- .../repositories/projects_repository.dart | 113 ++- lib/data/repositories/tasks_repository.dart | 138 ++- lib/data/repositories/write_queue.dart | 385 ++++++++ lib/providers/api_client_provider.dart | 25 + lib/widgets/offline_banner.dart | 12 +- test/widget_test.dart | 33 + 14 files changed, 2090 insertions(+), 93 deletions(-) create mode 100644 lib/data/repositories/write_queue.dart diff --git a/lib/app.dart b/lib/app.dart index 05c727a..fe61fed 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -7,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'; @@ -331,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. @@ -341,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); diff --git a/lib/core/exceptions.dart b/lib/core/exceptions.dart index a64fd1c..0daabb5 100644 --- a/lib/core/exceptions.dart +++ b/lib/core/exceptions.dart @@ -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); +} diff --git a/lib/data/api/api_client.dart b/lib/data/api/api_client.dart index e88b917..098eeda 100644 --- a/lib/data/api/api_client.dart +++ b/lib/data/api/api_client.dart @@ -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) { + for (final key in const ['detail', 'error', 'message']) { + final value = data[key]; + if (value is String && value.isNotEmpty) return value; + } + } + return null; +} diff --git a/lib/data/local/database.dart b/lib/data/local/database.dart index de84957..fe4f01e 100644 --- a/lib/data/local/database.dart +++ b/lib/data/local/database.dart @@ -136,6 +136,29 @@ class SyncMetadata extends Table { Set 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'; @@ -143,6 +166,10 @@ 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, @@ -151,13 +178,14 @@ const String kSyncDomainConversations = 'conversations'; CachedCalendarEvents, CachedConversations, SyncMetadata, + PendingWrites, ]) class FabledDatabase extends _$FabledDatabase { FabledDatabase() : super(_openConnection()); FabledDatabase.forTesting(super.executor); @override - int get schemaVersion => 2; + int get schemaVersion => 3; @override MigrationStrategy get migration => MigrationStrategy( @@ -171,6 +199,10 @@ class FabledDatabase extends _$FabledDatabase { await m.createTable(cachedCalendarEvents); await m.createTable(cachedConversations); } + // v2 → v3: added the offline write queue. + if (from < 3) { + await m.createTable(pendingWrites); + } }, ); @@ -448,6 +480,99 @@ class FabledDatabase extends _$FabledDatabase { ), ); } + + // ── 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 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('m'); + return (current ?? 0) - 1; + } + + Future enqueuePending({ + required String domain, + required String verb, + int? targetId, + int? tempId, + Map 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 findQueuedCreate(String domain, int tempId) { + return (select(pendingWrites) + ..where((t) => + t.domain.equals(domain) & + t.verb.equals(kWriteVerbCreate) & + t.tempId.equals(tempId))) + .getSingleOrNull(); + } + + Future updatePendingPayload(int opId, Map payload) { + return (update(pendingWrites)..where((t) => t.id.equals(opId))).write( + PendingWritesCompanion(payloadJson: Value(jsonEncode(payload))), + ); + } + + Future deletePending(int opId) { + return (delete(pendingWrites)..where((t) => t.id.equals(opId))).go(); + } + + /// Pending ops oldest first — replay order. + Future> listPending() { + return (select(pendingWrites) + ..orderBy([(t) => OrderingTerm.asc(t.id)])) + .get(); + } + + Future queueDepth() async { + final row = await customSelect('SELECT COUNT(*) AS c FROM pending_writes') + .getSingle(); + return row.read('c'); + } + + Future markPendingFailed(int opId, String error) { + return (update(pendingWrites)..where((t) => t.id.equals(opId))).write( + PendingWritesCompanion( + tries: const Value.absent(), + lastError: Value(error), + ), + ); + } + + Future 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 watchQueueDepth() { + final query = customSelect( + 'SELECT COUNT(*) AS c FROM pending_writes', + readsFrom: {pendingWrites}, + ); + return query.watchSingle().map((row) => row.read('c')); + } } // ── Row ↔ model converters ────────────────────────────────────────────────── diff --git a/lib/data/local/database.g.dart b/lib/data/local/database.g.dart index 2598bca..3ee62cf 100644 --- a/lib/data/local/database.g.dart +++ b/lib/data/local/database.g.dart @@ -3831,6 +3831,609 @@ class SyncMetadataCompanion extends UpdateCompanion { } } +class $PendingWritesTable extends PendingWrites + with TableInfo<$PendingWritesTable, PendingWrite> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $PendingWritesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _domainMeta = const VerificationMeta('domain'); + @override + late final GeneratedColumn domain = GeneratedColumn( + 'domain', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _verbMeta = const VerificationMeta('verb'); + @override + late final GeneratedColumn verb = GeneratedColumn( + 'verb', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _targetIdMeta = const VerificationMeta( + 'targetId', + ); + @override + late final GeneratedColumn targetId = GeneratedColumn( + 'target_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _tempIdMeta = const VerificationMeta('tempId'); + @override + late final GeneratedColumn tempId = GeneratedColumn( + 'temp_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _payloadJsonMeta = const VerificationMeta( + 'payloadJson', + ); + @override + late final GeneratedColumn payloadJson = GeneratedColumn( + 'payload_json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('{}'), + ); + static const VerificationMeta _baselineUpdatedAtMeta = const VerificationMeta( + 'baselineUpdatedAt', + ); + @override + late final GeneratedColumn baselineUpdatedAt = + GeneratedColumn( + 'baseline_updated_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + clientDefault: () => DateTime.now(), + ); + static const VerificationMeta _triesMeta = const VerificationMeta('tries'); + @override + late final GeneratedColumn tries = GeneratedColumn( + 'tries', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _lastErrorMeta = const VerificationMeta( + 'lastError', + ); + @override + late final GeneratedColumn lastError = GeneratedColumn( + 'last_error', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + domain, + verb, + targetId, + tempId, + payloadJson, + baselineUpdatedAt, + createdAt, + tries, + lastError, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'pending_writes'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('domain')) { + context.handle( + _domainMeta, + domain.isAcceptableOrUnknown(data['domain']!, _domainMeta), + ); + } else if (isInserting) { + context.missing(_domainMeta); + } + if (data.containsKey('verb')) { + context.handle( + _verbMeta, + verb.isAcceptableOrUnknown(data['verb']!, _verbMeta), + ); + } else if (isInserting) { + context.missing(_verbMeta); + } + if (data.containsKey('target_id')) { + context.handle( + _targetIdMeta, + targetId.isAcceptableOrUnknown(data['target_id']!, _targetIdMeta), + ); + } + if (data.containsKey('temp_id')) { + context.handle( + _tempIdMeta, + tempId.isAcceptableOrUnknown(data['temp_id']!, _tempIdMeta), + ); + } + if (data.containsKey('payload_json')) { + context.handle( + _payloadJsonMeta, + payloadJson.isAcceptableOrUnknown( + data['payload_json']!, + _payloadJsonMeta, + ), + ); + } + if (data.containsKey('baseline_updated_at')) { + context.handle( + _baselineUpdatedAtMeta, + baselineUpdatedAt.isAcceptableOrUnknown( + data['baseline_updated_at']!, + _baselineUpdatedAtMeta, + ), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + if (data.containsKey('tries')) { + context.handle( + _triesMeta, + tries.isAcceptableOrUnknown(data['tries']!, _triesMeta), + ); + } + if (data.containsKey('last_error')) { + context.handle( + _lastErrorMeta, + lastError.isAcceptableOrUnknown(data['last_error']!, _lastErrorMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + PendingWrite map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PendingWrite( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + domain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}domain'], + )!, + verb: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}verb'], + )!, + targetId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}target_id'], + ), + tempId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}temp_id'], + ), + payloadJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}payload_json'], + )!, + baselineUpdatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}baseline_updated_at'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + tries: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}tries'], + )!, + lastError: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_error'], + ), + ); + } + + @override + $PendingWritesTable createAlias(String alias) { + return $PendingWritesTable(attachedDatabase, alias); + } +} + +class PendingWrite extends DataClass implements Insertable { + final int id; + final String domain; + final String verb; + final int? targetId; + final int? tempId; + final String payloadJson; + final DateTime? baselineUpdatedAt; + final DateTime createdAt; + final int tries; + final String? lastError; + const PendingWrite({ + required this.id, + required this.domain, + required this.verb, + this.targetId, + this.tempId, + required this.payloadJson, + this.baselineUpdatedAt, + required this.createdAt, + required this.tries, + this.lastError, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['domain'] = Variable(domain); + map['verb'] = Variable(verb); + if (!nullToAbsent || targetId != null) { + map['target_id'] = Variable(targetId); + } + if (!nullToAbsent || tempId != null) { + map['temp_id'] = Variable(tempId); + } + map['payload_json'] = Variable(payloadJson); + if (!nullToAbsent || baselineUpdatedAt != null) { + map['baseline_updated_at'] = Variable(baselineUpdatedAt); + } + map['created_at'] = Variable(createdAt); + map['tries'] = Variable(tries); + if (!nullToAbsent || lastError != null) { + map['last_error'] = Variable(lastError); + } + return map; + } + + PendingWritesCompanion toCompanion(bool nullToAbsent) { + return PendingWritesCompanion( + id: Value(id), + domain: Value(domain), + verb: Value(verb), + targetId: targetId == null && nullToAbsent + ? const Value.absent() + : Value(targetId), + tempId: tempId == null && nullToAbsent + ? const Value.absent() + : Value(tempId), + payloadJson: Value(payloadJson), + baselineUpdatedAt: baselineUpdatedAt == null && nullToAbsent + ? const Value.absent() + : Value(baselineUpdatedAt), + createdAt: Value(createdAt), + tries: Value(tries), + lastError: lastError == null && nullToAbsent + ? const Value.absent() + : Value(lastError), + ); + } + + factory PendingWrite.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PendingWrite( + id: serializer.fromJson(json['id']), + domain: serializer.fromJson(json['domain']), + verb: serializer.fromJson(json['verb']), + targetId: serializer.fromJson(json['targetId']), + tempId: serializer.fromJson(json['tempId']), + payloadJson: serializer.fromJson(json['payloadJson']), + baselineUpdatedAt: serializer.fromJson( + json['baselineUpdatedAt'], + ), + createdAt: serializer.fromJson(json['createdAt']), + tries: serializer.fromJson(json['tries']), + lastError: serializer.fromJson(json['lastError']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'domain': serializer.toJson(domain), + 'verb': serializer.toJson(verb), + 'targetId': serializer.toJson(targetId), + 'tempId': serializer.toJson(tempId), + 'payloadJson': serializer.toJson(payloadJson), + 'baselineUpdatedAt': serializer.toJson(baselineUpdatedAt), + 'createdAt': serializer.toJson(createdAt), + 'tries': serializer.toJson(tries), + 'lastError': serializer.toJson(lastError), + }; + } + + PendingWrite copyWith({ + int? id, + String? domain, + String? verb, + Value targetId = const Value.absent(), + Value tempId = const Value.absent(), + String? payloadJson, + Value baselineUpdatedAt = const Value.absent(), + DateTime? createdAt, + int? tries, + Value lastError = const Value.absent(), + }) => PendingWrite( + id: id ?? this.id, + domain: domain ?? this.domain, + verb: verb ?? this.verb, + targetId: targetId.present ? targetId.value : this.targetId, + tempId: tempId.present ? tempId.value : this.tempId, + payloadJson: payloadJson ?? this.payloadJson, + baselineUpdatedAt: baselineUpdatedAt.present + ? baselineUpdatedAt.value + : this.baselineUpdatedAt, + createdAt: createdAt ?? this.createdAt, + tries: tries ?? this.tries, + lastError: lastError.present ? lastError.value : this.lastError, + ); + PendingWrite copyWithCompanion(PendingWritesCompanion data) { + return PendingWrite( + id: data.id.present ? data.id.value : this.id, + domain: data.domain.present ? data.domain.value : this.domain, + verb: data.verb.present ? data.verb.value : this.verb, + targetId: data.targetId.present ? data.targetId.value : this.targetId, + tempId: data.tempId.present ? data.tempId.value : this.tempId, + payloadJson: data.payloadJson.present + ? data.payloadJson.value + : this.payloadJson, + baselineUpdatedAt: data.baselineUpdatedAt.present + ? data.baselineUpdatedAt.value + : this.baselineUpdatedAt, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + tries: data.tries.present ? data.tries.value : this.tries, + lastError: data.lastError.present ? data.lastError.value : this.lastError, + ); + } + + @override + String toString() { + return (StringBuffer('PendingWrite(') + ..write('id: $id, ') + ..write('domain: $domain, ') + ..write('verb: $verb, ') + ..write('targetId: $targetId, ') + ..write('tempId: $tempId, ') + ..write('payloadJson: $payloadJson, ') + ..write('baselineUpdatedAt: $baselineUpdatedAt, ') + ..write('createdAt: $createdAt, ') + ..write('tries: $tries, ') + ..write('lastError: $lastError') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + domain, + verb, + targetId, + tempId, + payloadJson, + baselineUpdatedAt, + createdAt, + tries, + lastError, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PendingWrite && + other.id == this.id && + other.domain == this.domain && + other.verb == this.verb && + other.targetId == this.targetId && + other.tempId == this.tempId && + other.payloadJson == this.payloadJson && + other.baselineUpdatedAt == this.baselineUpdatedAt && + other.createdAt == this.createdAt && + other.tries == this.tries && + other.lastError == this.lastError); +} + +class PendingWritesCompanion extends UpdateCompanion { + final Value id; + final Value domain; + final Value verb; + final Value targetId; + final Value tempId; + final Value payloadJson; + final Value baselineUpdatedAt; + final Value createdAt; + final Value tries; + final Value lastError; + const PendingWritesCompanion({ + this.id = const Value.absent(), + this.domain = const Value.absent(), + this.verb = const Value.absent(), + this.targetId = const Value.absent(), + this.tempId = const Value.absent(), + this.payloadJson = const Value.absent(), + this.baselineUpdatedAt = const Value.absent(), + this.createdAt = const Value.absent(), + this.tries = const Value.absent(), + this.lastError = const Value.absent(), + }); + PendingWritesCompanion.insert({ + this.id = const Value.absent(), + required String domain, + required String verb, + this.targetId = const Value.absent(), + this.tempId = const Value.absent(), + this.payloadJson = const Value.absent(), + this.baselineUpdatedAt = const Value.absent(), + this.createdAt = const Value.absent(), + this.tries = const Value.absent(), + this.lastError = const Value.absent(), + }) : domain = Value(domain), + verb = Value(verb); + static Insertable custom({ + Expression? id, + Expression? domain, + Expression? verb, + Expression? targetId, + Expression? tempId, + Expression? payloadJson, + Expression? baselineUpdatedAt, + Expression? createdAt, + Expression? tries, + Expression? lastError, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (domain != null) 'domain': domain, + if (verb != null) 'verb': verb, + if (targetId != null) 'target_id': targetId, + if (tempId != null) 'temp_id': tempId, + if (payloadJson != null) 'payload_json': payloadJson, + if (baselineUpdatedAt != null) 'baseline_updated_at': baselineUpdatedAt, + if (createdAt != null) 'created_at': createdAt, + if (tries != null) 'tries': tries, + if (lastError != null) 'last_error': lastError, + }); + } + + PendingWritesCompanion copyWith({ + Value? id, + Value? domain, + Value? verb, + Value? targetId, + Value? tempId, + Value? payloadJson, + Value? baselineUpdatedAt, + Value? createdAt, + Value? tries, + Value? lastError, + }) { + return PendingWritesCompanion( + id: id ?? this.id, + domain: domain ?? this.domain, + verb: verb ?? this.verb, + targetId: targetId ?? this.targetId, + tempId: tempId ?? this.tempId, + payloadJson: payloadJson ?? this.payloadJson, + baselineUpdatedAt: baselineUpdatedAt ?? this.baselineUpdatedAt, + createdAt: createdAt ?? this.createdAt, + tries: tries ?? this.tries, + lastError: lastError ?? this.lastError, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (domain.present) { + map['domain'] = Variable(domain.value); + } + if (verb.present) { + map['verb'] = Variable(verb.value); + } + if (targetId.present) { + map['target_id'] = Variable(targetId.value); + } + if (tempId.present) { + map['temp_id'] = Variable(tempId.value); + } + if (payloadJson.present) { + map['payload_json'] = Variable(payloadJson.value); + } + if (baselineUpdatedAt.present) { + map['baseline_updated_at'] = Variable(baselineUpdatedAt.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (tries.present) { + map['tries'] = Variable(tries.value); + } + if (lastError.present) { + map['last_error'] = Variable(lastError.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PendingWritesCompanion(') + ..write('id: $id, ') + ..write('domain: $domain, ') + ..write('verb: $verb, ') + ..write('targetId: $targetId, ') + ..write('tempId: $tempId, ') + ..write('payloadJson: $payloadJson, ') + ..write('baselineUpdatedAt: $baselineUpdatedAt, ') + ..write('createdAt: $createdAt, ') + ..write('tries: $tries, ') + ..write('lastError: $lastError') + ..write(')')) + .toString(); + } +} + abstract class _$FabledDatabase extends GeneratedDatabase { _$FabledDatabase(QueryExecutor e) : super(e); $FabledDatabaseManager get managers => $FabledDatabaseManager(this); @@ -3845,6 +4448,7 @@ abstract class _$FabledDatabase extends GeneratedDatabase { late final $CachedConversationsTable cachedConversations = $CachedConversationsTable(this); late final $SyncMetadataTable syncMetadata = $SyncMetadataTable(this); + late final $PendingWritesTable pendingWrites = $PendingWritesTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -3857,6 +4461,7 @@ abstract class _$FabledDatabase extends GeneratedDatabase { cachedCalendarEvents, cachedConversations, syncMetadata, + pendingWrites, ]; } @@ -5830,6 +6435,301 @@ typedef $$SyncMetadataTableProcessedTableManager = SyncMetadataData, PrefetchHooks Function() >; +typedef $$PendingWritesTableCreateCompanionBuilder = + PendingWritesCompanion Function({ + Value id, + required String domain, + required String verb, + Value targetId, + Value tempId, + Value payloadJson, + Value baselineUpdatedAt, + Value createdAt, + Value tries, + Value lastError, + }); +typedef $$PendingWritesTableUpdateCompanionBuilder = + PendingWritesCompanion Function({ + Value id, + Value domain, + Value verb, + Value targetId, + Value tempId, + Value payloadJson, + Value baselineUpdatedAt, + Value createdAt, + Value tries, + Value lastError, + }); + +class $$PendingWritesTableFilterComposer + extends Composer<_$FabledDatabase, $PendingWritesTable> { + $$PendingWritesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get domain => $composableBuilder( + column: $table.domain, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get verb => $composableBuilder( + column: $table.verb, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get targetId => $composableBuilder( + column: $table.targetId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get tempId => $composableBuilder( + column: $table.tempId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get payloadJson => $composableBuilder( + column: $table.payloadJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get baselineUpdatedAt => $composableBuilder( + column: $table.baselineUpdatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get tries => $composableBuilder( + column: $table.tries, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastError => $composableBuilder( + column: $table.lastError, + builder: (column) => ColumnFilters(column), + ); +} + +class $$PendingWritesTableOrderingComposer + extends Composer<_$FabledDatabase, $PendingWritesTable> { + $$PendingWritesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get domain => $composableBuilder( + column: $table.domain, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get verb => $composableBuilder( + column: $table.verb, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get targetId => $composableBuilder( + column: $table.targetId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get tempId => $composableBuilder( + column: $table.tempId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get payloadJson => $composableBuilder( + column: $table.payloadJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get baselineUpdatedAt => $composableBuilder( + column: $table.baselineUpdatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get tries => $composableBuilder( + column: $table.tries, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastError => $composableBuilder( + column: $table.lastError, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$PendingWritesTableAnnotationComposer + extends Composer<_$FabledDatabase, $PendingWritesTable> { + $$PendingWritesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get domain => + $composableBuilder(column: $table.domain, builder: (column) => column); + + GeneratedColumn get verb => + $composableBuilder(column: $table.verb, builder: (column) => column); + + GeneratedColumn get targetId => + $composableBuilder(column: $table.targetId, builder: (column) => column); + + GeneratedColumn get tempId => + $composableBuilder(column: $table.tempId, builder: (column) => column); + + GeneratedColumn get payloadJson => $composableBuilder( + column: $table.payloadJson, + builder: (column) => column, + ); + + GeneratedColumn get baselineUpdatedAt => $composableBuilder( + column: $table.baselineUpdatedAt, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get tries => + $composableBuilder(column: $table.tries, builder: (column) => column); + + GeneratedColumn get lastError => + $composableBuilder(column: $table.lastError, builder: (column) => column); +} + +class $$PendingWritesTableTableManager + extends + RootTableManager< + _$FabledDatabase, + $PendingWritesTable, + PendingWrite, + $$PendingWritesTableFilterComposer, + $$PendingWritesTableOrderingComposer, + $$PendingWritesTableAnnotationComposer, + $$PendingWritesTableCreateCompanionBuilder, + $$PendingWritesTableUpdateCompanionBuilder, + ( + PendingWrite, + BaseReferences<_$FabledDatabase, $PendingWritesTable, PendingWrite>, + ), + PendingWrite, + PrefetchHooks Function() + > { + $$PendingWritesTableTableManager( + _$FabledDatabase db, + $PendingWritesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$PendingWritesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$PendingWritesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$PendingWritesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value domain = const Value.absent(), + Value verb = const Value.absent(), + Value targetId = const Value.absent(), + Value tempId = const Value.absent(), + Value payloadJson = const Value.absent(), + Value baselineUpdatedAt = const Value.absent(), + Value createdAt = const Value.absent(), + Value tries = const Value.absent(), + Value lastError = const Value.absent(), + }) => PendingWritesCompanion( + id: id, + domain: domain, + verb: verb, + targetId: targetId, + tempId: tempId, + payloadJson: payloadJson, + baselineUpdatedAt: baselineUpdatedAt, + createdAt: createdAt, + tries: tries, + lastError: lastError, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String domain, + required String verb, + Value targetId = const Value.absent(), + Value tempId = const Value.absent(), + Value payloadJson = const Value.absent(), + Value baselineUpdatedAt = const Value.absent(), + Value createdAt = const Value.absent(), + Value tries = const Value.absent(), + Value lastError = const Value.absent(), + }) => PendingWritesCompanion.insert( + id: id, + domain: domain, + verb: verb, + targetId: targetId, + tempId: tempId, + payloadJson: payloadJson, + baselineUpdatedAt: baselineUpdatedAt, + createdAt: createdAt, + tries: tries, + lastError: lastError, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$PendingWritesTableProcessedTableManager = + ProcessedTableManager< + _$FabledDatabase, + $PendingWritesTable, + PendingWrite, + $$PendingWritesTableFilterComposer, + $$PendingWritesTableOrderingComposer, + $$PendingWritesTableAnnotationComposer, + $$PendingWritesTableCreateCompanionBuilder, + $$PendingWritesTableUpdateCompanionBuilder, + ( + PendingWrite, + BaseReferences<_$FabledDatabase, $PendingWritesTable, PendingWrite>, + ), + PendingWrite, + PrefetchHooks Function() + >; class $FabledDatabaseManager { final _$FabledDatabase _db; @@ -5848,4 +6748,6 @@ class $FabledDatabaseManager { $$CachedConversationsTableTableManager(_db, _db.cachedConversations); $$SyncMetadataTableTableManager get syncMetadata => $$SyncMetadataTableTableManager(_db, _db.syncMetadata); + $$PendingWritesTableTableManager get pendingWrites => + $$PendingWritesTableTableManager(_db, _db.pendingWrites); } diff --git a/lib/data/repositories/events_repository.dart b/lib/data/repositories/events_repository.dart index 41dd3a4..373ff19 100644 --- a/lib/data/repositories/events_repository.dart +++ b/lib/data/repositories/events_repository.dart @@ -3,10 +3,13 @@ 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. +/// 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; @@ -26,20 +29,116 @@ class EventsRepository { } Future createEvent(Map payload) async { - final event = await _api.createEvent(payload); - await _db.upsertEvent(event); - return event; + 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 updateEvent( int id, Map fields) async { - final updated = await _api.updateEvent(id, fields); - await _db.upsertEvent(updated); - return updated; + 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 deleteEvent(int id) async { - await _api.deleteEvent(id); - await _db.deleteEvent(id); + 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 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 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; +} diff --git a/lib/data/repositories/milestones_repository.dart b/lib/data/repositories/milestones_repository.dart index c9eb364..2d3b4cd 100644 --- a/lib/data/repositories/milestones_repository.dart +++ b/lib/data/repositories/milestones_repository.dart @@ -3,8 +3,9 @@ import '../api/milestones_api.dart'; import '../local/database.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. +/// 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; final FabledDatabase _db; @@ -40,14 +41,45 @@ class MilestonesRepository { String? description, int orderIndex = 0, }) async { - final milestone = await _api.create( - projectId, - title: title, - description: description, - orderIndex: orderIndex, - ); - await _db.upsertMilestone(milestone); - return milestone; + 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 update( @@ -55,13 +87,78 @@ class MilestonesRepository { int milestoneId, Map fields, ) async { - final updated = await _api.update(projectId, milestoneId, fields); - await _db.upsertMilestone(updated); - return updated; + 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 = { + 'project_id': projectId, + ...fields, + }; + await _db.updatePendingPayload(queued.id, merged); + return optimistic; + } + } + await _db.enqueuePending( + domain: kSyncDomainMilestones, + verb: kWriteVerbUpdate, + targetId: milestoneId, + payload: {'project_id': projectId, ...fields}, + baselineUpdatedAt: cached.updatedAt, + ); + return optimistic; + } } Future delete(int projectId, int milestoneId) async { - await _api.delete(projectId, milestoneId); - await _db.deleteMilestone(milestoneId); + 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 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, + ); +} diff --git a/lib/data/repositories/notes_repository.dart b/lib/data/repositories/notes_repository.dart index 41042ac..f9ec037 100644 --- a/lib/data/repositories/notes_repository.dart +++ b/lib/data/repositories/notes_repository.dart @@ -3,19 +3,22 @@ import '../api/notes_api.dart'; import '../local/database.dart'; import '../models/note.dart'; -/// Notes repository with read-through caching for Tier 2 offline support. +/// 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 (create / update / delete) hit the -/// server then sync the cache; offline write queueing is Phase 3 work and -/// not yet wired here — write methods will throw on `NetworkException` in -/// the meantime. +/// 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 — its -/// only job is to serve what's cached when the network is unreachable. +/// heartbeat). This repository deliberately does not poke that state. class NotesRepository { final NotesApi _api; final FabledDatabase _db; @@ -53,15 +56,44 @@ class NotesRepository { int? projectId, String noteType = 'note', }) async { - final note = await _api.create( - title, - body, - tags: tags, - projectId: projectId, - noteType: noteType, - ); - await _db.upsertNote(note); - return note; + 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 update( @@ -73,21 +105,75 @@ class NotesRepository { bool clearProject = false, String noteType = 'note', }) async { - final updated = await _api.update( - id, - title, - body, - tags: tags, - projectId: projectId, - clearProject: clearProject, - noteType: noteType, - ); - await _db.upsertNote(updated); - return updated; + 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 = { + '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 delete(int id) async { - await _api.delete(id); - await _db.deleteNote(id); + 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, + ); + } } } diff --git a/lib/data/repositories/projects_repository.dart b/lib/data/repositories/projects_repository.dart index e6b617d..ccd4cf1 100644 --- a/lib/data/repositories/projects_repository.dart +++ b/lib/data/repositories/projects_repository.dart @@ -3,8 +3,8 @@ import '../api/projects_api.dart'; import '../local/database.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. +/// 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 @@ -63,25 +63,106 @@ class ProjectsRepository { String? color, String status = 'active', }) async { - final project = await _api.create( - title: title, - description: description, - goal: goal, - color: color, - status: status, - ); - await _db.upsertProject(project); - return project; + 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 update(int id, Map fields) async { - final updated = await _api.update(id, fields); - await _db.upsertProject(updated); - return updated; + 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 delete(int id) async { - await _api.delete(id); - await _db.deleteProject(id); + 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 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, + ); +} diff --git a/lib/data/repositories/tasks_repository.dart b/lib/data/repositories/tasks_repository.dart index 3bb7771..f4d04b4 100644 --- a/lib/data/repositories/tasks_repository.dart +++ b/lib/data/repositories/tasks_repository.dart @@ -3,8 +3,8 @@ import '../api/tasks_api.dart'; import '../local/database.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. +/// 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; final FabledDatabase _db; @@ -68,27 +68,129 @@ class TasksRepository { int? projectId, int? parentId, }) async { - final task = await _api.create( - title: title, - description: description, - status: status, - priority: priority, - dueDate: dueDate, - projectId: projectId, - parentId: parentId, - ); - await _db.upsertTask(task); - return task; + try { + final task = await _api.create( + title: title, + description: description, + status: status, + priority: priority, + dueDate: dueDate, + 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 update(int id, Map fields) async { - final updated = await _api.update(id, fields); - await _db.upsertTask(updated); - return updated; + 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 delete(int id) async { - await _api.delete(id); - await _db.deleteTask(id); + 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 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, + ); +} diff --git a/lib/data/repositories/write_queue.dart b/lib/data/repositories/write_queue.dart new file mode 100644 index 0000000..462b524 --- /dev/null +++ b/lib/data/repositories/write_queue.dart @@ -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 _failures = + StreamController.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 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 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 _replayOne(PendingWrite op) async { + final payload = jsonDecode(op.payloadJson) as Map; + 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 _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 _replayNote(PendingWrite op, Map 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( + 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 _replayTask(PendingWrite op, Map 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( + 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 _replayProject(PendingWrite op, Map 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( + 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 _replayMilestone( + PendingWrite op, Map 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.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 _replayEvent(PendingWrite op, Map 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 _conflictGuard({ + required DateTime? baseline, + required Future Function() fetch, + required DateTime Function(T) updatedAt, + required String Function(T) title, + required Future 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 _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); +} diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart index 1e5ada6..973e3f4 100644 --- a/lib/providers/api_client_provider.dart +++ b/lib/providers/api_client_provider.dart @@ -25,6 +25,7 @@ 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((ref) { @@ -148,3 +149,27 @@ final eventsRepositoryProvider = Provider((ref) { 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((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((ref) { + return ref.watch(fabledDatabaseProvider).watchQueueDepth(); +}); + +final writeQueueFailuresProvider = StreamProvider((ref) { + return ref.watch(writeQueueProvider).failures; +}); diff --git a/lib/widgets/offline_banner.dart b/lib/widgets/offline_banner.dart index a53e97b..35b2d7e 100644 --- a/lib/widgets/offline_banner.dart +++ b/lib/widgets/offline_banner.dart @@ -84,9 +84,17 @@ class _OfflineBannerState extends ConsumerState { final scheme = Theme.of(context).colorScheme; final ago = _lastSync == null ? null : _relativeAgo(_lastSync!); - final message = ago == null + // 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, @@ -117,7 +125,7 @@ class _OfflineBannerState extends ConsumerState { height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Retry'), + : Text(retryLabel), ), ], ), diff --git a/test/widget_test.dart b/test/widget_test.dart index 211d3b4..9dd90d8 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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')); + }); + }); + } From 76aff4ea9e1b1743dbfda75c557f82c285c1f076 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Tue, 28 Apr 2026 22:14:36 -0400 Subject: [PATCH 5/5] =?UTF-8?q?feat(offline):=20tier=202=20phase=204=20?= =?UTF-8?q?=E2=80=94=20per-row=20pending-sync=20indicators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small cloud-upload glyph appears next to any row whose id has a queued offline write (offline-created temp ids and pending edits both count). Tooltip reads "Pending sync — will save when online". Renders nothing during normal online operation so the list stays clean. - DB: `watchPendingIds(domain)` streams the union of target_id and temp_id across the queue, scoped per domain. - Per-domain Riverpod stream providers for notes / tasks / projects. - New `PendingSyncBadge` widget — used by KnowledgeItemCard (both list and grid variants), `_ProjectCard`, and `_TaskRow` in the project workspace. flutter analyze clean; 21 tests pass. Closes #147 — all four phases of Tier 2 offline mode are in place. Co-Authored-By: Claude Opus 4.7 --- lib/data/local/database.dart | 21 ++++++++ lib/providers/api_client_provider.dart | 17 +++++++ lib/screens/library/project_tasks_screen.dart | 36 +++++++++----- lib/screens/projects/projects_screen.dart | 9 +++- lib/widgets/knowledge_item_card.dart | 21 ++++++-- lib/widgets/pending_sync_badge.dart | 48 +++++++++++++++++++ 6 files changed, 135 insertions(+), 17 deletions(-) create mode 100644 lib/widgets/pending_sync_badge.dart diff --git a/lib/data/local/database.dart b/lib/data/local/database.dart index fe4f01e..18b5454 100644 --- a/lib/data/local/database.dart +++ b/lib/data/local/database.dart @@ -573,6 +573,27 @@ class FabledDatabase extends _$FabledDatabase { ); return query.watchSingle().map((row) => row.read('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> 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 = {}; + for (final r in rows) { + final t = r.read('target_id'); + final tmp = r.read('temp_id'); + if (t != null) ids.add(t); + if (tmp != null) ids.add(tmp); + } + return ids; + }); + } } // ── Row ↔ model converters ────────────────────────────────────────────────── diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart index 973e3f4..fe4248e 100644 --- a/lib/providers/api_client_provider.dart +++ b/lib/providers/api_client_provider.dart @@ -173,3 +173,20 @@ final writeQueueDepthProvider = StreamProvider((ref) { final writeQueueFailuresProvider = StreamProvider((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>((ref) { + return ref.watch(fabledDatabaseProvider).watchPendingIds(kSyncDomainNotes); +}); + +final pendingTaskIdsProvider = StreamProvider>((ref) { + return ref.watch(fabledDatabaseProvider).watchPendingIds(kSyncDomainTasks); +}); + +final pendingProjectIdsProvider = StreamProvider>((ref) { + return ref + .watch(fabledDatabaseProvider) + .watchPendingIds(kSyncDomainProjects); +}); diff --git a/lib/screens/library/project_tasks_screen.dart b/lib/screens/library/project_tasks_screen.dart index 9dd73f3..ae05f37 100644 --- a/lib/screens/library/project_tasks_screen.dart +++ b/lib/screens/library/project_tasks_screen.dart @@ -4,6 +4,7 @@ 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'; @@ -11,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; @@ -346,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), diff --git a/lib/screens/projects/projects_screen.dart b/lib/screens/projects/projects_screen.dart index 0a9ecf0..fa69844 100644 --- a/lib/screens/projects/projects_screen.dart +++ b/lib/screens/projects/projects_screen.dart @@ -3,8 +3,10 @@ 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}); @@ -50,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: [ diff --git a/lib/widgets/knowledge_item_card.dart b/lib/widgets/knowledge_item_card.dart index f1f8dda..f0d23a3 100644 --- a/lib/widgets/knowledge_item_card.dart +++ b/lib/widgets/knowledge_item_card.dart @@ -2,12 +2,17 @@ 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' => LucideIcons.user, 'place' => LucideIcons.mapPin, @@ -52,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( @@ -100,6 +112,7 @@ class KnowledgeItemCard extends StatelessWidget { ?.copyWith(fontWeight: FontWeight.w600), ), ), + PendingSyncBadge(domain: _pendingDomain, id: item.id), ], ), if (_subtitle != null) ...[ diff --git a/lib/widgets/pending_sync_badge.dart b/lib/widgets/pending_sync_badge.dart new file mode 100644 index 0000000..5673a24 --- /dev/null +++ b/lib/widgets/pending_sync_badge.dart @@ -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, + ), + ), + ); + } +}