diff --git a/lib/screens/attendance/attendance_screen.dart b/lib/screens/attendance/attendance_screen.dart index 3cab9e8a..6eae36fe 100644 --- a/lib/screens/attendance/attendance_screen.dart +++ b/lib/screens/attendance/attendance_screen.dart @@ -30,6 +30,7 @@ import '../../models/it_service_request.dart'; import '../../models/swap_request.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; +import 'dtr_pdf.dart'; import 'logbook_day_activity.dart'; import 'work_log_tab.dart'; import '../../theme/m3_motion.dart'; @@ -1755,6 +1756,10 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { return 'Overtime'; case 'on_call': return 'On Call'; + case 'on_call_saturday': + return 'On Call (Saturday)'; + case 'on_call_sunday': + return 'On Call (Sunday)'; default: return shiftType; } @@ -1795,6 +1800,7 @@ class _LogbookEntry { this.checkOutLng, this.rawLog, this.swapRequestId, + this.swapRemark, }); final String name; @@ -1827,6 +1833,8 @@ class _LogbookEntry { final AttendanceLog? rawLog; // Non-null when the duty schedule was created by an accepted swap. final String? swapRequestId; + // Pre-computed remark text showing the other swap party's name. + final String? swapRemark; /// Whether this entry can be re-verified (within 10 min of check-in). bool canReverify(String currentUserId) { @@ -1841,7 +1849,11 @@ class _LogbookEntry { // NOTE: logbook entry creation is handled in an async provider (so that // filtering/sorting does not block the UI). Use `_computeLogbookEntries`. - factory _LogbookEntry.absent(DutySchedule s, Map byId) { + factory _LogbookEntry.absent( + DutySchedule s, + Map byId, { + String? swapRemark, + }) { final p = byId[s.userId]; final name = p is Profile ? p.fullName : (p as String?) ?? s.userId; return _LogbookEntry( @@ -1857,6 +1869,8 @@ class _LogbookEntry { duration: '—', status: 'Absent', isAbsent: true, + swapRequestId: s.swapRequestId, + swapRemark: swapRemark, ); } @@ -1876,6 +1890,10 @@ class _LogbookEntry { return 'Overtime'; case 'on_call': return 'On Call'; + case 'on_call_saturday': + return 'On Call (Saturday)'; + case 'on_call_sunday': + return 'On Call (Sunday)'; default: return shiftType; } @@ -1941,6 +1959,14 @@ class _ShiftGroup { /// Whether this shift was created by an accepted swap request. bool get isSwapped => sessions.any((s) => s.swapRequestId != null); + + /// Pre-computed swap remark from any session in this group (e.g. "Swap ↔ Alice Cruz"). + String? get swapRemark { + for (final s in sessions) { + if (s.swapRemark != null) return s.swapRemark; + } + return null; + } } // ──────────────────────────────────────────────── @@ -2011,6 +2037,12 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> { icon: const Icon(Icons.tune, size: 18), label: const Text('Change'), ), + const SizedBox(width: 8), + IconButton.filled( + tooltip: 'Export DTR as PDF', + icon: const Icon(Icons.file_download_outlined, size: 18), + onPressed: () => _exportDtr(context, ref), + ), ], ), ), @@ -2172,7 +2204,7 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> { final now = AppTime.now(); final absentSchedules = schedules.where((s) { - if (s.shiftType == 'overtime' || s.shiftType == 'on_call') return false; + if (s.shiftType == 'overtime' || s.shiftType.startsWith('on_call')) return false; if (logScheduleIds.contains(s.id)) return false; if (!s.endTime.isBefore(now)) return false; if (s.startTime.isBefore(range.start) || @@ -2216,6 +2248,27 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> { final scheduleById = {for (final s in schedules) s.id: s}; + // Both sides of an accepted swap share the same swapRequestId on their new + // DutySchedule rows. Group those userIds so we can resolve the other party. + final swapIdToUserIds = >{}; + for (final s in schedules) { + if (s.swapRequestId != null) { + swapIdToUserIds.putIfAbsent(s.swapRequestId!, () => {}).add(s.userId); + } + } + + String? swapRemarkFor(String? swapRequestId, String entryUserId) { + if (swapRequestId == null) return null; + final userIds = swapIdToUserIds[swapRequestId]; + if (userIds == null) return null; + final others = userIds.where((id) => id != entryUserId).toList(); + if (others.isEmpty) return null; + final names = others + .map((id) => profileById[id]?.fullName ?? id) + .join(', '); + return 'Swap ↔ $names'; + } + final List<_LogbookEntry> entries = [ for (final l in filtered) _LogbookEntry( @@ -2249,8 +2302,17 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> { checkOutLng: l.checkOutLng, rawLog: l, swapRequestId: scheduleById[l.dutyScheduleId]?.swapRequestId, + swapRemark: swapRemarkFor( + scheduleById[l.dutyScheduleId]?.swapRequestId, + l.userId, + ), ), - ...absentSchedules.map((s) => _LogbookEntry.absent(s, profileById)), + ...absentSchedules.map( + (s) => _LogbookEntry.absent( + s, profileById, + swapRemark: swapRemarkFor(s.swapRequestId, s.userId), + ), + ), ...leaveEntries, ]; @@ -2468,6 +2530,112 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> { ); } } + + void _exportDtr(BuildContext context, WidgetRef ref) { + final entries = _entriesAsync.valueOrNull; + if (entries == null || entries.isEmpty) { + showInfoSnackBar(context, 'No logbook data to export.'); + return; + } + + final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? []; + final passSlips = ref.read(passSlipsProvider).valueOrNull ?? []; + final profileList = ref.read(profilesProvider).valueOrNull ?? []; + final range = ref.read(attendanceDateRangeProvider); + final scheduleById = {for (final s in schedules) s.id: s}; + final profileById = {for (final p in profileList) p.id: p}; + + // Ensure current user's profile is always resolvable. + final currentProfile = ref.read(currentProfileProvider).valueOrNull; + if (currentProfile != null) { + profileById.putIfAbsent(currentProfile.id, () => currentProfile); + } + + // Group entries by user, preserving insertion order. + final byUser = >{}; + for (final e in entries) { + byUser.putIfAbsent(e.userId, () => []).add(e); + } + + final perUser = byUser.entries.map((kv) { + final userId = kv.key; + // Sort ascending for a chronological time record. + final userEntries = kv.value..sort((a, b) => a.date.compareTo(b.date)); + final profile = profileById[userId]; + final name = userEntries.first.name; + final role = profile?.role ?? ''; + final dtrEntries = userEntries + .map((e) => _toDtrEntry(e, passSlips, scheduleById)) + .toList(); + return (name, role, dtrEntries); + }).toList(); + + if (perUser.length == 1) { + final (name, role, dtrEntries) = perUser.first; + showDtrPdfDialog( + context, + employeeName: name, + role: role, + period: range.dateTimeRange, + entries: dtrEntries, + ); + } else { + showDtrPdfDialogMulti( + context, + users: perUser, + period: range.dateTimeRange, + ); + } + } + + DtrEntry _toDtrEntry( + _LogbookEntry e, + List allSlips, + Map scheduleById, + ) { + final schedule = + e.dutyScheduleId != null ? scheduleById[e.dutyScheduleId!] : null; + final isLate = !e.isAbsent && + !e.isLeave && + schedule != null && + e.checkInAt.isAfter( + schedule.startTime.add(const Duration(minutes: 5)), + ); + + final mySlips = allSlips + .where( + (s) => + e.dutyScheduleId != null && + s.dutyScheduleId == e.dutyScheduleId && + s.status == 'approved', + ) + .map( + (s) => DtrPassSlipEntry( + reason: s.reason, + timeRange: (s.slipStart != null && s.slipEnd != null) + ? '${AppTime.formatTime(s.slipStart!)} – ${AppTime.formatTime(s.slipEnd!)}' + : '—', + status: s.status, + ), + ) + .toList(); + + return DtrEntry( + date: e.date, + shiftLabel: e.shift, + checkInAt: e.checkInAt, + checkOutAt: e.checkOutAt, + checkIn: e.checkIn, + checkOut: e.checkOut, + duration: e.duration, + isAbsent: e.isAbsent, + isLeave: e.isLeave, + isLate: isLate, + leaveType: e.leaveType, + justification: e.justification, + passSlips: mySlips, + ); + } } // ──────────────────────────────────────────────── @@ -2522,7 +2690,10 @@ class _DateGroupTile extends StatelessWidget { // If the group is an absence or leave, render as a single row. final first = group.sessions.first; if (first.isAbsent || first.isLeave) { - return _buildSessionTile(context, first, 0, 1); + return Card( + margin: const EdgeInsets.fromLTRB(12, 8, 12, 0), + child: _buildSessionTile(context, first, 0, 1), + ); } final colors = Theme.of(context).colorScheme; @@ -2590,11 +2761,12 @@ class _DateGroupTile extends StatelessWidget { color: colors.onSecondaryContainer), const SizedBox(width: 3), Text( - 'Swapped', + group.swapRemark ?? 'Swapped', style: textTheme.labelSmall?.copyWith( color: colors.onSecondaryContainer, fontWeight: FontWeight.w600, ), + overflow: TextOverflow.ellipsis, ), ], ), @@ -2729,9 +2901,9 @@ class _DateGroupTile extends StatelessWidget { Expanded( child: Text( entry.isLeave - ? 'Leave${entry.leaveType != null ? ' — ${_leaveLabel(entry.leaveType!)}' : ''}' + ? entry.name : entry.isAbsent - ? 'No Check-In Recorded' + ? entry.name : entry.justification?.isNotEmpty == true ? entry.justification! : total > 1 @@ -2758,9 +2930,9 @@ class _DateGroupTile extends StatelessWidget { ), subtitle: Text( entry.isLeave - ? 'On Leave${entry.leaveType != null ? ' — ${_leaveLabel(entry.leaveType!)}' : ''}' + ? '${entry.shift} — On Leave${entry.leaveType != null ? ' — ${_leaveLabel(entry.leaveType!)}' : ''}' : entry.isAbsent - ? 'Absent — no check-in recorded' + ? '${entry.shift} — No check-in recorded' : 'In: ${entry.checkIn} · Out: ${entry.checkOut} · ${entry.duration}${entry.status == 'On duty' ? ' · On duty' : ''}', ), trailing: isLeaveOrAbsent diff --git a/lib/screens/attendance/dtr_pdf.dart b/lib/screens/attendance/dtr_pdf.dart new file mode 100644 index 00000000..61fff396 --- /dev/null +++ b/lib/screens/attendance/dtr_pdf.dart @@ -0,0 +1,604 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:intl/intl.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:pdf/pdf.dart' as pdf; +import 'package:printing/printing.dart'; +import 'package:pdfrx/pdfrx.dart'; + +// ─── DTOs ───────────────────────────────────────────────────────────────── + +class DtrEntry { + const DtrEntry({ + required this.date, + required this.shiftLabel, + required this.checkInAt, + this.checkOutAt, + required this.checkIn, + required this.checkOut, + required this.duration, + required this.isAbsent, + required this.isLeave, + required this.isLate, + this.leaveType, + this.justification, + this.passSlips = const [], + }); + + final DateTime date; + final String shiftLabel; + final DateTime checkInAt; + final DateTime? checkOutAt; + final String checkIn; + final String checkOut; + final String duration; + final bool isAbsent; + final bool isLeave; + final bool isLate; + final String? leaveType; + final String? justification; + final List passSlips; +} + +class DtrPassSlipEntry { + const DtrPassSlipEntry({ + required this.reason, + required this.timeRange, + required this.status, + }); + + final String reason; + final String timeRange; + final String status; +} + +// ─── Public entry points ────────────────────────────────────────────────── + +void showDtrPdfDialog( + BuildContext context, { + required String employeeName, + required String role, + required DateTimeRange period, + required List entries, +}) { + showDialog( + context: context, + builder: (_) => _DtrPdfDialog( + title: 'DTR – $employeeName', + filename: 'DTR - $employeeName.pdf', + buildBytes: () => _buildPdf([(employeeName, role, entries)], period), + ), + ); +} + +void showDtrPdfDialogMulti( + BuildContext context, { + required List<(String, String, List)> users, + required DateTimeRange period, +}) { + showDialog( + context: context, + builder: (_) => _DtrPdfDialog( + title: 'DTR Export (${users.length} employees)', + filename: 'DTR Export.pdf', + buildBytes: () => _buildPdf(users, period), + ), + ); +} + +Future buildDtrPdfBytes({ + required String employeeName, + required String role, + required DateTimeRange period, + required List entries, +}) => + _buildPdf([(employeeName, role, entries)], period); + +// ─── PDF builder ────────────────────────────────────────────────────────── + +Future _buildPdf( + List<(String, String, List)> users, + DateTimeRange period, +) async { + final logoData = await rootBundle.load('assets/crmc_logo.png'); + final logoImage = pw.MemoryImage(logoData.buffer.asUint8List()); + + final regular = pw.Font.ttf( + await rootBundle.load('assets/fonts/Roboto-Regular.ttf'), + ); + final bold = pw.Font.ttf( + await rootBundle.load('assets/fonts/Roboto-Bold.ttf'), + ); + + final doc = pw.Document(); + + for (final (name, role, entries) in users) { + // Sort ascending (oldest first) for a chronological time record. + final sorted = [...entries]..sort((a, b) => a.date.compareTo(b.date)); + + doc.addPage( + pw.MultiPage( + pageFormat: pdf.PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(32), + theme: pw.ThemeData.withFont(base: regular, bold: bold), + footer: (ctx) => pw.Align( + alignment: pw.Alignment.centerRight, + child: pw.Text( + 'Page ${ctx.pageNumber} of ${ctx.pagesCount}', + style: pw.TextStyle( + font: regular, + fontSize: 8, + color: pdf.PdfColors.grey600, + ), + ), + ), + build: (ctx) => [ + _header(logoImage, regular, bold), + pw.SizedBox(height: 8), + pw.Center( + child: pw.Text( + 'DAILY TIME RECORD (DTR)', + style: pw.TextStyle(font: bold, fontSize: 14, letterSpacing: 1.5), + ), + ), + pw.SizedBox(height: 10), + _employeeInfo(name, role, period, regular, bold), + pw.SizedBox(height: 10), + _attendanceTable(sorted, regular, bold), + if (sorted.any((e) => e.passSlips.isNotEmpty)) ...[ + pw.SizedBox(height: 8), + _passSlipsSection(sorted, regular, bold), + ], + pw.SizedBox(height: 10), + _summary(sorted, regular, bold), + ], + ), + ); + } + + return doc.save(); +} + +// ─── Section builders ───────────────────────────────────────────────────── + +pw.Widget _header( + pw.ImageProvider logo, + pw.Font regular, + pw.Font bold, +) { + return pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.center, + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Image(logo, width: 60, height: 60), + pw.SizedBox(width: 16), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Text( + 'Republic of the Philippines', + style: pw.TextStyle(font: regular, fontSize: 9), + ), + pw.Text( + 'Department of Health', + style: pw.TextStyle(font: regular, fontSize: 9), + ), + pw.Text( + 'Cotabato Regional and Medical Center', + style: pw.TextStyle(font: regular, fontSize: 9), + ), + pw.SizedBox(height: 3), + pw.Text( + 'Integrated Hospital Operations and Management Program (IHOMP)', + style: pw.TextStyle(font: bold, fontSize: 9), + ), + ], + ), + ], + ); +} + +pw.Widget _employeeInfo( + String name, + String role, + DateTimeRange period, + pw.Font regular, + pw.Font bold, +) { + final fmt = DateFormat('MMMM d, yyyy'); + final periodStr = '${fmt.format(period.start)} – ${fmt.format(period.end)}'; + + pw.Widget infoRow(String label, String value) => pw.RichText( + text: pw.TextSpan( + children: [ + pw.TextSpan( + text: '$label ', + style: pw.TextStyle(font: bold, fontSize: 9), + ), + pw.TextSpan( + text: value, + style: pw.TextStyle(font: regular, fontSize: 9), + ), + ], + ), + ); + + return pw.Container( + decoration: pw.BoxDecoration( + border: pw.Border.all(width: 0.8, color: pdf.PdfColors.grey400), + ), + padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: pw.Row( + children: [ + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + infoRow('Name:', name), + pw.SizedBox(height: 3), + infoRow('Position:', _roleLabel(role)), + ], + ), + ), + pw.Expanded( + child: infoRow('Period:', periodStr), + ), + ], + ), + ); +} + +pw.Widget _attendanceTable( + List entries, + pw.Font regular, + pw.Font bold, +) { + final dayFmt = DateFormat('EEE'); + final dateFmt = DateFormat('MMM d'); + + pw.Widget th(String text) => pw.Padding( + padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: pw.Text( + text, + style: pw.TextStyle(font: bold, fontSize: 8), + ), + ); + + pw.Widget td(String text) => pw.Padding( + padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 3), + child: pw.Text( + text, + style: pw.TextStyle(font: regular, fontSize: 8), + ), + ); + + String statusText(DtrEntry e) { + if (e.isAbsent) return 'Absent'; + if (e.isLeave) return _leaveTypeLabel(e.leaveType); + if (e.isLate) return 'Late'; + if (e.shiftLabel == 'Overtime') return 'Overtime'; + if (e.duration == 'On duty') return 'Active'; + return 'Present'; + } + + pdf.PdfColor? rowColor(DtrEntry e) { + if (e.isAbsent) return pdf.PdfColor.fromHex('#FFEBEE'); + if (e.isLeave) return pdf.PdfColor.fromHex('#E3F2FD'); + if (e.shiftLabel == 'Overtime') return pdf.PdfColor.fromHex('#FFF8E1'); + if (e.isLate) return pdf.PdfColor.fromHex('#FFF3E0'); + return null; + } + + return pw.Table( + border: pw.TableBorder.all(width: 0.5, color: pdf.PdfColors.grey400), + columnWidths: const { + 0: pw.FixedColumnWidth(56), + 1: pw.FixedColumnWidth(28), + 2: pw.FixedColumnWidth(72), + 3: pw.FixedColumnWidth(60), + 4: pw.FixedColumnWidth(60), + 5: pw.FixedColumnWidth(50), + 6: pw.FlexColumnWidth(), + }, + children: [ + pw.TableRow( + decoration: const pw.BoxDecoration(color: pdf.PdfColors.grey200), + children: [ + th('Date'), + th('Day'), + th('Shift'), + th('Check-In'), + th('Check-Out'), + th('Hours'), + th('Remarks'), + ], + ), + for (final e in entries) + pw.TableRow( + decoration: pw.BoxDecoration(color: rowColor(e)), + children: [ + td(dateFmt.format(e.date)), + td(dayFmt.format(e.date)), + td(e.shiftLabel), + td(e.checkIn), + td(e.checkOut), + td(e.duration), + td(statusText(e)), + ], + ), + ], + ); +} + +pw.Widget _passSlipsSection( + List entries, + pw.Font regular, + pw.Font bold, +) { + final dateFmt = DateFormat('MMM d (EEE)'); + final slips = <(String, DtrPassSlipEntry)>[]; + for (final e in entries) { + for (final s in e.passSlips) { + slips.add((dateFmt.format(e.date), s)); + } + } + + pw.Widget th(String text) => pw.Padding( + padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 3), + child: pw.Text(text, style: pw.TextStyle(font: bold, fontSize: 8)), + ); + + pw.Widget td(String text) => pw.Padding( + padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: pw.Text(text, style: pw.TextStyle(font: regular, fontSize: 7.5)), + ); + + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'Pass Slips', + style: pw.TextStyle(font: bold, fontSize: 9, letterSpacing: 0.5), + ), + pw.SizedBox(height: 4), + pw.Table( + border: pw.TableBorder.all(width: 0.5, color: pdf.PdfColors.grey400), + columnWidths: const { + 0: pw.FixedColumnWidth(88), + 1: pw.FixedColumnWidth(88), + 2: pw.FlexColumnWidth(), + 3: pw.FixedColumnWidth(60), + }, + children: [ + pw.TableRow( + decoration: const pw.BoxDecoration(color: pdf.PdfColors.grey200), + children: [ + th('Date'), + th('Time Range'), + th('Reason'), + th('Status'), + ], + ), + for (final (date, slip) in slips) + pw.TableRow( + children: [ + td(date), + td(slip.timeRange), + td(slip.reason), + td(slip.status), + ], + ), + ], + ), + ], + ); +} + +pw.Widget _summary(List entries, pw.Font regular, pw.Font bold) { + final present = entries.where((e) => !e.isAbsent && !e.isLeave).length; + final absent = entries.where((e) => e.isAbsent).length; + final lates = entries.where((e) => e.isLate).length; + final leaves = entries.where((e) => e.isLeave).toList(); + + final leaveByType = {}; + for (final l in leaves) { + final label = _leaveTypeLabel(l.leaveType); + leaveByType[label] = (leaveByType[label] ?? 0) + 1; + } + final leaveSummary = leaveByType.isEmpty + ? '${leaves.length} day(s)' + : leaveByType.entries.map((e) => '${e.key}: ${e.value}').join(', '); + + final otEntries = + entries.where((e) => e.shiftLabel == 'Overtime' && e.checkOutAt != null); + final otMinutes = otEntries.fold( + 0, + (sum, e) => sum + e.checkOutAt!.difference(e.checkInAt).inMinutes, + ); + final otStr = + otMinutes == 0 ? 'None' : '${otMinutes ~/ 60}h ${otMinutes % 60}m'; + final passSlipTotal = + entries.fold(0, (s, e) => s + e.passSlips.length); + + pw.Widget cell(String label, String value) => pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text(label, style: pw.TextStyle(font: bold, fontSize: 8)), + pw.SizedBox(height: 2), + pw.Text(value, style: pw.TextStyle(font: regular, fontSize: 9)), + ], + ), + ); + + return pw.Container( + decoration: pw.BoxDecoration( + border: pw.Border.all(width: 0.8, color: pdf.PdfColors.grey400), + color: pdf.PdfColors.grey100, + ), + padding: const pw.EdgeInsets.all(8), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'SUMMARY', + style: pw.TextStyle(font: bold, fontSize: 9, letterSpacing: 1), + ), + pw.SizedBox(height: 6), + pw.Row( + children: [ + cell('Days Present', '$present'), + cell('Absences', '$absent'), + cell('Lates', '$lates'), + cell('Overtime', otStr), + ], + ), + pw.SizedBox(height: 4), + pw.Row( + children: [ + cell('Leaves', leaveSummary), + cell('Pass Slips', '$passSlipTotal'), + pw.Expanded(child: pw.SizedBox()), + pw.Expanded(child: pw.SizedBox()), + ], + ), + ], + ), + ); +} + +// ─── Helpers ────────────────────────────────────────────────────────────── + +String _roleLabel(String role) { + switch (role) { + case 'admin': + return 'Administrator'; + case 'dispatcher': + return 'Dispatcher'; + case 'programmer': + return 'Programmer'; + case 'it_staff': + return 'IT Staff'; + default: + return role.isEmpty ? 'Staff' : role; + } +} + +String _leaveTypeLabel(String? leaveType) { + switch (leaveType) { + case 'sick_leave': + return 'Sick Leave'; + case 'vacation_leave': + return 'Vacation Leave'; + case 'emergency_leave': + return 'Emergency Leave'; + case 'parental_leave': + return 'Parental Leave'; + default: + return 'On Leave'; + } +} + +// ─── Dialog ─────────────────────────────────────────────────────────────── + +class _DtrPdfDialog extends StatefulWidget { + const _DtrPdfDialog({ + required this.title, + required this.filename, + required this.buildBytes, + }); + + final String title; + final String filename; + final Future Function() buildBytes; + + @override + State<_DtrPdfDialog> createState() => _DtrPdfDialogState(); +} + +class _DtrPdfDialogState extends State<_DtrPdfDialog> { + late final Future _future; + + @override + void initState() { + super.initState(); + _future = widget.buildBytes(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + contentPadding: EdgeInsets.zero, + content: SizedBox( + width: 800, + height: 900, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Expanded( + child: Text( + widget.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + tooltip: 'Print', + icon: const Icon(Icons.print), + onPressed: () async { + final bytes = await _future; + await Printing.layoutPdf( + onLayout: (_) async => bytes, + name: widget.filename, + ); + }, + ), + IconButton( + tooltip: 'Download', + icon: const Icon(Icons.download), + onPressed: () async { + final bytes = await _future; + await Printing.sharePdf( + bytes: bytes, + filename: widget.filename, + ); + }, + ), + IconButton( + tooltip: 'Close', + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text(snapshot.error.toString())); + } + final data = snapshot.data; + if (data == null) return const SizedBox.shrink(); + return PdfViewer.data(data, sourceName: 'dtr.pdf'); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/offline_readiness_sheet.dart b/lib/widgets/offline_readiness_sheet.dart index 78e2c56a..7beadf86 100644 --- a/lib/widgets/offline_readiness_sheet.dart +++ b/lib/widgets/offline_readiness_sheet.dart @@ -306,7 +306,7 @@ class _CacheGridState extends State<_CacheGrid> with SingleTickerProviderStateMi crossAxisCount: 3, mainAxisSpacing: 8, crossAxisSpacing: 8, - childAspectRatio: 0.9, + mainAxisExtent: 100, ), itemCount: _groups.length, itemBuilder: (context, i) { @@ -376,12 +376,17 @@ class _CacheTile extends StatelessWidget { children: [ Icon(icon, color: fg, size: 24), const SizedBox(height: 6), - Text( - label, - style: TextStyle(color: fg, fontSize: 11, fontWeight: FontWeight.w500), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, + SizedBox( + height: 30, + child: Center( + child: Text( + label, + style: TextStyle(color: fg, fontSize: 11, fontWeight: FontWeight.w500), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), ), const SizedBox(height: 2), Text(