diff --git a/lib/screens/programmer_tasks/dashboard/now_working_card.dart b/lib/screens/programmer_tasks/dashboard/now_working_card.dart new file mode 100644 index 00000000..14e55ec4 --- /dev/null +++ b/lib/screens/programmer_tasks/dashboard/now_working_card.dart @@ -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'), + ), + ], + ); + } +} diff --git a/lib/screens/programmer_tasks/dashboard/programmer_dashboard.dart b/lib/screens/programmer_tasks/dashboard/programmer_dashboard.dart new file mode 100644 index 00000000..3f8c408b --- /dev/null +++ b/lib/screens/programmer_tasks/dashboard/programmer_dashboard.dart @@ -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 = { + for (final p in profilesAsync.valueOrNull ?? []) 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 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 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(0, (s, v) => s + v); + return Duration(seconds: totalSecs); + } +} diff --git a/lib/screens/programmer_tasks/dashboard/task_sections.dart b/lib/screens/programmer_tasks/dashboard/task_sections.dart new file mode 100644 index 00000000..9a056596 --- /dev/null +++ b/lib/screens/programmer_tasks/dashboard/task_sections.dart @@ -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 running, + List paused, + List notStarted, + List completed, + List 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 tasks, + Map runStates, + String userId, +) { + final running = []; + final paused = []; + final notStarted = []; + final completed = []; + final cancelled = []; + + 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 profileById; + final bool readOnly; + + @override + State createState() => _TaskSectionsState(); +} + +class _TaskSectionsState extends State { + 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 _alwaysSection(String name, List tasks) { + if (tasks.isEmpty) return const []; + return [ + AppSectionHeader(name, trailing: Text('${tasks.length}')), + ...tasks.map(_taskCard), + const SizedBox(height: 8), + ]; + } + + List _collapsibleSection( + String name, + List tasks, + bool expanded, + bool showAll, + void Function(bool) onToggle, + void Function(bool) onShowAll, + ) { + if (tasks.isEmpty) return const []; + + List 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, + ), + ); +} diff --git a/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart b/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart index 98a717be..e8dffe77 100644 --- a/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart +++ b/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart @@ -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 diff --git a/test/programmer_dashboard_sections_test.dart b/test/programmer_dashboard_sections_test.dart new file mode 100644 index 00000000..acc74b44 --- /dev/null +++ b/test/programmer_dashboard_sections_test.dart @@ -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 = { + '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 = { + '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 = { + 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 = { + '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); + }); +}