feat(programmer): day sheet detail screen
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -30,6 +30,7 @@ import '../widgets/app_shell.dart';
|
|||||||
import '../screens/teams/teams_screen.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_requests_list_screen.dart';
|
||||||
import '../screens/it_service_requests/it_service_request_detail_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_drill_down_screen.dart';
|
||||||
import '../screens/programmer_tasks/programmer_tasks_list_screen.dart';
|
import '../screens/programmer_tasks/programmer_tasks_list_screen.dart';
|
||||||
import '../screens/programmer_tasks/programmer_task_detail_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(
|
GoRoute(
|
||||||
path: ':id',
|
path: ':id',
|
||||||
pageBuilder: (context, state) => M3ContainerTransformPage(
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user