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