feat(programmer): Mine dashboard hero + task sections

Replace the Mine tab flat list with ProgrammerDashboard — a NowWorkingCard
hero (running task with Pause/Complete, or idle with nextUp Start), a
TODO stub for Task 8 stat tiles/badges, and collapsible TaskSections
(In progress, Paused, Not started always-visible; Completed/Cancelled
start collapsed with Show-all at 10+). groupTasksForDashboard excludes
other assignees and sorts completed/cancelled by terminal date desc.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-09-27 21:52:13 +08:00
parent f8c3422715
commit 29bcbcaa4d
5 changed files with 679 additions and 12 deletions
@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/programmer_task.model.dart';
import '../../../providers/programmer_day_data_provider.dart';
import '../../../providers/programmer_tasks_provider.dart';
import '../../../utils/programmer_task_ledger.dart';
import '../../../widgets/m3_card.dart';
import '../../../widgets/tech_chip.dart';
import '../programmer_task_actions.dart';
/// Hero card at the top of the programmer dashboard showing the currently
/// running task (or an idle state when nothing is running).
class NowWorkingCard extends ConsumerWidget {
const NowWorkingCard({
super.key,
required this.running,
required this.nextUp,
required this.todayOnTask,
required this.totalOnTask,
required this.readOnly,
});
/// The currently running task, or null when nothing is running.
final ProgrammerTask? running;
/// First queued (not-started) task, shown as a start target when idle.
final ProgrammerTask? nextUp;
/// How long the user has worked on [running] today.
final Duration todayOnTask;
/// Total worked time on [running] across all days.
final Duration totalOnTask;
final bool readOnly;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Subscribe to the clock so durations re-render every 30 s.
ref.watch(clockTickerProvider);
return M3Card.elevated(
child: Padding(
padding: const EdgeInsets.all(16),
child: running != null
? _RunningView(
task: running!,
todayOnTask: todayOnTask,
totalOnTask: totalOnTask,
readOnly: readOnly,
)
: _IdleView(nextUp: nextUp, readOnly: readOnly),
),
);
}
}
class _RunningView extends ConsumerWidget {
const _RunningView({
required this.task,
required this.todayOnTask,
required this.totalOnTask,
required this.readOnly,
});
final ProgrammerTask task;
final Duration todayOnTask;
final Duration totalOnTask;
final bool readOnly;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.title,
style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
TechChip(task.category, icon: Icons.category_outlined, dense: true),
Text(
'Today ${formatDurationHm(todayOnTask)} · '
'Total ${formatDurationHm(totalOnTask)}',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
],
),
if (!readOnly) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton.icon(
onPressed: () => ref
.read(programmerTasksControllerProvider)
.pauseTask(taskId: task.id),
icon: const Icon(Icons.pause_rounded, size: 18),
label: const Text('Pause'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: () =>
completeProgrammerTaskWithConfirm(context, ref, task),
icon: const Icon(Icons.check_rounded, size: 18),
label: const Text('Complete'),
),
],
),
],
],
);
}
}
class _IdleView extends ConsumerWidget {
const _IdleView({required this.nextUp, required this.readOnly});
final ProgrammerTask? nextUp;
final bool readOnly;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return Row(
children: [
Expanded(
child: Text(
'Nothing running',
style: tt.bodyLarge?.copyWith(color: cs.onSurfaceVariant),
),
),
if (nextUp != null && !readOnly)
FilledButton.icon(
onPressed: () =>
startProgrammerTaskWithPausePrompt(context, ref, nextUp!),
icon: const Icon(Icons.play_arrow_rounded, size: 18),
label: const Text('Start'),
),
],
);
}
}
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/profile.model.dart';
import '../../../models/programmer_task.model.dart';
import '../../../providers/profile_provider.dart';
import '../../../providers/programmer_day_data_provider.dart';
import '../../../providers/programmer_tasks_provider.dart';
import '../../../utils/programmer_daily_time.dart';
import 'now_working_card.dart';
import 'task_sections.dart';
/// The programmer's own work dashboard — hero card, stat tiles (Task 8), and
/// task sections. Drop it into the Mine tab with `userId: me`.
class ProgrammerDashboard extends ConsumerWidget {
const ProgrammerDashboard({
super.key,
required this.userId,
this.readOnly = false,
});
final String userId;
final bool readOnly;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasksAsync = ref.watch(programmerTasksProvider);
final runStates = ref.watch(programmerRunStatesProvider);
final todayKey = ref.watch(todayKeyProvider);
final dayDataAsync = ref.watch(programmerDayDataProvider(todayKey));
final profilesAsync = ref.watch(profilesProvider);
final tasks = tasksAsync.valueOrNull ?? const [];
final profileById = <String, Profile>{
for (final p in profilesAsync.valueOrNull ?? <Profile>[]) p.id: p,
};
final groups = groupTasksForDashboard(tasks, runStates, userId);
final runningTask =
groups.running.isNotEmpty ? groups.running.first : null;
final nextUp =
groups.notStarted.isNotEmpty ? groups.notStarted.first : null;
final todayOnTask = _computeTodayDuration(
runningTask: runningTask,
dayDataAsync: dayDataAsync,
userId: userId,
todayKey: todayKey,
);
final totalOnTask = _computeTotalDuration(
runningTask: runningTask,
dayDataAsync: dayDataAsync,
userId: userId,
);
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
NowWorkingCard(
running: runningTask,
nextUp: nextUp,
todayOnTask: todayOnTask,
totalOnTask: totalOnTask,
readOnly: readOnly,
),
const SizedBox(height: 16),
// TODO: Task 8 inserts StatTiles + BadgesStrip here
TaskSections(
groups: groups,
profileById: profileById,
readOnly: readOnly,
),
],
);
}
Duration _computeTodayDuration({
required ProgrammerTask? runningTask,
required AsyncValue<dynamic> dayDataAsync,
required String userId,
required DateTime todayKey,
}) {
if (runningTask == null) return Duration.zero;
final dayData = dayDataAsync.valueOrNull;
if (dayData == null) return Duration.zero;
final logs = dayData.logsByTask[runningTask.id] ?? const [];
final dailyByAssignee = dailySecondsByAssignee(
task: runningTask,
logs: logs,
now: DateTime.now().toUtc(),
);
final secs = dailyByAssignee[userId]?[todayKey] ?? 0;
return Duration(seconds: secs);
}
Duration _computeTotalDuration({
required ProgrammerTask? runningTask,
required AsyncValue<dynamic> dayDataAsync,
required String userId,
}) {
if (runningTask == null) return Duration.zero;
final dayData = dayDataAsync.valueOrNull;
if (dayData == null) return Duration.zero;
final logs = dayData.logsByTask[runningTask.id] ?? const [];
final dailyByAssignee = dailySecondsByAssignee(
task: runningTask,
logs: logs,
now: DateTime.now().toUtc(),
);
final totalSecs =
(dailyByAssignee[userId] ?? const {}).values.fold<int>(0, (s, v) => s + v);
return Duration(seconds: totalSecs);
}
}
@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import '../../../models/profile.model.dart';
import '../../../models/programmer_task.model.dart';
import '../../../providers/programmer_day_data_provider.dart';
import '../../../widgets/app_section_header.dart';
import '../widgets/programmer_task_card.dart';
typedef TaskSectionGroups = ({
List<ProgrammerTask> running,
List<ProgrammerTask> paused,
List<ProgrammerTask> notStarted,
List<ProgrammerTask> completed,
List<ProgrammerTask> cancelled,
});
/// Groups [tasks] into dashboard buckets for [userId], using [runStates] to
/// distinguish running from paused from not-started. Only tasks assigned to
/// [userId] are included.
TaskSectionGroups groupTasksForDashboard(
List<ProgrammerTask> tasks,
Map<String, ProgrammerRunInfo> runStates,
String userId,
) {
final running = <ProgrammerTask>[];
final paused = <ProgrammerTask>[];
final notStarted = <ProgrammerTask>[];
final completed = <ProgrammerTask>[];
final cancelled = <ProgrammerTask>[];
for (final task in tasks) {
if (task.assigneeId != userId) continue;
final state = runStates[task.id]?.state;
if (state == ProgrammerRunState.running) {
running.add(task);
} else if (state == ProgrammerRunState.paused) {
paused.add(task);
} else if (state == ProgrammerRunState.notStarted) {
notStarted.add(task);
} else if (task.status == ProgrammerTaskStatus.completed) {
completed.add(task);
} else if (task.status == ProgrammerTaskStatus.cancelled) {
cancelled.add(task);
}
}
completed.sort(
(a, b) =>
(b.completedAt ?? DateTime(0)).compareTo(a.completedAt ?? DateTime(0)),
);
cancelled.sort(
(a, b) =>
(b.cancelledAt ?? DateTime(0)).compareTo(a.cancelledAt ?? DateTime(0)),
);
return (
running: running,
paused: paused,
notStarted: notStarted,
completed: completed,
cancelled: cancelled,
);
}
/// Renders the five task groups as collapsible / always-visible sections.
///
/// - In progress, Paused, Not started: always visible when non-empty.
/// - Completed, Cancelled: start collapsed; tap header to expand; shows first
/// 10 + "Show all (N)" when N > 10; tap "Show all" to see all.
/// - Empty sections produce no header.
class TaskSections extends StatefulWidget {
const TaskSections({
super.key,
required this.groups,
required this.profileById,
required this.readOnly,
});
final TaskSectionGroups groups;
final Map<String, Profile> profileById;
final bool readOnly;
@override
State<TaskSections> createState() => _TaskSectionsState();
}
class _TaskSectionsState extends State<TaskSections> {
bool _completedExpanded = false;
bool _completedShowAll = false;
bool _cancelledExpanded = false;
bool _cancelledShowAll = false;
@override
Widget build(BuildContext context) {
final g = widget.groups;
final isEmpty = g.running.isEmpty &&
g.paused.isEmpty &&
g.notStarted.isEmpty &&
g.completed.isEmpty &&
g.cancelled.isEmpty;
if (isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: Text('No tasks assigned to you')),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
..._alwaysSection('In progress', g.running),
..._alwaysSection('Paused', g.paused),
..._alwaysSection('Not started', g.notStarted),
..._collapsibleSection(
'Completed',
g.completed,
_completedExpanded,
_completedShowAll,
(v) => setState(() => _completedExpanded = v),
(v) => setState(() => _completedShowAll = v),
),
..._collapsibleSection(
'Cancelled',
g.cancelled,
_cancelledExpanded,
_cancelledShowAll,
(v) => setState(() => _cancelledExpanded = v),
(v) => setState(() => _cancelledShowAll = v),
),
],
);
}
List<Widget> _alwaysSection(String name, List<ProgrammerTask> tasks) {
if (tasks.isEmpty) return const [];
return [
AppSectionHeader(name, trailing: Text('${tasks.length}')),
...tasks.map(_taskCard),
const SizedBox(height: 8),
];
}
List<Widget> _collapsibleSection(
String name,
List<ProgrammerTask> tasks,
bool expanded,
bool showAll,
void Function(bool) onToggle,
void Function(bool) onShowAll,
) {
if (tasks.isEmpty) return const [];
List<ProgrammerTask> visible;
if (!expanded) {
visible = const [];
} else if (showAll || tasks.length <= 10) {
visible = tasks;
} else {
visible = tasks.take(10).toList();
}
return [
GestureDetector(
onTap: () => onToggle(!expanded),
behavior: HitTestBehavior.opaque,
child: AppSectionHeader(name, trailing: Text('${tasks.length}')),
),
if (expanded) ...[
...visible.map(_taskCard),
if (tasks.length > 10 && !showAll)
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => onShowAll(true),
child: Text('Show all (${tasks.length})'),
),
),
const SizedBox(height: 8),
],
];
}
Widget _taskCard(ProgrammerTask t) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ProgrammerTaskCard(
task: t,
assignee: t.assigneeId != null
? widget.profileById[t.assigneeId!]
: null,
isMine: true,
readOnly: widget.readOnly,
),
);
}
@@ -9,6 +9,7 @@ import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/reconnect_overlay.dart';
import '../../widgets/responsive_body.dart';
import 'dashboard/programmer_dashboard.dart';
import 'programmer_task_create.dart';
import 'widgets/programmer_task_card.dart';
@@ -69,6 +70,17 @@ class _ProgrammerTasksListScreenState
Expanded(
child: Builder(
builder: (context) {
final showMine = _tabController.index == 0;
// Mine tab — delegate entirely to ProgrammerDashboard.
if (showMine) {
if (userId == null) {
return const Center(child: CircularProgressIndicator());
}
return ProgrammerDashboard(userId: userId);
}
// All tab — flat list with error / loading / empty states.
if (tasksAsync.hasError && !tasksAsync.hasValue) {
return AppErrorView(
error: tasksAsync.error!,
@@ -80,27 +92,22 @@ class _ProgrammerTasksListScreenState
return const Center(child: CircularProgressIndicator());
}
final all = tasksAsync.valueOrNull ?? const [];
final showMine = _tabController.index == 0;
final tasks = showMine
? all.where((t) => t.assigneeId == userId).toList()
: all;
if (tasks.isEmpty) {
return AppEmptyView(
if (all.isEmpty) {
return const AppEmptyView(
icon: Icons.developer_mode_outlined,
title: showMine ? 'No tasks assigned to you' : 'No tasks yet',
subtitle: showMine
? 'Tasks you log or that are assigned to you appear here.'
: 'Create the first programmer task with the + button.',
title: 'No tasks yet',
subtitle:
'Create the first programmer task with the + button.',
);
}
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: tasks.length,
itemCount: all.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, i) {
final task = tasks[i];
final task = all[i];
return ProgrammerTaskCard(
task: task,
assignee: task.assigneeId == null
@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/models/profile.model.dart';
import 'package:tasq/models/programmer_task.model.dart';
import 'package:tasq/providers/programmer_day_data_provider.dart';
import 'package:tasq/screens/programmer_tasks/dashboard/task_sections.dart';
import 'package:tasq/utils/app_time.dart';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
ProgrammerTask _makeTask({
required String id,
required String status,
String assigneeId = 'user1',
DateTime? completedAt,
DateTime? cancelledAt,
}) {
final now = DateTime(2026, 1, 1);
return ProgrammerTask(
id: id,
title: 'Task $id',
category: ProgrammerTaskCategory.bugFix,
status: status,
assigneeId: assigneeId,
createdAt: now,
updatedAt: now,
completedAt: completedAt,
cancelledAt: cancelledAt,
);
}
Widget _buildSections(TaskSectionGroups groups, {bool readOnly = false}) {
return ProviderScope(
overrides: [
programmerRunStatesProvider.overrideWithValue({}),
],
child: MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: TaskSections(
groups: groups,
profileById: const {},
readOnly: readOnly,
),
),
),
),
);
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
void main() {
setUpAll(AppTime.initialize);
test('groupTasksForDashboard splits by run state and excludes other assignees', () {
final tasks = [
_makeTask(id: 't1', status: ProgrammerTaskStatus.inProgress, assigneeId: 'user1'),
_makeTask(id: 't2', status: ProgrammerTaskStatus.inProgress, assigneeId: 'user1'),
_makeTask(id: 't3', status: ProgrammerTaskStatus.queued, assigneeId: 'user1'),
_makeTask(id: 't4', status: ProgrammerTaskStatus.queued, assigneeId: 'other'),
];
final runStates = <String, ProgrammerRunInfo>{
't1': (state: ProgrammerRunState.running, since: null),
't2': (state: ProgrammerRunState.paused, since: null),
't3': (state: ProgrammerRunState.notStarted, since: null),
't4': (state: ProgrammerRunState.notStarted, since: null),
};
final groups = groupTasksForDashboard(tasks, runStates, 'user1');
expect(groups.running.map((t) => t.id), ['t1']);
expect(groups.paused.map((t) => t.id), ['t2']);
expect(groups.notStarted.map((t) => t.id), ['t3']);
// t4 belongs to 'other' — must not appear
expect(groups.running.any((t) => t.id == 't4'), isFalse);
expect(groups.paused.any((t) => t.id == 't4'), isFalse);
expect(groups.notStarted.any((t) => t.id == 't4'), isFalse);
});
test('completed sorted by completedAt desc, cancelled by cancelledAt desc', () {
final base = DateTime(2026, 1, 1);
final tasks = [
_makeTask(
id: 'c1',
status: ProgrammerTaskStatus.completed,
completedAt: base.add(const Duration(hours: 1)),
),
_makeTask(
id: 'c2',
status: ProgrammerTaskStatus.completed,
completedAt: base.add(const Duration(hours: 3)),
),
_makeTask(
id: 'x1',
status: ProgrammerTaskStatus.cancelled,
cancelledAt: base.add(const Duration(hours: 2)),
),
_makeTask(
id: 'x2',
status: ProgrammerTaskStatus.cancelled,
cancelledAt: base.add(const Duration(hours: 5)),
),
];
final runStates = <String, ProgrammerRunInfo>{
'c1': (state: ProgrammerRunState.completed, since: null),
'c2': (state: ProgrammerRunState.completed, since: null),
'x1': (state: ProgrammerRunState.cancelled, since: null),
'x2': (state: ProgrammerRunState.cancelled, since: null),
};
final groups = groupTasksForDashboard(tasks, runStates, 'user1');
expect(groups.completed.map((t) => t.id).toList(), ['c2', 'c1']);
expect(groups.cancelled.map((t) => t.id).toList(), ['x2', 'x1']);
});
// ---------------------------------------------------------------------------
// Widget tests
// ---------------------------------------------------------------------------
testWidgets('Completed and Cancelled start collapsed', (tester) async {
final base = DateTime(2026, 1, 1);
final completed = List.generate(
12,
(i) => _makeTask(
id: 'c$i',
status: ProgrammerTaskStatus.completed,
completedAt: base.add(Duration(hours: i)),
),
);
final runStates = <String, ProgrammerRunInfo>{
for (final t in completed)
t.id: (state: ProgrammerRunState.completed, since: null),
};
final groups = groupTasksForDashboard(completed, runStates, 'user1');
await tester.pumpWidget(_buildSections(groups));
await tester.pump();
// Header is visible
expect(find.textContaining('COMPLETED'), findsOneWidget);
// First task title is NOT visible (collapsed)
expect(find.text('Task c0'), findsNothing);
// Tap the header to expand
await tester.tap(find.textContaining('COMPLETED'));
await tester.pump();
// 10 titles visible (first 10 of 12)
int visibleCount = 0;
for (int i = 0; i < 12; i++) {
if (tester.any(find.text('Task c${11 - i}'))) visibleCount++;
}
expect(visibleCount, 10);
// "Show all (12)" link is visible
expect(find.text('Show all (12)'), findsOneWidget);
});
testWidgets('empty sections are hidden', (tester) async {
final tasks = [
_makeTask(id: 'r1', status: ProgrammerTaskStatus.inProgress),
];
final runStates = <String, ProgrammerRunInfo>{
'r1': (state: ProgrammerRunState.running, since: null),
};
final groups = groupTasksForDashboard(tasks, runStates, 'user1');
await tester.pumpWidget(_buildSections(groups));
await tester.pump();
// 'In progress' header present (at least one widget contains the text)
expect(find.textContaining('IN PROGRESS'), findsAtLeastNWidgets(1));
// 'Cancelled' header not rendered (no cancelled tasks)
expect(find.textContaining('CANCELLED'), findsNothing);
// 'Paused' header not rendered
expect(find.textContaining('PAUSED'), findsNothing);
});
}