diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 258c29d5..b2d2129e 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -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((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( diff --git a/lib/screens/programmer_tasks/day_sheets/day_sheet_detail_screen.dart b/lib/screens/programmer_tasks/day_sheets/day_sheet_detail_screen.dart new file mode 100644 index 00000000..317aab8c --- /dev/null +++ b/lib/screens/programmer_tasks/day_sheets/day_sheet_detail_screen.dart @@ -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 createState() => + _DaySheetDetailScreenState(); +} + +class _DaySheetDetailScreenState extends ConsumerState { + Set _selectedTaskIds = {}; + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + StatusKind _kindForStatus(String s) => switch (s) { + DaySheetStatus.approved => StatusKind.success, + DaySheetStatus.pending => StatusKind.info, + DaySheetStatus.disapproved => StatusKind.warning, + _ => StatusKind.neutral, + }; + + Set _flaggedTaskIdsFromEvents(List 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.from(disapproved.first.flaggedTaskIds); + } + + void _showError(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); + } + + // --------------------------------------------------------------------------- + // Actions — Owner + // --------------------------------------------------------------------------- + + Future _onAddCorrection( + BuildContext ctx, + ProgrammerTask task, + String currentUserId, + DateTime workDate, + ) async { + await showModalBottomSheet( + 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 _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 _onDisapprove( + String sheetId, + List 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 _onApprove( + ProgrammerDaySheet sheet, + List 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( + 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(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 _buildLiveRowsFrom( + ProgrammerDaySheet sheet, + List events, + List 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 []; +} diff --git a/lib/screens/programmer_tasks/day_sheets/day_sheet_dialogs.dart b/lib/screens/programmer_tasks/day_sheets/day_sheet_dialogs.dart new file mode 100644 index 00000000..58f6bc31 --- /dev/null +++ b/lib/screens/programmer_tasks/day_sheets/day_sheet_dialogs.dart @@ -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 rowsForSheet( + ProgrammerDaySheet sheet, + List 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 showDisapproveDialog( + BuildContext context, { + required List flaggedRows, +}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _DisapproveDialog(flaggedRows: flaggedRows), + ); +} + +class _DisapproveDialog extends StatefulWidget { + const _DisapproveDialog({required this.flaggedRows}); + + final List flaggedRows; + + @override + State<_DisapproveDialog> createState() => _DisapproveDialogState(); +} + +class _DisapproveDialogState extends State<_DisapproveDialog> { + final _remarksCtrl = TextEditingController(); + String? _error; + late Set _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 showJustifyDialog(BuildContext context) { + return showDialog( + 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'), + ), + ], + ); + } +} diff --git a/lib/screens/programmer_tasks/day_sheets/day_sheet_rows_table.dart b/lib/screens/programmer_tasks/day_sheets/day_sheet_rows_table.dart new file mode 100644 index 00000000..f2061945 --- /dev/null +++ b/lib/screens/programmer_tasks/day_sheets/day_sheet_rows_table.dart @@ -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 rows; + + /// Task IDs flagged in the most recent disapproved event. These are + /// highlighted while the sheet is not yet approved. + final Set flaggedTaskIds; + + /// When non-null, admin selection checkboxes are shown and changes are + /// forwarded to this callback. + final Set? selectedTaskIds; + final ValueChanged>? onSelectionChanged; + + /// When non-null, a correction icon is shown on each row and tapping it + /// invokes this callback with the affected row. + final ValueChanged? 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.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? 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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/programmer_tasks/day_sheets/day_sheet_thread.dart b/lib/screens/programmer_tasks/day_sheets/day_sheet_thread.dart new file mode 100644 index 00000000..b8ca3c6a --- /dev/null +++ b/lib/screens/programmer_tasks/day_sheets/day_sheet_thread.dart @@ -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 events; + + /// Map of userId → display name used to show actor names. + final Map 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, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} diff --git a/test/day_sheet_dialogs_test.dart b/test/day_sheet_dialogs_test.dart new file mode 100644 index 00000000..8c60d9bb --- /dev/null +++ b/test/day_sheet_dialogs_test.dart @@ -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 rows; + final ValueNotifier? 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? 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(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(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(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(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]); + }); + }); +}