From f8c3422715daa19bd1dd04e10445b46724caefbd Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sun, 27 Sep 2026 21:40:07 +0800 Subject: [PATCH] refactor(programmer): shared task card driven by run states Extract _TaskCard from the list screen into ProgrammerTaskCard, a shared ConsumerWidget that reads programmerRunStatesProvider so the paused-task pill and Start/Resume/Complete buttons are consistent across any surface that embeds it. Move the _complete body verbatim to completeProgrammerTaskWithConfirm in programmer_task_actions.dart; the detail screen's _ActionBar now calls the shared function. Co-Authored-By: claude-flow --- .../programmer_task_actions.dart | 34 +++++ .../programmer_task_detail_screen.dart | 32 +--- .../programmer_tasks_list_screen.dart | 114 +------------- .../widgets/programmer_task_card.dart | 143 ++++++++++++++++++ test/programmer_task_card_test.dart | 64 ++++++++ 5 files changed, 245 insertions(+), 142 deletions(-) create mode 100644 lib/screens/programmer_tasks/widgets/programmer_task_card.dart create mode 100644 test/programmer_task_card_test.dart diff --git a/lib/screens/programmer_tasks/programmer_task_actions.dart b/lib/screens/programmer_tasks/programmer_task_actions.dart index 60294bed..f196da53 100644 --- a/lib/screens/programmer_tasks/programmer_task_actions.dart +++ b/lib/screens/programmer_tasks/programmer_task_actions.dart @@ -4,6 +4,40 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/programmer_task.model.dart'; import '../../providers/programmer_tasks_provider.dart'; +/// Confirms with the user and completes [task]. Body moved verbatim from the +/// detail screen's `_complete` so it can be called from both the card and the +/// detail action bar. +Future completeProgrammerTaskWithConfirm( + BuildContext context, + WidgetRef ref, + ProgrammerTask task, +) async { + final messenger = ScaffoldMessenger.of(context); + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + semanticLabel: 'Complete task', + title: const Text('Complete task?'), + content: Text('Mark "${task.title}" as completed?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Complete'), + ), + ], + ), + ); + if (ok != true) return; + await ref + .read(programmerTasksControllerProvider) + .completeTask(taskId: task.id); + messenger.showSnackBar(const SnackBar(content: Text('Task completed'))); +} + /// Starts (or resumes) [task] for the current user, enforcing the single-active /// task discipline: if the user already has a *running* task, they are prompted /// to pause it first. This is the headline "proper time management" behavior and diff --git a/lib/screens/programmer_tasks/programmer_task_detail_screen.dart b/lib/screens/programmer_tasks/programmer_task_detail_screen.dart index 49b85d38..1969b8c3 100644 --- a/lib/screens/programmer_tasks/programmer_task_detail_screen.dart +++ b/lib/screens/programmer_tasks/programmer_task_detail_screen.dart @@ -325,7 +325,8 @@ class _ActionBar extends ConsumerWidget { ), if (isRunning || isPaused) OutlinedButton.icon( - onPressed: () => _complete(context, ref, task), + onPressed: () => + completeProgrammerTaskWithConfirm(context, ref, task), icon: const Icon(Icons.check_rounded), label: const Text('Complete'), ), @@ -340,35 +341,6 @@ class _ActionBar extends ConsumerWidget { } -Future _complete( - BuildContext context, - WidgetRef ref, - ProgrammerTask task, -) async { - final messenger = ScaffoldMessenger.of(context); - final ok = await showDialog( - context: context, - builder: (context) => AlertDialog( - semanticLabel: 'Complete task', - title: const Text('Complete task?'), - content: Text('Mark "${task.title}" as completed?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Complete'), - ), - ], - ), - ); - if (ok != true) return; - await ref.read(programmerTasksControllerProvider).completeTask(taskId: task.id); - messenger.showSnackBar(const SnackBar(content: Text('Task completed'))); -} - Future _cancel( BuildContext context, WidgetRef ref, diff --git a/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart b/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart index d13243e3..98a717be 100644 --- a/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart +++ b/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart @@ -1,25 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import '../../models/profile.model.dart'; -import '../../models/programmer_task.model.dart'; import '../../providers/profile_provider.dart'; -import '../../providers/programmer_projects_provider.dart'; import '../../providers/programmer_tasks_provider.dart'; -import '../../theme/app_status_colors.dart'; import '../../theme/m3_motion.dart'; -import '../../utils/app_time.dart'; import '../../widgets/app_page_header.dart'; import '../../widgets/app_state_view.dart'; -import '../../widgets/m3_card.dart'; import '../../widgets/reconnect_overlay.dart'; import '../../widgets/responsive_body.dart'; -import '../../widgets/status_pill.dart'; -import '../../widgets/tech_chip.dart'; -import 'programmer_task_actions.dart'; import 'programmer_task_create.dart'; -import 'programmer_task_status_kind.dart'; +import 'widgets/programmer_task_card.dart'; class ProgrammerTasksListScreen extends ConsumerStatefulWidget { const ProgrammerTasksListScreen({super.key}); @@ -110,7 +101,7 @@ class _ProgrammerTasksListScreenState separatorBuilder: (_, _) => const SizedBox(height: 10), itemBuilder: (context, i) { final task = tasks[i]; - return _TaskCard( + return ProgrammerTaskCard( task: task, assignee: task.assigneeId == null ? null @@ -141,104 +132,3 @@ class _ProgrammerTasksListScreenState } } - -class _TaskCard extends ConsumerWidget { - const _TaskCard({required this.task, required this.assignee, required this.isMine}); - - final ProgrammerTask task; - final Profile? assignee; - final bool isMine; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final tt = Theme.of(context).textTheme; - final cs = Theme.of(context).colorScheme; - - // Distinguish a *paused* task from a *running* one (both share the - // `in_progress` status) using the current user's running-task id. - final runningId = ref.watch(myRunningProgrammerTaskIdProvider).valueOrNull; - final isInProgress = task.status == ProgrammerTaskStatus.inProgress; - final isPaused = isInProgress && isMine && task.id != runningId; - - final canStart = isMine && task.status == ProgrammerTaskStatus.queued; - final canResume = isPaused; - - final project = task.projectId == null - ? null - : ref.watch(programmerProjectByIdProvider(task.projectId!)); - - final pillKind = - isPaused ? StatusKind.warning : programmerTaskStatusKind(task.status); - final pillLabel = - isPaused ? 'Paused' : ProgrammerTaskStatus.label(task.status); - - return M3Card.outlined( - onTap: () => context.go('/programmer-tasks/${task.id}'), - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - task.title, - style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - StatusPill.kind(pillKind, label: pillLabel), - ], - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - TechChip(task.category, icon: Icons.category_outlined, dense: true), - if (project != null) - TechChip(project.name, - icon: Icons.folder_outlined, dense: true), - if (assignee != null && !isMine) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.person_outline, - size: 14, color: cs.onSurfaceVariant), - const SizedBox(width: 4), - Text( - assignee!.fullName, - style: - tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), - ), - ], - ), - Text( - AppTime.relative(task.createdAt), - style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), - ), - ], - ), - if (canStart || canResume) ...[ - const SizedBox(height: 12), - Align( - alignment: Alignment.centerRight, - child: FilledButton.tonalIcon( - onPressed: () => - startProgrammerTaskWithPausePrompt(context, ref, task), - icon: const Icon(Icons.play_arrow_rounded, size: 18), - label: Text(canResume ? 'Resume' : 'Start'), - ), - ), - ], - ], - ), - ), - ); - } -} diff --git a/lib/screens/programmer_tasks/widgets/programmer_task_card.dart b/lib/screens/programmer_tasks/widgets/programmer_task_card.dart new file mode 100644 index 00000000..702de57c --- /dev/null +++ b/lib/screens/programmer_tasks/widgets/programmer_task_card.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../models/profile.model.dart'; +import '../../../models/programmer_task.model.dart'; +import '../../../providers/programmer_day_data_provider.dart'; +import '../../../providers/programmer_projects_provider.dart'; +import '../../../theme/app_status_colors.dart'; +import '../../../utils/app_time.dart'; +import '../../../widgets/m3_card.dart'; +import '../../../widgets/status_pill.dart'; +import '../../../widgets/tech_chip.dart'; +import '../programmer_task_actions.dart'; +import '../programmer_task_status_kind.dart'; + +/// Shared task card that reflects the current run state (running / paused / +/// not started) sourced from [programmerRunStatesProvider]. +/// +/// Used by the task list screen (and any future surface) so the paused-task +/// pill and action buttons are consistent everywhere. +class ProgrammerTaskCard extends ConsumerWidget { + const ProgrammerTaskCard({ + super.key, + required this.task, + this.assignee, + required this.isMine, + this.readOnly = false, + }); + + final ProgrammerTask task; + final Profile? assignee; + final bool isMine; + final bool readOnly; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + + final runStates = ref.watch(programmerRunStatesProvider); + final runInfo = runStates[task.id]; + final isPaused = runInfo?.state == ProgrammerRunState.paused; + final isRunning = runInfo?.state == ProgrammerRunState.running; + + final canStart = + isMine && !readOnly && task.status == ProgrammerTaskStatus.queued; + final canResume = isMine && !readOnly && isPaused; + final canComplete = isMine && !readOnly && (isRunning || isPaused); + + final project = task.projectId == null + ? null + : ref.watch(programmerProjectByIdProvider(task.projectId!)); + + final pillKind = + isPaused ? StatusKind.warning : programmerTaskStatusKind(task.status); + final pillLabel = + isPaused ? 'Paused' : ProgrammerTaskStatus.label(task.status); + + return M3Card.outlined( + onTap: () => context.go('/programmer-tasks/${task.id}'), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + task.title, + style: + tt.titleMedium?.copyWith(fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + StatusPill.kind(pillKind, label: pillLabel), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + TechChip(task.category, + icon: Icons.category_outlined, dense: true), + if (project != null) + TechChip(project.name, + icon: Icons.folder_outlined, dense: true), + if (assignee != null && !isMine) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.person_outline, + size: 14, color: cs.onSurfaceVariant), + const SizedBox(width: 4), + Text( + assignee!.fullName, + style: tt.bodySmall + ?.copyWith(color: cs.onSurfaceVariant), + ), + ], + ), + Text( + AppTime.relative(task.createdAt), + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ], + ), + if (canStart || canResume) ...[ + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: FilledButton.tonalIcon( + onPressed: () => + startProgrammerTaskWithPausePrompt(context, ref, task), + icon: const Icon(Icons.play_arrow_rounded, size: 18), + label: Text(canResume ? 'Resume' : 'Start'), + ), + ), + ], + if (canComplete) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: OutlinedButton.icon( + onPressed: () => + completeProgrammerTaskWithConfirm(context, ref, task), + icon: const Icon(Icons.check_rounded, size: 18), + label: const Text('Complete'), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/test/programmer_task_card_test.dart b/test/programmer_task_card_test.dart new file mode 100644 index 00000000..d389c18f --- /dev/null +++ b/test/programmer_task_card_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tasq/models/programmer_task.model.dart'; +import 'package:tasq/providers/programmer_day_data_provider.dart'; +import 'package:tasq/screens/programmer_tasks/widgets/programmer_task_card.dart'; +import 'package:tasq/utils/app_time.dart'; + +ProgrammerTask _task() => ProgrammerTask( + id: 't1', + title: 'Test Task', + category: ProgrammerTaskCategory.bugFix, + status: ProgrammerTaskStatus.inProgress, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + +Widget _buildCard({required bool isMine, bool readOnly = false}) { + return ProviderScope( + overrides: [ + programmerRunStatesProvider.overrideWithValue({ + 't1': ( + state: ProgrammerRunState.paused, + since: DateTime(2026, 1, 1), + ), + }), + ], + child: MaterialApp( + home: Scaffold( + body: ProgrammerTaskCard( + task: _task(), + isMine: isMine, + readOnly: readOnly, + ), + ), + ), + ); +} + +void main() { + setUpAll(AppTime.initialize); + + testWidgets( + 'paused pill shows for any assignee; Resume only for mine and not read-only', + (tester) async { + // Case 1: isMine: false -> Paused pill shown, Resume not shown + await tester.pumpWidget(_buildCard(isMine: false)); + await tester.pump(); + expect(find.text('PAUSED'), findsOneWidget); + expect(find.text('Resume'), findsNothing); + + // Case 2: isMine: true -> Resume shown + await tester.pumpWidget(_buildCard(isMine: true)); + await tester.pump(); + expect(find.text('PAUSED'), findsOneWidget); + expect(find.text('Resume'), findsOneWidget); + + // Case 3: isMine: true, readOnly: true -> Resume not shown + await tester.pumpWidget(_buildCard(isMine: true, readOnly: true)); + await tester.pump(); + expect(find.text('PAUSED'), findsOneWidget); + expect(find.text('Resume'), findsNothing); + }); +}