From 844f68d3760fbd189e2106ed77e78ba5f26dd900 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Thu, 12 Mar 2026 08:23:24 -0400 Subject: [PATCH 1/5] fix: update loop, ghost queue item, briefing card scroll trap - Update dialog no longer re-appears on every shell re-mount; check() is skipped if status is already available/upToDate/downloading - Offline queue dequeue now happens before the mounted check, preventing ghost items when the widget disposes mid-drain - Briefing digest card and messages now share a CustomScrollView so expanding the card doesn't trap the user without scroll access Co-Authored-By: Claude Sonnet 4.6 --- lib/app.dart | 18 ++++-- lib/screens/briefing/briefing_screen.dart | 75 +++++++++++++---------- 2 files changed, 56 insertions(+), 37 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 963b26c..2992b77 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -152,10 +152,14 @@ class _ShellState extends ConsumerState<_Shell> { @override void initState() { super.initState(); - // Silent update check on first app load. + // Silent update check — only if we haven't already checked this session. + // Skipping when status is not idle/error prevents the dialog from + // re-appearing every time the shell re-mounts (e.g. after visiting settings). WidgetsBinding.instance.addPostFrameCallback((_) { final repoUrl = ref.read(forgejoRepoUrlProvider); - if (repoUrl != null && repoUrl.isNotEmpty) { + if (repoUrl == null || repoUrl.isEmpty) return; + final status = ref.read(updateProvider).status; + if (status == UpdateStatus.idle || status == UpdateStatus.error) { ref.read(updateProvider.notifier).check(repoUrl); } }); @@ -356,8 +360,10 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { if (!mounted) break; try { final result = await api.capture(text); - if (!mounted) break; + // Dequeue before the mounted check — SharedPreferences doesn't need + // the widget alive, and skipping this would leave a ghost item. await ref.read(captureQueueProvider.notifier).dequeue(text); + if (!mounted) break; switch (result.type) { case 'note': ref.invalidate(notesProvider); @@ -368,9 +374,9 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { } on NetworkException { break; } catch (_) { - if (mounted) { - await ref.read(captureQueueProvider.notifier).dequeue(text); - } + // Server error or unexpected failure — drop from queue to prevent + // ghost items that can never be cleared. + await ref.read(captureQueueProvider.notifier).dequeue(text); } } } diff --git a/lib/screens/briefing/briefing_screen.dart b/lib/screens/briefing/briefing_screen.dart index 69a71ad..b1cdfe3 100644 --- a/lib/screens/briefing/briefing_screen.dart +++ b/lib/screens/briefing/briefing_screen.dart @@ -154,39 +154,52 @@ class _BriefingScreenState extends ConsumerState { return Column( children: [ - // Digest card header - BriefingDigestCard( - message: firstAssistant, - onGenerateNow: _refresh, - ), - - // Divider + label - if (conv.messages.isNotEmpty) ...[ - const SizedBox(height: 4), - Row(children: [ - const Expanded(child: Divider()), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Text( - 'Conversation', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ), - const Expanded(child: Divider()), - ]), - ], - - // Message list + // Digest card + messages share one scroll view so expanding the + // card doesn't trap the user — the whole page just becomes taller. Expanded( - child: ListView.builder( + child: CustomScrollView( controller: _scrollController, - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 8), - itemCount: conv.messages.length, - itemBuilder: (_, i) => - ChatMessageBubble(message: conv.messages[i]), + slivers: [ + SliverToBoxAdapter( + child: BriefingDigestCard( + message: firstAssistant, + onGenerateNow: _refresh, + ), + ), + + if (conv.messages.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row(children: [ + const Expanded(child: Divider()), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 10), + child: Text( + 'Conversation', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: scheme.onSurfaceVariant), + ), + ), + const Expanded(child: Divider()), + ]), + ), + ), + + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 8), + sliver: SliverList.builder( + itemCount: conv.messages.length, + itemBuilder: (_, i) => + ChatMessageBubble(message: conv.messages[i]), + ), + ), + ], ), ), From 0971433c7aba5e24e7c40d173cbed8f8e77c45a3 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Thu, 12 Mar 2026 19:08:21 -0400 Subject: [PATCH 2/5] refactor: remove briefing digest card, show full message list directly The summary card duplicated the first assistant message and consumed screen real estate without benefit on small screens. Replaced with: - Full message list via ChatMessageBubble from the top - SliverFillRemaining empty state ("No briefing yet today" + generate button) when no messages exist Co-Authored-By: Claude Sonnet 4.6 --- lib/screens/briefing/briefing_screen.dart | 71 +++++++++-------------- 1 file changed, 27 insertions(+), 44 deletions(-) diff --git a/lib/screens/briefing/briefing_screen.dart b/lib/screens/briefing/briefing_screen.dart index b1cdfe3..23abd5b 100644 --- a/lib/screens/briefing/briefing_screen.dart +++ b/lib/screens/briefing/briefing_screen.dart @@ -2,9 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/exceptions.dart'; -import '../../data/models/message.dart'; import '../../providers/briefing_provider.dart'; -import '../../widgets/briefing_digest_card.dart'; import '../../widgets/chat_message_bubble.dart'; import 'briefing_history_screen.dart'; @@ -146,59 +144,44 @@ class _BriefingScreenState extends ConsumerState { ), ), data: (conv) { - // First assistant message for the digest card (null if none yet) - final Message? firstAssistant = conv.messages - .where((m) => m.role == MessageRole.assistant) - .toList() - .firstOrNull; - return Column( children: [ - // Digest card + messages share one scroll view so expanding the - // card doesn't trap the user — the whole page just becomes taller. Expanded( child: CustomScrollView( controller: _scrollController, slivers: [ - SliverToBoxAdapter( - child: BriefingDigestCard( - message: firstAssistant, - onGenerateNow: _refresh, - ), - ), - - if (conv.messages.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row(children: [ - const Expanded(child: Divider()), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 10), - child: Text( - 'Conversation', + if (conv.messages.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'No briefing yet today.', style: Theme.of(context) .textTheme - .labelSmall - ?.copyWith( - color: scheme.onSurfaceVariant), + .bodyMedium + ?.copyWith(color: scheme.onSurfaceVariant), ), - ), - const Expanded(child: Divider()), - ]), + const SizedBox(height: 12), + FilledButton.tonal( + onPressed: _refresh, + child: const Text('Generate now'), + ), + ], + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 8), + sliver: SliverList.builder( + itemCount: conv.messages.length, + itemBuilder: (_, i) => + ChatMessageBubble(message: conv.messages[i]), ), ), - - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 8), - sliver: SliverList.builder( - itemCount: conv.messages.length, - itemBuilder: (_, i) => - ChatMessageBubble(message: conv.messages[i]), - ), - ), ], ), ), From fc1c7cade2ff48ce8c3e7caec0bababcdfc2dbce Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Thu, 12 Mar 2026 20:52:39 -0400 Subject: [PATCH 3/5] chore: rename APK output to Fabled-.apk e.g. Fabled-2603122.apk for tag v26.03.12.2 Co-Authored-By: Claude Sonnet 4.6 --- android/app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ac1073e..f528af0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -61,7 +61,7 @@ android { val variant = this outputs.all { val output = this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl - output?.outputFileName = "Fabled-${variant.versionName}.${variant.versionCode}.apk" + output?.outputFileName = "Fabled-${variant.versionCode}.apk" } } } From a337c3fda3be5571f3539d72879d48cd93a15938 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Thu, 12 Mar 2026 21:01:01 -0400 Subject: [PATCH 4/5] Fix update dialog: show errors, handle OpenFile result correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Check OpenResult.type before resetting state — previously a failed open_file call (e.g. installer blocked on emulator) silently called state = const UpdateState(), nulling latestVersion and downloadUrl, causing "Version null" and a non-functional Download button - Show errorMessage in dialog with error colour so failures are visible - Guard Download button on downloadUrl != null (no button if URL lost) Co-Authored-By: Claude Sonnet 4.6 --- lib/app.dart | 14 ++++++++++++-- lib/providers/update_provider.dart | 14 ++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 2992b77..ef50671 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -185,7 +185,7 @@ class _ShellState extends ConsumerState<_Shell> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Version ${state.latestVersion} is ready to install.'), + Text('Version ${state.latestVersion ?? '?'} is ready to install.'), if (state.currentVersion != null) Text( 'Installed: v${state.currentVersion}', @@ -205,6 +205,16 @@ class _ShellState extends ConsumerState<_Shell> { style: Theme.of(context).textTheme.bodySmall, ), ], + if (state.status == UpdateStatus.error && + state.errorMessage != null) ...[ + const SizedBox(height: 12), + Text( + state.errorMessage!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ], ], ), actions: [ @@ -212,7 +222,7 @@ class _ShellState extends ConsumerState<_Shell> { onPressed: () => Navigator.pop(dialogContext), child: const Text('Later'), ), - if (!isDownloading) + if (!isDownloading && state.downloadUrl != null) FilledButton( onPressed: () => ref .read(updateProvider.notifier) diff --git a/lib/providers/update_provider.dart b/lib/providers/update_provider.dart index 6ef3473..73dbdcc 100644 --- a/lib/providers/update_provider.dart +++ b/lib/providers/update_provider.dart @@ -119,14 +119,20 @@ class UpdateNotifier extends Notifier { }, ); - await OpenFile.open( + final result = await OpenFile.open( path, type: 'application/vnd.android.package-archive', ); - // Transition to idle — the system installer is now open. - // Going back to `available` would re-trigger the update dialog loop. - state = const UpdateState(); + if (result.type == ResultType.done) { + // Installer launched — reset to idle so the dialog closes naturally. + state = const UpdateState(); + } else { + state = state.copyWith( + status: UpdateStatus.error, + errorMessage: 'Could not open installer: ${result.message}', + ); + } } catch (e) { state = state.copyWith( status: UpdateStatus.error, From 5ca5856f154a329e13c703307e93ab041ef319d2 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Thu, 12 Mar 2026 21:40:14 -0400 Subject: [PATCH 5/5] Project task view: tapping a project shows milestone-grouped task list - ProjectLibraryCard.onTap navigates to /projects/:id/tasks - New ProjectTasksScreen: milestone sections with coloured dot, done/total counter and thin progress bar; unassigned tasks at bottom - Status cycle icon updates optimistically (pendingStatus map) so the list doesn't flash on every tap; syncs global tasksProvider - New route /projects/:id/tasks registered in app.dart - TasksApi.getByProject + TasksRepository + projectTasksProvider (family) Co-Authored-By: Claude Sonnet 4.6 --- lib/app.dart | 7 + lib/core/constants.dart | 1 + lib/data/api/tasks_api.dart | 18 + lib/data/repositories/tasks_repository.dart | 1 + lib/providers/tasks_provider.dart | 5 + lib/screens/library/project_tasks_screen.dart | 381 ++++++++++++++++++ lib/widgets/library_item_card.dart | 3 +- 7 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 lib/screens/library/project_tasks_screen.dart diff --git a/lib/app.dart b/lib/app.dart index ef50671..135d78f 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -15,6 +15,7 @@ import 'providers/update_provider.dart'; import 'providers/tasks_provider.dart'; import 'screens/auth/login_screen.dart'; import 'screens/briefing/briefing_screen.dart'; +import 'screens/library/project_tasks_screen.dart'; import 'screens/chat/chat_screen.dart'; import 'screens/chat/conversations_tab_screen.dart'; import 'screens/library/library_screen.dart'; @@ -107,6 +108,12 @@ final routerProvider = Provider((ref) { taskId: int.parse(state.pathParameters['id']!), ), ), + GoRoute( + path: Routes.projectTasks, + builder: (_, state) => ProjectTasksScreen( + projectId: int.parse(state.pathParameters['id']!), + ), + ), GoRoute( path: Routes.chat, builder: (_, state) => ChatScreen( diff --git a/lib/core/constants.dart b/lib/core/constants.dart index 5400dac..9d84bf1 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -16,4 +16,5 @@ abstract class Routes { static const settings = '/settings'; static const briefing = '/briefing'; static const library = '/library'; + static const projectTasks = '/projects/:id/tasks'; } diff --git a/lib/data/api/tasks_api.dart b/lib/data/api/tasks_api.dart index bb098b8..a0837c0 100644 --- a/lib/data/api/tasks_api.dart +++ b/lib/data/api/tasks_api.dart @@ -52,6 +52,24 @@ class TasksApi { } } + Future> getByProject(int projectId) async { + try { + final response = await _dio.get( + '/api/notes', + queryParameters: { + 'project_id': projectId, + 'is_task': 'true', + 'limit': 500, + }, + ); + final data = response.data as Map; + final list = data['notes'] as List; + return list.map((e) => Task.fromJson(e as Map)).toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + Future> getSubTasks(int parentId) async { try { final response = await _dio.get( diff --git a/lib/data/repositories/tasks_repository.dart b/lib/data/repositories/tasks_repository.dart index 6b509e4..c8f3abe 100644 --- a/lib/data/repositories/tasks_repository.dart +++ b/lib/data/repositories/tasks_repository.dart @@ -27,6 +27,7 @@ class TasksRepository { parentId: parentId, ); + Future> getByProject(int projectId) => _api.getByProject(projectId); Future> getSubTasks(int parentId) => _api.getSubTasks(parentId); Future update(int id, Map fields) => diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index b9555d4..9ad6b42 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -6,6 +6,11 @@ import 'api_client_provider.dart'; final tasksProvider = AsyncNotifierProvider>(TasksNotifier.new); +final projectTasksProvider = + FutureProvider.family, int>((ref, projectId) { + return ref.watch(tasksRepositoryProvider).getByProject(projectId); +}); + class TasksNotifier extends AsyncNotifier> { @override Future> build() async { diff --git a/lib/screens/library/project_tasks_screen.dart b/lib/screens/library/project_tasks_screen.dart new file mode 100644 index 0000000..b21bbf5 --- /dev/null +++ b/lib/screens/library/project_tasks_screen.dart @@ -0,0 +1,381 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/constants.dart'; +import '../../data/models/milestone.dart'; +import '../../data/models/project.dart'; +import '../../data/models/task.dart'; +import '../../providers/api_client_provider.dart'; +import '../../providers/milestones_provider.dart'; +import '../../providers/projects_provider.dart'; +import '../../providers/tasks_provider.dart'; + +class ProjectTasksScreen extends ConsumerStatefulWidget { + final int projectId; + const ProjectTasksScreen({super.key, required this.projectId}); + + @override + ConsumerState createState() => _ProjectTasksScreenState(); +} + +class _ProjectTasksScreenState extends ConsumerState { + // Local optimistic status overrides — avoids a reload flash on every cycle tap. + final Map _pendingStatus = {}; + + TaskStatus _effectiveStatus(Task task) => + _pendingStatus[task.id] ?? task.status; + + TaskStatus _nextStatus(TaskStatus s) => switch (s) { + TaskStatus.todo => TaskStatus.inProgress, + TaskStatus.inProgress => TaskStatus.done, + TaskStatus.done => TaskStatus.todo, + }; + + Future _cycleStatus(Task task) async { + final current = _effectiveStatus(task); + final next = _nextStatus(current); + setState(() => _pendingStatus[task.id] = next); + try { + await ref + .read(tasksRepositoryProvider) + .update(task.id, {'status': next.value}); + // Sync the global tasks list so the library view stays consistent. + ref.invalidate(tasksProvider); + } catch (_) { + // Revert optimistic change on error. + setState(() => _pendingStatus.remove(task.id)); + } + } + + Color _parseColor(String? hex) { + if (hex == null || hex.isEmpty) return const Color(0xFF6366F1); + try { + return Color(int.parse(hex.replaceFirst('#', '0xFF'))); + } catch (_) { + return const Color(0xFF6366F1); + } + } + + @override + Widget build(BuildContext context) { + final tasksAsync = ref.watch(projectTasksProvider(widget.projectId)); + final milestonesAsync = ref.watch(projectMilestonesProvider(widget.projectId)); + final project = ref.watch(projectsProvider).value + ?.whereType() + .where((p) => p.id == widget.projectId) + .firstOrNull; + + final color = _parseColor(project?.color); + + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + project?.title ?? 'Project', + style: Theme.of(context).textTheme.titleLarge, + ), + if (project?.description?.isNotEmpty == true) + Text( + project!.description!, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + body: tasksAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error loading tasks: $e')), + data: (tasks) => _buildBody(context, tasks, milestonesAsync, color), + ), + ); + } + + Widget _buildBody( + BuildContext context, + List tasks, + AsyncValue> milestonesAsync, + Color color, + ) { + final milestones = (milestonesAsync.value ?? []).toList() + ..sort((a, b) => a.orderIndex.compareTo(b.orderIndex)); + + // Group tasks by milestoneId. + final byMilestone = >{}; + for (final t in tasks) { + (byMilestone[t.milestoneId] ??= []).add(t); + } + + final unassigned = byMilestone[null] ?? []; + + if (tasks.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_box_outlined, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(height: 12), + Text( + 'No tasks in this project yet.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: () async { + setState(() => _pendingStatus.clear()); + ref.invalidate(projectTasksProvider(widget.projectId)); + ref.invalidate(projectMilestonesProvider(widget.projectId)); + }, + child: CustomScrollView( + slivers: [ + // Top colour strip. + SliverToBoxAdapter(child: Container(height: 4, color: color)), + + // Milestone sections. + for (final ms in milestones) ...[ + SliverToBoxAdapter( + child: _MilestoneHeader( + milestone: ms, + tasks: byMilestone[ms.id] ?? [], + color: color, + ), + ), + if ((byMilestone[ms.id] ?? []).isNotEmpty) + SliverList.builder( + itemCount: byMilestone[ms.id]!.length, + itemBuilder: (_, i) { + final task = byMilestone[ms.id]![i]; + return _TaskRow( + task: task, + effectiveStatus: _effectiveStatus(task), + onStatusTap: () => _cycleStatus(task), + ); + }, + ), + ], + + // Unassigned tasks. + if (unassigned.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 6), + child: Text( + 'No milestone', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + ), + ), + SliverList.builder( + itemCount: unassigned.length, + itemBuilder: (_, i) { + final task = unassigned[i]; + return _TaskRow( + task: task, + effectiveStatus: _effectiveStatus(task), + onStatusTap: () => _cycleStatus(task), + ); + }, + ), + ], + + const SliverToBoxAdapter(child: SizedBox(height: 16)), + ], + ), + ); + } +} + +// ── Milestone section header ────────────────────────────────────────────────── + +class _MilestoneHeader extends StatelessWidget { + final Milestone milestone; + final List tasks; + final Color color; + + const _MilestoneHeader({ + required this.milestone, + required this.tasks, + required this.color, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final done = tasks.where((t) => t.status == TaskStatus.done).length; + final total = tasks.length; + final pct = total > 0 ? done / total : 0.0; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + milestone.title, + style: theme.textTheme.titleSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Text( + '$done / $total', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + if (total > 0) ...[ + const SizedBox(height: 4), + LinearProgressIndicator( + value: pct, + minHeight: 2, + color: color, + backgroundColor: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(1), + ), + ], + ], + ), + ); + } +} + +// ── Task row ────────────────────────────────────────────────────────────────── + +class _TaskRow extends StatelessWidget { + final Task task; + final TaskStatus effectiveStatus; + final VoidCallback onStatusTap; + + const _TaskRow({ + required this.task, + required this.effectiveStatus, + required this.onStatusTap, + }); + + IconData get _statusIcon => switch (effectiveStatus) { + TaskStatus.done => Icons.check_circle, + TaskStatus.inProgress => Icons.timelapse, + TaskStatus.todo => Icons.radio_button_unchecked, + }; + + Color _statusColor(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return switch (effectiveStatus) { + TaskStatus.done => const Color(0xFF22C55E), + TaskStatus.inProgress => cs.primary, + TaskStatus.todo => cs.onSurfaceVariant, + }; + } + + Color _priorityColor(BuildContext context) => switch (task.priority) { + TaskPriority.high => const Color(0xFFEF4444), + TaskPriority.medium => const Color(0xFFF59E0B), + _ => Colors.transparent, + }; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), + child: InkWell( + onTap: () => context + .push(Routes.taskEdit.replaceFirst(':id', '${task.id}')), + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 6, 14, 6), + child: Row( + children: [ + IconButton( + icon: Icon(_statusIcon, color: _statusColor(context)), + onPressed: onStatusTap, + tooltip: 'Cycle status', + visualDensity: VisualDensity.compact, + ), + Expanded( + 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, + ), + if (task.dueDate != null) ...[ + const SizedBox(height: 2), + Text( + 'Due ${_formatDate(task.dueDate!)}', + style: theme.textTheme.labelSmall?.copyWith( + color: task.dueDate!.isBefore(DateTime.now()) && + effectiveStatus != TaskStatus.done + ? const Color(0xFFEF4444) + : theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + if (task.priority == TaskPriority.high || + task.priority == TaskPriority.medium) + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: _priorityColor(context), + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ), + ); + } +} + +String _formatDate(DateTime dt) { + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + return '${months[dt.month - 1]} ${dt.day}'; +} diff --git a/lib/widgets/library_item_card.dart b/lib/widgets/library_item_card.dart index bfa29d0..ef0a970 100644 --- a/lib/widgets/library_item_card.dart +++ b/lib/widgets/library_item_card.dart @@ -210,7 +210,8 @@ class ProjectLibraryCard extends StatelessWidget { margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), clipBehavior: Clip.antiAlias, child: InkWell( - onTap: () {}, // No project detail screen in this app + onTap: () => context + .push('/projects/${project.id}/tasks'), borderRadius: BorderRadius.circular(14), child: Row( children: [