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:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user