Files
tasq/lib/screens/attendance/logbook_day_activity.dart

597 lines
21 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/attendance_log.dart';
import '../../models/it_service_request.dart';
import '../../models/pass_slip.dart';
import '../../models/task.dart';
import '../../models/ticket.dart';
import '../../theme/m3_motion.dart';
import '../../utils/app_time.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Data class
// ─────────────────────────────────────────────────────────────────────────────
class LogbookDayActivityData {
final String userId;
final String name;
final DateTime date;
final String shiftLabel;
final DateTime? scheduledStart;
final DateTime? scheduledEnd;
final bool isSwapped;
final List<AttendanceLog> sessions;
final List<PassSlip> passSlips;
final List<Task> tasks;
final List<Ticket> tickets;
final List<ItServiceRequest> serviceRequests;
const LogbookDayActivityData({
required this.userId,
required this.name,
required this.date,
required this.shiftLabel,
this.scheduledStart,
this.scheduledEnd,
this.isSwapped = false,
required this.sessions,
required this.passSlips,
required this.tasks,
required this.tickets,
required this.serviceRequests,
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Timeline event model
// ─────────────────────────────────────────────────────────────────────────────
enum _DayEventType {
shiftScheduled,
swapNote,
checkIn,
passSlipOut,
passSlipReturn,
checkOut,
stillOnDuty,
taskCreated,
taskStarted,
taskCompleted,
ticketCreated,
ticketResponded,
ticketPromoted,
isrCreated,
isrUpdated,
isrCompleted,
}
class _DayEvent {
final _DayEventType type;
final DateTime time;
final String title;
final String? subtitle;
const _DayEvent({
required this.type,
required this.time,
required this.title,
this.subtitle,
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Event assembly
// ─────────────────────────────────────────────────────────────────────────────
List<_DayEvent> _buildEvents(LogbookDayActivityData data) {
final events = <_DayEvent>[];
final t = AppTime.formatTime;
if (data.scheduledStart != null) {
events.add(_DayEvent(
type: _DayEventType.shiftScheduled,
time: data.scheduledStart!,
title: 'Shift scheduled — ${data.shiftLabel}',
subtitle: data.scheduledEnd != null
? '${t(data.scheduledStart!)} ${t(data.scheduledEnd!)}'
: t(data.scheduledStart!),
));
if (data.isSwapped) {
events.add(_DayEvent(
type: _DayEventType.swapNote,
time: data.scheduledStart!,
title: 'Shift received via swap',
));
}
}
final sortedSessions = [...data.sessions]
..sort((a, b) => a.checkInAt.compareTo(b.checkInAt));
for (final session in sortedSessions) {
events.add(_DayEvent(
type: _DayEventType.checkIn,
time: session.checkInAt,
title: 'Checked in',
subtitle: t(session.checkInAt),
));
// Pass slips that started during this session window.
final sessionEnd = session.checkOutAt ?? AppTime.now();
for (final slip in data.passSlips) {
if (slip.slipStart == null) continue;
if (slip.slipStart!.isBefore(session.checkInAt) ||
slip.slipStart!.isAfter(sessionEnd)) {
continue;
}
events.add(_DayEvent(
type: _DayEventType.passSlipOut,
time: slip.slipStart!,
title: 'Pass slip — left',
subtitle: slip.reason,
));
if (slip.slipEnd != null) {
events.add(_DayEvent(
type: _DayEventType.passSlipReturn,
time: slip.slipEnd!,
title: 'Pass slip — returned',
subtitle: '${t(slip.slipStart!)} ${t(slip.slipEnd!)}',
));
} else {
events.add(_DayEvent(
type: _DayEventType.passSlipReturn,
time: slip.slipStart!.add(const Duration(seconds: 1)),
title: 'Pass slip — still out',
));
}
}
if (session.checkOutAt != null) {
events.add(_DayEvent(
type: _DayEventType.checkOut,
time: session.checkOutAt!,
title: 'Checked out',
subtitle: t(session.checkOutAt!),
));
} else {
events.add(_DayEvent(
type: _DayEventType.stillOnDuty,
time: AppTime.now(),
title: 'Currently on duty',
));
}
}
for (final task in data.tasks) {
if (task.startedAt != null) {
events.add(_DayEvent(
type: _DayEventType.taskStarted,
time: task.startedAt!,
title: 'Task started',
subtitle: task.title,
));
} else if (task.completedAt != null) {
events.add(_DayEvent(
type: _DayEventType.taskCompleted,
time: task.completedAt!,
title: 'Task completed',
subtitle: task.title,
));
} else {
events.add(_DayEvent(
type: _DayEventType.taskCreated,
time: task.createdAt,
title: 'Task created',
subtitle: task.title,
));
}
}
for (final ticket in data.tickets) {
if (ticket.promotedAt != null) {
events.add(_DayEvent(
type: _DayEventType.ticketPromoted,
time: ticket.promotedAt!,
title: 'Ticket promoted to task',
subtitle: ticket.subject,
));
} else if (ticket.respondedAt != null) {
events.add(_DayEvent(
type: _DayEventType.ticketResponded,
time: ticket.respondedAt!,
title: 'Ticket responded',
subtitle: ticket.subject,
));
} else {
events.add(_DayEvent(
type: _DayEventType.ticketCreated,
time: ticket.createdAt,
title: 'Ticket created',
subtitle: ticket.subject,
));
}
}
for (final isr in data.serviceRequests) {
if (isr.completedAt != null) {
events.add(_DayEvent(
type: _DayEventType.isrCompleted,
time: isr.completedAt!,
title: 'IT request completed',
subtitle: isr.requestNumber,
));
} else {
final isCreatedDay = _sameDay(isr.createdAt, data.date);
events.add(_DayEvent(
type: isCreatedDay ? _DayEventType.isrCreated : _DayEventType.isrUpdated,
time: isCreatedDay ? isr.createdAt : isr.updatedAt,
title: isCreatedDay ? 'IT request created' : 'IT request updated',
subtitle: isr.requestNumber,
));
}
}
events.sort((a, b) => a.time.compareTo(b.time));
return events;
}
bool _sameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
// ─────────────────────────────────────────────────────────────────────────────
// Main sheet widget
// ─────────────────────────────────────────────────────────────────────────────
class LogbookDayActivitySheet extends ConsumerWidget {
const LogbookDayActivitySheet({super.key, required this.data});
final LogbookDayActivityData data;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final events = _buildEvents(data);
final dateStr = _formatDate(data.date);
final initials = _initials(data.name);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Drag handle (mobile) ──────────────────────────────
Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 4),
width: 36,
height: 4,
decoration: BoxDecoration(
color: colors.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
// ── Header ───────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 16, 0),
child: Row(
children: [
CircleAvatar(
radius: 22,
backgroundColor: colors.primaryContainer,
child: Text(
initials,
style: textTheme.titleSmall?.copyWith(
color: colors.onPrimaryContainer,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(data.name,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
Text(dateStr,
style: textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
)),
],
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// ── Summary chips ─────────────────────────────────────
if (data.tasks.isNotEmpty ||
data.tickets.isNotEmpty ||
data.serviceRequests.isNotEmpty ||
data.passSlips.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 0),
child: Wrap(
spacing: 6,
runSpacing: 4,
children: [
if (data.tasks.isNotEmpty)
_SummaryChip(
label: '${data.tasks.length} task${data.tasks.length == 1 ? '' : 's'}',
icon: Icons.task_alt_rounded,
color: colors.secondaryContainer,
onColor: colors.onSecondaryContainer,
),
if (data.tickets.isNotEmpty)
_SummaryChip(
label: '${data.tickets.length} ticket${data.tickets.length == 1 ? '' : 's'}',
icon: Icons.confirmation_num_outlined,
color: colors.tertiaryContainer,
onColor: colors.onTertiaryContainer,
),
if (data.serviceRequests.isNotEmpty)
_SummaryChip(
label: '${data.serviceRequests.length} ISR${data.serviceRequests.length == 1 ? '' : 's'}',
icon: Icons.computer_outlined,
color: colors.surfaceContainerHighest,
onColor: colors.onSurface,
),
if (data.passSlips.isNotEmpty)
_SummaryChip(
label: '${data.passSlips.length} pass slip${data.passSlips.length == 1 ? '' : 's'}',
icon: Icons.badge_outlined,
color: colors.primaryContainer,
onColor: colors.onPrimaryContainer,
),
],
),
),
const SizedBox(height: 12),
const Divider(height: 1),
// ── Timeline ─────────────────────────────────────────
Flexible(
child: events.isEmpty
? Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Text(
'No activity recorded for this day.',
style: textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
)
: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
itemCount: events.length,
itemBuilder: (context, index) {
return M3FadeSlideIn(
delay: Duration(
milliseconds: index.clamp(0, 14) * 45),
child: _TimelineTile(
event: events[index],
isFirst: index == 0,
isLast: index == events.length - 1,
),
);
},
),
),
],
);
}
static String _formatDate(DateTime d) {
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
final wd = weekdays[d.weekday - 1];
final mo = months[d.month - 1];
return '$wd, $mo ${d.day}, ${d.year}';
}
static String _initials(String name) {
final parts = name.trim().split(RegExp(r'\s+'));
if (parts.isEmpty) return '?';
if (parts.length == 1) return parts[0][0].toUpperCase();
return '${parts[0][0]}${parts[parts.length - 1][0]}'.toUpperCase();
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Summary chip
// ─────────────────────────────────────────────────────────────────────────────
class _SummaryChip extends StatelessWidget {
const _SummaryChip({
required this.label,
required this.icon,
required this.color,
required this.onColor,
});
final String label;
final IconData icon;
final Color color;
final Color onColor;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: onColor),
const SizedBox(width: 5),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: onColor,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Timeline tile
// ─────────────────────────────────────────────────────────────────────────────
class _TimelineTile extends StatelessWidget {
const _TimelineTile({
required this.event,
required this.isFirst,
required this.isLast,
});
final _DayEvent event;
final bool isFirst;
final bool isLast;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final dotColor = _dotColor(colors);
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Dot + connector line ──────────────────────────
SizedBox(
width: 28,
child: Column(
children: [
// Top connector (hidden for first item)
SizedBox(
height: isFirst ? 6 : 0,
width: 2,
child: isFirst
? null
: ColoredBox(color: colors.outlineVariant),
),
// Dot
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: dotColor,
border: Border.all(
color: colors.surface,
width: 1.5,
),
),
),
// Bottom connector (hidden for last item)
if (!isLast)
Expanded(
child: Center(
child: SizedBox(
width: 2,
child: ColoredBox(color: colors.outlineVariant),
),
),
),
],
),
),
const SizedBox(width: 10),
// ── Content ───────────────────────────────────────
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.title,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
if (event.subtitle != null) ...[
const SizedBox(height: 2),
Text(
event.subtitle!,
style: textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
const SizedBox(width: 8),
Text(
AppTime.formatTime(event.time),
style: textTheme.labelSmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
),
),
],
),
);
}
Color _dotColor(ColorScheme colors) {
switch (event.type) {
case _DayEventType.shiftScheduled:
case _DayEventType.swapNote:
return colors.outline;
case _DayEventType.checkIn:
case _DayEventType.checkOut:
return colors.primary;
case _DayEventType.passSlipOut:
case _DayEventType.passSlipReturn:
return colors.tertiary;
case _DayEventType.stillOnDuty:
return colors.primaryContainer;
case _DayEventType.taskCreated:
case _DayEventType.taskStarted:
case _DayEventType.taskCompleted:
return colors.secondary;
case _DayEventType.ticketCreated:
case _DayEventType.ticketResponded:
case _DayEventType.ticketPromoted:
return colors.secondaryContainer;
case _DayEventType.isrCreated:
case _DayEventType.isrUpdated:
case _DayEventType.isrCompleted:
return colors.tertiaryContainer;
}
}
}