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 <ruv@ruv.net>
This commit is contained in:
@@ -4,6 +4,40 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../models/programmer_task.model.dart';
|
import '../../models/programmer_task.model.dart';
|
||||||
import '../../providers/programmer_tasks_provider.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<void> completeProgrammerTaskWithConfirm(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
ProgrammerTask task,
|
||||||
|
) async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
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
|
/// 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
|
/// 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
|
/// to pause it first. This is the headline "proper time management" behavior and
|
||||||
|
|||||||
@@ -325,7 +325,8 @@ class _ActionBar extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
if (isRunning || isPaused)
|
if (isRunning || isPaused)
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () => _complete(context, ref, task),
|
onPressed: () =>
|
||||||
|
completeProgrammerTaskWithConfirm(context, ref, task),
|
||||||
icon: const Icon(Icons.check_rounded),
|
icon: const Icon(Icons.check_rounded),
|
||||||
label: const Text('Complete'),
|
label: const Text('Complete'),
|
||||||
),
|
),
|
||||||
@@ -340,35 +341,6 @@ class _ActionBar extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> _complete(
|
|
||||||
BuildContext context,
|
|
||||||
WidgetRef ref,
|
|
||||||
ProgrammerTask task,
|
|
||||||
) async {
|
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
|
||||||
final ok = await showDialog<bool>(
|
|
||||||
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<void> _cancel(
|
Future<void> _cancel(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import '../../models/profile.model.dart';
|
import '../../models/profile.model.dart';
|
||||||
import '../../models/programmer_task.model.dart';
|
|
||||||
import '../../providers/profile_provider.dart';
|
import '../../providers/profile_provider.dart';
|
||||||
import '../../providers/programmer_projects_provider.dart';
|
|
||||||
import '../../providers/programmer_tasks_provider.dart';
|
import '../../providers/programmer_tasks_provider.dart';
|
||||||
import '../../theme/app_status_colors.dart';
|
|
||||||
import '../../theme/m3_motion.dart';
|
import '../../theme/m3_motion.dart';
|
||||||
import '../../utils/app_time.dart';
|
|
||||||
import '../../widgets/app_page_header.dart';
|
import '../../widgets/app_page_header.dart';
|
||||||
import '../../widgets/app_state_view.dart';
|
import '../../widgets/app_state_view.dart';
|
||||||
import '../../widgets/m3_card.dart';
|
|
||||||
import '../../widgets/reconnect_overlay.dart';
|
import '../../widgets/reconnect_overlay.dart';
|
||||||
import '../../widgets/responsive_body.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_create.dart';
|
||||||
import 'programmer_task_status_kind.dart';
|
import 'widgets/programmer_task_card.dart';
|
||||||
|
|
||||||
class ProgrammerTasksListScreen extends ConsumerStatefulWidget {
|
class ProgrammerTasksListScreen extends ConsumerStatefulWidget {
|
||||||
const ProgrammerTasksListScreen({super.key});
|
const ProgrammerTasksListScreen({super.key});
|
||||||
@@ -110,7 +101,7 @@ class _ProgrammerTasksListScreenState
|
|||||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final task = tasks[i];
|
final task = tasks[i];
|
||||||
return _TaskCard(
|
return ProgrammerTaskCard(
|
||||||
task: task,
|
task: task,
|
||||||
assignee: task.assigneeId == null
|
assignee: task.assigneeId == null
|
||||||
? 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'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user