feat(programmer): day sheet detail screen

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-09-28 14:21:22 +08:00
parent 91fb023ef2
commit 1fe9653bdf
6 changed files with 1291 additions and 0 deletions
+11
View File
@@ -30,6 +30,7 @@ import '../widgets/app_shell.dart';
import '../screens/teams/teams_screen.dart';
import '../screens/it_service_requests/it_service_requests_list_screen.dart';
import '../screens/it_service_requests/it_service_request_detail_screen.dart';
import '../screens/programmer_tasks/day_sheets/day_sheet_detail_screen.dart';
import '../screens/programmer_tasks/programmer_drill_down_screen.dart';
import '../screens/programmer_tasks/programmer_tasks_list_screen.dart';
import '../screens/programmer_tasks/programmer_task_detail_screen.dart';
@@ -237,6 +238,16 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
),
),
GoRoute(
name: 'daySheetDetail',
path: 'day-sheets/:id',
pageBuilder: (context, state) => M3ContainerTransformPage(
key: state.pageKey,
child: DaySheetDetailScreen(
sheetId: state.pathParameters['id'] ?? '',
),
),
),
GoRoute(
path: ':id',
pageBuilder: (context, state) => M3ContainerTransformPage(
@@ -0,0 +1,498 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/programmer_day_sheet.model.dart';
import '../../../models/programmer_day_sheet_event.model.dart';
import '../../../models/programmer_task.model.dart';
import '../../../providers/connectivity_provider.dart';
import '../../../providers/programmer_day_data_provider.dart';
import '../../../providers/programmer_day_sheets_provider.dart';
import '../../../providers/programmer_tasks_provider.dart';
import '../../../providers/profile_provider.dart';
import '../../../theme/app_status_colors.dart';
import '../../../utils/app_time.dart';
import '../../../utils/programmer_day_rows.dart';
import '../../../utils/programmer_task_ledger.dart';
import '../../../widgets/app_section_header.dart';
import '../../../widgets/app_state_view.dart';
import '../../../widgets/offline_banner.dart';
import '../../../widgets/status_pill.dart';
import '../widgets/work_log_section.dart';
import 'day_sheet_dialogs.dart';
import 'day_sheet_rows_table.dart';
import 'day_sheet_thread.dart';
/// The full detail view for a single programmer day sheet.
class DaySheetDetailScreen extends ConsumerStatefulWidget {
const DaySheetDetailScreen({super.key, required this.sheetId});
final String sheetId;
@override
ConsumerState<DaySheetDetailScreen> createState() =>
_DaySheetDetailScreenState();
}
class _DaySheetDetailScreenState extends ConsumerState<DaySheetDetailScreen> {
Set<String> _selectedTaskIds = {};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
StatusKind _kindForStatus(String s) => switch (s) {
DaySheetStatus.approved => StatusKind.success,
DaySheetStatus.pending => StatusKind.info,
DaySheetStatus.disapproved => StatusKind.warning,
_ => StatusKind.neutral,
};
Set<String> _flaggedTaskIdsFromEvents(List<ProgrammerDaySheetEvent> events) {
final disapproved = events
.where((e) => e.kind == 'disapproved')
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
if (disapproved.isEmpty) return const {};
return Set<String>.from(disapproved.first.flaggedTaskIds);
}
void _showError(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
// ---------------------------------------------------------------------------
// Actions — Owner
// ---------------------------------------------------------------------------
Future<void> _onAddCorrection(
BuildContext ctx,
ProgrammerTask task,
String currentUserId,
DateTime workDate,
) async {
await showModalBottomSheet<void>(
context: ctx,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
left: 16,
right: 16,
top: 16,
),
child: WorkLogSection(
task: task,
isAssignee: task.assigneeId == currentUserId,
workDate: workDate,
),
),
);
}
Future<void> _onJustify(String sheetId) async {
final body = await showJustifyDialog(context);
if (body == null || !mounted) return;
try {
await ref
.read(daySheetsControllerProvider)
.justify(sheetId: sheetId, body: body);
} on DaySheetActionException catch (e) {
_showError(e.message);
} catch (e) {
_showError(e.toString());
}
}
// ---------------------------------------------------------------------------
// Actions — Admin
// ---------------------------------------------------------------------------
Future<void> _onDisapprove(
String sheetId,
List<DaySheetRow> rows,
) async {
final flaggedRows =
rows.where((r) => _selectedTaskIds.contains(r.taskId)).toList();
final remarks =
await showDisapproveDialog(context, flaggedRows: flaggedRows);
if (remarks == null || !mounted) return;
try {
await ref.read(daySheetsControllerProvider).disapprove(
sheetId: sheetId,
remarks: remarks,
flaggedTaskIds: flaggedRows.map((r) => r.taskId).toList(),
);
setState(() => _selectedTaskIds = {});
} on DaySheetActionException catch (e) {
_showError(e.message);
} catch (e) {
_showError(e.toString());
}
}
Future<void> _onApprove(
ProgrammerDaySheet sheet,
List<DaySheetRow> rows,
String programmerName,
) async {
final today = ref.read(todayKeyProvider);
final isToday = sheet.workDate == today;
if (isToday) {
final runStates = ref.read(programmerRunStatesProvider);
final hasRunning = runStates.entries.any((e) =>
e.value.state == ProgrammerRunState.running);
if (hasRunning) {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Timer still running'),
content: Text(
'$programmerName has a timer running. '
'Work logged after approval will reopen this sheet. Approve anyway?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Approve'),
),
],
),
);
if (confirm != true || !mounted) return;
}
}
final snapshot = DaySheetSnapshot.fromRows(rows);
try {
await ref.read(daySheetsControllerProvider).approve(
[(sheetId: sheet.id, snapshot: snapshot)],
);
setState(() => _selectedTaskIds = {});
} on DaySheetActionException catch (e) {
_showError(e.message);
} catch (e) {
_showError(e.toString());
}
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final sheetAsync = ref.watch(daySheetByIdProvider(widget.sheetId));
final eventsAsync = ref.watch(daySheetEventsProvider(widget.sheetId));
final profilesAsync = ref.watch(profilesProvider);
final currentUserId = ref.watch(currentUserIdProvider);
final isAdmin = ref.watch(isStrictAdminProvider);
final isOnline = ref.watch(isOnlineProvider);
return Scaffold(
appBar: AppBar(title: const Text('Day Sheet')),
body: OfflineBanner(
child: sheetAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) =>
AppErrorView(error: e, onRetry: () => ref.invalidate(daySheetByIdProvider(widget.sheetId))),
data: (sheet) {
if (sheet == null) {
return const AppEmptyView(
icon: Icons.receipt_long_outlined,
title: 'Sheet not found',
subtitle: 'This day sheet may have been removed.',
);
}
final events = eventsAsync.valueOrNull ?? const [];
final profiles = profilesAsync.valueOrNull ?? const [];
final profileById = {for (final p in profiles) p.id: p};
final profileNames = {for (final p in profiles) p.id: p.fullName};
final programmerName =
profileById[sheet.programmerId]?.fullName ?? 'Programmer';
final isOwner = currentUserId == sheet.programmerId;
final flaggedIds = _flaggedTaskIdsFromEvents(events);
// Build live rows from tasks provider.
final allTasks =
ref.watch(programmerTasksProvider).valueOrNull ?? const [];
final sheetTasks = allTasks
.where((t) => t.assigneeId == sheet.programmerId)
.toList();
// For simplicity, build live rows from any tasks that appear in events
// or we just use empty — the sheet already has snapshot rows if approved.
// Live rows come from an RPC-built dataset; use snapshot rows if approved.
final liveRows = _buildLiveRowsFrom(sheet, events, allTasks);
final rows = rowsForSheet(sheet, liveRows);
final totalSecs = rows.fold<int>(0, (s, r) => s + r.seconds);
final totalDuration = Duration(seconds: totalSecs);
final canAdminAct = isAdmin &&
(sheet.status == DaySheetStatus.pending ||
sheet.status == DaySheetStatus.disapproved);
final canOwnerJustify = isOwner &&
sheet.status == DaySheetStatus.disapproved;
final canOwnerCorrect = isOwner &&
(sheet.status == DaySheetStatus.disapproved ||
(sheet.status == DaySheetStatus.pending &&
sheet.workDate.isBefore(ref.read(todayKeyProvider))));
return ListView(
padding: const EdgeInsets.all(16),
children: [
// ── Offline indicator chip ──────────────────────────────────
if (!isOnline)
Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.wifi_off_rounded,
size: 16,
color: Theme.of(context)
.colorScheme
.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
'You are offline. Actions are unavailable.',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onErrorContainer,
),
),
),
],
),
),
// ── Header ────────────────────────────────────────────────
_Header(
sheet: sheet,
programmerName: programmerName,
totalDuration: totalDuration,
statusKind: _kindForStatus(sheet.status),
),
const SizedBox(height: 20),
// ── Rows table ────────────────────────────────────────────
AppSectionHeader(
'Work Entries',
trailing: canAdminAct && rows.isNotEmpty
? TextButton(
onPressed: () {
if (_selectedTaskIds.length == rows.length) {
setState(() => _selectedTaskIds = {});
} else {
setState(() => _selectedTaskIds = {
for (final r in rows) r.taskId,
});
}
},
child: Text(
_selectedTaskIds.length == rows.length
? 'Deselect all'
: 'Select all',
),
)
: null,
),
DaySheetRowsTable(
rows: rows,
flaggedTaskIds: sheet.status == DaySheetStatus.approved
? const {}
: flaggedIds,
selectedTaskIds: canAdminAct ? _selectedTaskIds : null,
onSelectionChanged: canAdminAct
? (ids) => setState(() => _selectedTaskIds = ids)
: null,
onAddCorrection: canOwnerCorrect
? (row) {
final task = sheetTasks
.where((t) => t.id == row.taskId)
.firstOrNull;
if (task != null && currentUserId != null) {
_onAddCorrection(
context, task, currentUserId, sheet.workDate);
}
}
: null,
),
const SizedBox(height: 20),
// ── Admin action bar ─────────────────────────────────────
if (canAdminAct) ...[
Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.thumb_down_alt_outlined),
label: const Text('Disapprove'),
onPressed: isOnline
? () => _onDisapprove(sheet.id, rows)
: null,
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.check_circle_outline),
label: const Text('Approve'),
onPressed: isOnline
? () => _onApprove(
sheet,
rows,
programmerName,
)
: null,
),
),
],
),
const SizedBox(height: 20),
],
// ── Owner justify bar ─────────────────────────────────────
if (canOwnerJustify) ...[
SizedBox(
width: double.infinity,
child: FilledButton.icon(
icon: const Icon(Icons.chat_bubble_outline),
label: const Text('Justify & Resubmit'),
onPressed: isOnline
? () => _onJustify(sheet.id)
: null,
),
),
const SizedBox(height: 20),
],
// ── Event thread ──────────────────────────────────────────
DaySheetThread(
events: events,
profileNames: profileNames,
),
],
);
},
),
),
);
}
}
// ---------------------------------------------------------------------------
// Header widget
// ---------------------------------------------------------------------------
class _Header extends StatelessWidget {
const _Header({
required this.sheet,
required this.programmerName,
required this.totalDuration,
required this.statusKind,
});
final ProgrammerDaySheet sheet;
final String programmerName;
final Duration totalDuration;
final StatusKind statusKind;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
AppTime.formatDate(sheet.workDate),
style: tt.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
),
StatusPill.kind(
statusKind,
label: DaySheetStatus.label(sheet.status),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.person_outline, size: 16, color: cs.onSurfaceVariant),
const SizedBox(width: 4),
Expanded(
child: Text(
programmerName,
style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
),
),
Text(
formatDurationHm(totalDuration),
style: tt.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
color: cs.primary,
),
),
],
),
if (sheet.resubmissions > 0) ...[
const SizedBox(height: 4),
Text(
'Round ${sheet.resubmissions + 1}',
style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant),
),
],
],
);
}
}
// ---------------------------------------------------------------------------
// Live rows helper
// ---------------------------------------------------------------------------
/// Attempt to surface live rows from the existing tasks list.
/// For a fully approved sheet the snapshot rows will be used anyway,
/// so this is only a best-effort fallback.
List<DaySheetRow> _buildLiveRowsFrom(
ProgrammerDaySheet sheet,
List<ProgrammerDaySheetEvent> events,
List<ProgrammerTask> allTasks,
) {
// If the sheet has an approved snapshot, the caller will use snapshot rows.
if (sheet.approvedSnapshot != null &&
sheet.status == DaySheetStatus.approved) {
return sheet.approvedSnapshot!.rows;
}
// Otherwise we don't have the full row data client-side without a separate
// fetch — return empty and let the screen show whatever the snapshot provides.
return const [];
}
@@ -0,0 +1,219 @@
import 'package:flutter/material.dart';
import '../../../models/programmer_day_sheet.model.dart';
import '../../../utils/programmer_day_rows.dart';
/// Pure helper: if the sheet is approved with a snapshot, returns the snapshot
/// rows; otherwise returns the live rows.
List<DaySheetRow> rowsForSheet(
ProgrammerDaySheet sheet,
List<DaySheetRow> liveRows,
) {
if (sheet.status == DaySheetStatus.approved &&
sheet.approvedSnapshot != null) {
return sheet.approvedSnapshot!.rows;
}
return liveRows;
}
// ---------------------------------------------------------------------------
// showDisapproveDialog
// ---------------------------------------------------------------------------
/// Shows an AlertDialog collecting disapproval remarks and optionally
/// toggling which rows are flagged.
///
/// Returns the non-empty remarks string on confirm, or `null` on cancel.
Future<String?> showDisapproveDialog(
BuildContext context, {
required List<DaySheetRow> flaggedRows,
}) {
return showDialog<String>(
context: context,
barrierDismissible: false,
builder: (ctx) => _DisapproveDialog(flaggedRows: flaggedRows),
);
}
class _DisapproveDialog extends StatefulWidget {
const _DisapproveDialog({required this.flaggedRows});
final List<DaySheetRow> flaggedRows;
@override
State<_DisapproveDialog> createState() => _DisapproveDialogState();
}
class _DisapproveDialogState extends State<_DisapproveDialog> {
final _remarksCtrl = TextEditingController();
String? _error;
late Set<String> _selected;
@override
void initState() {
super.initState();
_selected = {for (final r in widget.flaggedRows) r.taskId};
}
@override
void dispose() {
_remarksCtrl.dispose();
super.dispose();
}
void _submit() {
final remarks = _remarksCtrl.text.trim();
if (remarks.isEmpty) {
setState(() => _error = 'Remarks are required');
return;
}
Navigator.of(context).pop(remarks);
}
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return AlertDialog(
title: const Text('Disapprove'),
content: SizedBox(
width: 360,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _remarksCtrl,
maxLines: 3,
decoration: InputDecoration(
labelText: 'Remarks',
hintText: 'Explain what needs to change',
errorText: _error,
border: const OutlineInputBorder(),
),
onChanged: (_) {
if (_error != null) setState(() => _error = null);
},
),
if (widget.flaggedRows.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
'Flagged tasks',
style: tt.labelMedium?.copyWith(
color: cs.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
for (final row in widget.flaggedRows)
CheckboxListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(row.title, style: tt.bodySmall),
value: _selected.contains(row.taskId),
onChanged: (v) {
setState(() {
if (v == true) {
_selected.add(row.taskId);
} else {
_selected.remove(row.taskId);
}
});
},
),
],
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _submit,
child: const Text('Disapprove'),
),
],
);
}
}
// ---------------------------------------------------------------------------
// showJustifyDialog
// ---------------------------------------------------------------------------
/// Shows an AlertDialog for the programmer to write a justification before
/// resubmitting.
///
/// Returns the non-empty justification string on confirm, or `null` on cancel.
Future<String?> showJustifyDialog(BuildContext context) {
return showDialog<String>(
context: context,
barrierDismissible: false,
builder: (ctx) => const _JustifyDialog(),
);
}
class _JustifyDialog extends StatefulWidget {
const _JustifyDialog();
@override
State<_JustifyDialog> createState() => _JustifyDialogState();
}
class _JustifyDialogState extends State<_JustifyDialog> {
final _bodyCtrl = TextEditingController();
String? _error;
@override
void dispose() {
_bodyCtrl.dispose();
super.dispose();
}
void _submit() {
final body = _bodyCtrl.text.trim();
if (body.isEmpty) {
setState(() => _error = 'Please write a justification');
return;
}
Navigator.of(context).pop(body);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Justify & Resubmit'),
content: SizedBox(
width: 360,
child: TextField(
controller: _bodyCtrl,
maxLines: 4,
decoration: InputDecoration(
labelText: 'Justification',
hintText: 'Explain what changed or why the sheet is correct',
errorText: _error,
border: const OutlineInputBorder(),
),
onChanged: (_) {
if (_error != null) setState(() => _error = null);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _submit,
child: const Text('Submit'),
),
],
);
}
}
@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import '../../../theme/app_status_colors.dart';
import '../../../utils/programmer_day_rows.dart';
import '../../../utils/programmer_task_ledger.dart';
import '../../../widgets/tech_chip.dart';
/// Table of day-sheet rows with optional selection checkboxes and a per-row
/// "add correction" action icon.
class DaySheetRowsTable extends StatelessWidget {
const DaySheetRowsTable({
super.key,
required this.rows,
required this.flaggedTaskIds,
this.selectedTaskIds,
this.onSelectionChanged,
this.onAddCorrection,
});
/// The rows to render (assignee + helper tasks for this sheet).
final List<DaySheetRow> rows;
/// Task IDs flagged in the most recent disapproved event. These are
/// highlighted while the sheet is not yet approved.
final Set<String> flaggedTaskIds;
/// When non-null, admin selection checkboxes are shown and changes are
/// forwarded to this callback.
final Set<String>? selectedTaskIds;
final ValueChanged<Set<String>>? onSelectionChanged;
/// When non-null, a correction icon is shown on each row and tapping it
/// invokes this callback with the affected row.
final ValueChanged<DaySheetRow>? onAddCorrection;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final status = AppStatusColors.of(context);
if (rows.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'No rows yet.',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
),
);
}
return Column(
children: [
for (final row in rows)
_RowTile(
key: ValueKey(row.taskId),
row: row,
isFlagged: flaggedTaskIds.contains(row.taskId),
isSelected: selectedTaskIds?.contains(row.taskId) ?? false,
showCheckbox: selectedTaskIds != null,
showCorrection: onAddCorrection != null,
statusColors: status,
onToggle: selectedTaskIds != null && onSelectionChanged != null
? (checked) {
final next = Set<String>.from(selectedTaskIds!);
if (checked) {
next.add(row.taskId);
} else {
next.remove(row.taskId);
}
onSelectionChanged!(next);
}
: null,
onAddCorrection:
onAddCorrection != null ? () => onAddCorrection!(row) : null,
),
],
);
}
}
class _RowTile extends StatelessWidget {
const _RowTile({
super.key,
required this.row,
required this.isFlagged,
required this.isSelected,
required this.showCheckbox,
required this.showCorrection,
required this.statusColors,
this.onToggle,
this.onAddCorrection,
});
final DaySheetRow row;
final bool isFlagged;
final bool isSelected;
final bool showCheckbox;
final bool showCorrection;
final AppStatusColors statusColors;
final ValueChanged<bool>? onToggle;
final VoidCallback? onAddCorrection;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final flaggedColor = statusColors.warning.container;
final bgColor = isFlagged ? flaggedColor : Colors.transparent;
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
color: bgColor,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showCheckbox)
Checkbox(
value: isSelected,
onChanged: onToggle != null ? (v) => onToggle!(v ?? false) : null,
visualDensity: VisualDensity.compact,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
row.title,
style: tt.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
Text(
formatDurationHm(Duration(seconds: row.seconds)),
style: tt.bodySmall?.copyWith(
color: cs.primary,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
TechChip(row.category, dense: true),
if (row.kind == 'helper')
TechChip(
'Helper${row.helpedName != null ? ' → ${row.helpedName}' : ''}',
dense: true,
status: StatusKind.info,
),
if (row.projectName != null)
TechChip(row.projectName!, dense: true),
if (isFlagged)
TechChip(
'Flagged',
dense: true,
status: StatusKind.warning,
icon: Icons.flag_outlined,
),
],
),
if (row.notes.isNotEmpty) ...[
const SizedBox(height: 4),
for (final note in row.notes)
Text(
'• $note',
style: tt.bodySmall
?.copyWith(color: cs.onSurfaceVariant),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
if (showCorrection)
IconButton(
icon: const Icon(Icons.add_comment_outlined, size: 18),
tooltip: 'Add correction',
onPressed: onAddCorrection,
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
],
),
),
);
}
}
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import '../../../models/programmer_day_sheet_event.model.dart';
import '../../../theme/app_status_colors.dart';
import '../../../utils/app_time.dart';
import '../../../widgets/app_section_header.dart';
/// Maps an event kind to a human-readable label.
String _threadLabel(String kind) => switch (kind) {
'submitted' => 'Submitted',
'auto_submitted' => 'Auto-submitted at end of day',
'disapproved' => 'Disapproved',
'justified' => 'Justified',
'approved' => 'Approved',
'amended' => 'Reopened — new work after approval',
_ => kind,
};
IconData _threadIcon(String kind) => switch (kind) {
'submitted' => Icons.send_outlined,
'auto_submitted' => Icons.schedule_send_outlined,
'disapproved' => Icons.thumb_down_alt_outlined,
'justified' => Icons.chat_bubble_outline,
'approved' => Icons.check_circle_outline,
'amended' => Icons.redo_outlined,
_ => Icons.circle_outlined,
};
StatusKind _threadKind(String kind) => switch (kind) {
'approved' => StatusKind.success,
'disapproved' => StatusKind.warning,
'justified' => StatusKind.info,
_ => StatusKind.neutral,
};
/// Renders day-sheet events as a vertical timeline, oldest first.
class DaySheetThread extends StatelessWidget {
const DaySheetThread({
super.key,
required this.events,
required this.profileNames,
});
final List<ProgrammerDaySheetEvent> events;
/// Map of userId → display name used to show actor names.
final Map<String, String> profileNames;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const AppSectionHeader('Activity'),
if (events.isEmpty)
Text(
'No activity yet.',
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
)
else
for (final event in events)
_ThreadRow(
event: event,
actorName: event.actorId != null
? profileNames[event.actorId] ?? 'Someone'
: null,
),
],
);
}
}
class _ThreadRow extends StatelessWidget {
const _ThreadRow({required this.event, this.actorName});
final ProgrammerDaySheetEvent event;
final String? actorName;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final status = AppStatusColors.of(context);
final kind = _threadKind(event.kind);
final swatch = status.byKind(kind);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: swatch.container,
shape: BoxShape.circle,
),
child: Icon(
_threadIcon(event.kind),
size: 14,
color: swatch.onContainer,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
actorName != null
? '$actorName · ${_threadLabel(event.kind)}'
: _threadLabel(event.kind),
style: tt.bodyMedium,
),
),
Text(
AppTime.relative(event.createdAt),
style: tt.bodySmall
?.copyWith(color: cs.onSurfaceVariant),
),
],
),
if (event.body != null && event.body!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
event.body!,
style: tt.bodySmall
?.copyWith(color: cs.onSurfaceVariant),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
],
if (event.flaggedTaskIds.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
'Flagged: ${event.flaggedTaskIds.length} task(s)',
style: tt.bodySmall?.copyWith(
color: cs.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
],
],
),
),
],
),
);
}
}
+207
View File
@@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/models/programmer_day_sheet.model.dart';
import 'package:tasq/screens/programmer_tasks/day_sheets/day_sheet_dialogs.dart';
import 'package:tasq/utils/programmer_day_rows.dart';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Widget _wrap(Widget child) {
return MaterialApp(home: Scaffold(body: child));
}
// Minimal tappable button that opens the dialog under test.
class _DisapproveOpener extends StatelessWidget {
const _DisapproveOpener({required this.rows, this.result});
final List<DaySheetRow> rows;
final ValueNotifier<String?>? result;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () async {
final r = await showDisapproveDialog(context, flaggedRows: rows);
result?.value = r;
},
child: const Text('Open'),
);
}
}
class _JustifyOpener extends StatelessWidget {
const _JustifyOpener({this.result});
final ValueNotifier<String?>? result;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () async {
final r = await showJustifyDialog(context);
result?.value = r;
},
child: const Text('Open'),
);
}
}
// ---------------------------------------------------------------------------
// DaySheetRow fixture
// ---------------------------------------------------------------------------
DaySheetRow _row(String taskId) => DaySheetRow(
taskId: taskId,
title: 'Task $taskId',
category: 'Software Development',
kind: 'assignee',
seconds: 3600,
notes: const [],
);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
group('showDisapproveDialog', () {
testWidgets('stays open and shows error when remarks are empty',
(tester) async {
final result = ValueNotifier<String?>(null);
await tester.pumpWidget(_wrap(_DisapproveOpener(
rows: [_row('t1')],
result: result,
)));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
// Dialog should be visible.
expect(find.text('Disapprove'), findsWidgets);
// Tap the Disapprove action button with empty remarks.
final disapproveButtons = find.text('Disapprove');
await tester.tap(disapproveButtons.last);
await tester.pumpAndSettle();
// Error message should appear and dialog should still be open.
expect(find.text('Remarks are required'), findsOneWidget);
expect(find.text('Disapprove'), findsWidgets);
expect(result.value, isNull);
});
testWidgets('returns the entered remarks when non-empty', (tester) async {
final result = ValueNotifier<String?>(null);
await tester.pumpWidget(_wrap(_DisapproveOpener(
rows: [_row('t1')],
result: result,
)));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.enterText(
find.byType(TextField).first, 'Missing notes');
await tester.pumpAndSettle();
final disapproveButtons = find.text('Disapprove');
await tester.tap(disapproveButtons.last);
await tester.pumpAndSettle();
expect(result.value, 'Missing notes');
});
});
group('showJustifyDialog', () {
testWidgets('stays open and shows error when justification is empty',
(tester) async {
final result = ValueNotifier<String?>(null);
await tester.pumpWidget(_wrap(_JustifyOpener(result: result)));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
// Tap submit with no text.
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
expect(find.text('Please write a justification'), findsOneWidget);
expect(result.value, isNull);
});
testWidgets('returns body when non-empty', (tester) async {
final result = ValueNotifier<String?>(null);
await tester.pumpWidget(_wrap(_JustifyOpener(result: result)));
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).first, 'My justification');
await tester.pumpAndSettle();
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
expect(result.value, 'My justification');
});
});
group('rowsForSheet', () {
test('returns snapshot rows when approved and snapshot exists', () {
final snapshotRow = _row('snap-1');
final snapshot = DaySheetSnapshot(
totalSeconds: 3600,
rows: [snapshotRow],
);
final sheet = ProgrammerDaySheet(
id: 'sheet-1',
programmerId: 'user-1',
status: DaySheetStatus.approved,
workDate: DateTime.utc(2026, 9, 27),
approvedSnapshot: snapshot,
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
);
final liveRow = _row('live-1');
final result = rowsForSheet(sheet, [liveRow]);
expect(result, [snapshotRow]);
});
test('returns live rows when not approved', () {
final sheet = ProgrammerDaySheet(
id: 'sheet-2',
programmerId: 'user-1',
status: DaySheetStatus.pending,
workDate: DateTime.utc(2026, 9, 27),
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
);
final liveRow = _row('live-1');
final result = rowsForSheet(sheet, [liveRow]);
expect(result, [liveRow]);
});
test('returns live rows when approved but snapshot is null', () {
final sheet = ProgrammerDaySheet(
id: 'sheet-3',
programmerId: 'user-1',
status: DaySheetStatus.approved,
workDate: DateTime.utc(2026, 9, 27),
approvedSnapshot: null,
createdAt: DateTime.utc(2026, 9, 27),
updatedAt: DateTime.utc(2026, 9, 27),
);
final liveRow = _row('live-1');
final result = rowsForSheet(sheet, [liveRow]);
expect(result, [liveRow]);
});
});
}