Files
tasq/lib/screens/attendance/logbook_day_activity.dart
T
redz1029 57377b1b21 UI: Cyber/IT design overhaul with semantic status tokens (Material 3)
Re-theme TasQ with a refined enterprise "Cyber/IT" identity on Material 3.
Functionality is unchanged — only color/shape tokens and a few state widgets.

Foundation:
- Hand-tuned light + dark ColorSchemes (deep-slate surfaces, azure/teal/violet
  accents), tightened 12-16dp shape language, flat hairline-outlined cards.
- New AppStatusColors theme extension (success/warning/danger/info/neutral,
  per-brightness) replacing scattered raw Colors.green/orange/red/grey so status
  color is consistent and legible in dark mode.
- New TechChip mono primitive; StatusPill.kind; M3Card hairline variants;
  tightened AppSurfaces radii.

Per-screen:
- Tokenized status colors + radii across dashboard, tickets, tasks, IT service
  requests, attendance, whereabouts, network map, reports charts, auth, and
  shared/admin screens. Added _ticketStatusKind/_taskStatusKind/_isrStatusKind
  mappers so status carries meaning consistently.
- Fixed white pie-slice borders for dark mode; dropped ad-hoc dialog radius
  overrides in favor of the theme default. Categorical palettes (leave-type
  icons, team color picker, distribution charts) intentionally preserved.

Verified: flutter analyze lib clean; flutter build web green; visual QA in
light and dark (dashboard, tickets, attendance).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-26 15:13:47 +08:00

591 lines
20 KiB
Dart
Raw 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 'package:intl/intl.dart';
import '../../models/attendance_log.model.dart';
import '../../models/it_service_request.model.dart';
import '../../models/pass_slip.model.dart';
import '../../models/task.model.dart';
import '../../models/ticket.model.dart';
import '../../theme/app_surfaces.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) =>
DateFormat('EEE, MMM d, yyyy', 'en_US').format(d);
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(AppSurfaces.of(context).chipRadius),
),
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;
}
}
}