380 lines
13 KiB
Dart
380 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
import '../core/formatters.dart';
|
|
import '../core/theme.dart';
|
|
import '../models/compliance.dart';
|
|
import '../models/shift.dart';
|
|
import '../state/compliance_controller.dart';
|
|
import '../state/shifts_controller.dart';
|
|
import '../widgets/common.dart';
|
|
|
|
class ShiftDetailScreen extends ConsumerWidget {
|
|
const ShiftDetailScreen({super.key, required this.shiftId});
|
|
|
|
final String shiftId;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final shift = ref.watch(shiftsProvider.notifier).byId(shiftId);
|
|
if (shift == null) {
|
|
return Scaffold(
|
|
appBar: AppBar(),
|
|
body: const Center(child: Text('That shift is no longer listed.')),
|
|
);
|
|
}
|
|
|
|
return Scaffold(
|
|
body: ListView(
|
|
padding: EdgeInsets.zero,
|
|
children: [
|
|
BrandHeader(
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
HeaderIconButton(
|
|
icon: LucideIcons.arrowLeft,
|
|
onPressed: () => context.pop(),
|
|
),
|
|
HeaderIconButton(
|
|
icon: LucideIcons.messageCircle,
|
|
tooltip: 'Message the manager',
|
|
onPressed: () => context.push('/support'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 18),
|
|
StatusChip(
|
|
shift.status.label,
|
|
tone: switch (shift.status) {
|
|
ShiftStatus.active || ShiftStatus.booked => ChipTone.yellow,
|
|
ShiftStatus.noShow => ChipTone.danger,
|
|
_ => ChipTone.neutral,
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
shift.role,
|
|
style: const TextStyle(
|
|
fontSize: 30,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -1.1,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${shift.business} · ${Fmt.dayShort(shift.start)}',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: Colors.white.withValues(alpha: 0.62),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
StatSlabRow(
|
|
slabs: [
|
|
StatSlab(
|
|
shift.status == ShiftStatus.completed ? 'Paid' : 'Est.',
|
|
Fmt.money(shift.pay),
|
|
),
|
|
StatSlab('Rate', '${Fmt.money(shift.rate)}/hr'),
|
|
StatSlab(
|
|
'Hours',
|
|
Fmt.duration(
|
|
shift.workedDuration ?? shift.scheduledDuration,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SectionLabel('The shift'),
|
|
KrowCard(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Column(
|
|
children: [
|
|
DetailRow('Date', Fmt.dayShort(shift.start)),
|
|
const Divider(height: 1),
|
|
DetailRow(
|
|
'Scheduled',
|
|
Fmt.window(shift.start, shift.end),
|
|
),
|
|
const Divider(height: 1),
|
|
DetailRow('Location', shift.address),
|
|
const Divider(height: 1),
|
|
DetailRow('Break, unpaid', '${shift.breakMinutes} min'),
|
|
if (shift.manager != null) ...[
|
|
const Divider(height: 1),
|
|
DetailRow('Report to', shift.manager!),
|
|
],
|
|
if (shift.clockIn != null) ...[
|
|
const Divider(height: 1),
|
|
DetailRow(
|
|
'Clocked',
|
|
shift.clockOut == null
|
|
? '${Fmt.time(shift.clockIn!)} - running'
|
|
: Fmt.window(shift.clockIn!, shift.clockOut!),
|
|
valueColor: KrowPalette.blue,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SectionLabel('Pay breakdown'),
|
|
KrowCard(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Column(
|
|
children: [
|
|
DetailRow('Base rate', '${Fmt.money(shift.rate)}/hr'),
|
|
const Divider(height: 1),
|
|
DetailRow(
|
|
'Regular · '
|
|
'${Fmt.duration(shift.workedDuration ?? shift.scheduledDuration)}',
|
|
Fmt.money(shift.pay),
|
|
),
|
|
const Divider(height: 1),
|
|
DetailRow(
|
|
'Total, pre-tax',
|
|
Fmt.money(shift.pay),
|
|
emphasis: true,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SectionLabel('Requirements'),
|
|
_Requirements(shift: shift),
|
|
const SizedBox(height: 18),
|
|
_Actions(shift: shift),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// What this business asks for, checked against what is already on file.
|
|
class _Requirements extends ConsumerWidget {
|
|
const _Requirements({required this.shift});
|
|
|
|
final Shift shift;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final held = ref.watch(complianceProvider).all;
|
|
var met = 0;
|
|
final rows = <Widget>[];
|
|
|
|
for (final req in shift.requirements) {
|
|
final item = held.where((i) => i.title == req).firstOrNull;
|
|
final ok = item?.state == ComplianceState.verified;
|
|
if (ok) met++;
|
|
rows.add(
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 11),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 22,
|
|
height: 22,
|
|
decoration: BoxDecoration(
|
|
color: ok ? KrowPalette.blueTint : const Color(0xFFF7E4AE),
|
|
borderRadius: BorderRadius.circular(7),
|
|
),
|
|
child: Icon(
|
|
ok ? LucideIcons.check : LucideIcons.clock,
|
|
size: 12,
|
|
color: ok ? KrowPalette.blue : KrowPalette.ink,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
req,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
ok ? 'On file' : (item?.badge ?? 'Missing'),
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: ok ? KrowPalette.mute : KrowPalette.ink,
|
|
fontWeight: ok ? FontWeight.w400 : FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final total = shift.requirements.length;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
KrowCard(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 54,
|
|
height: 54,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
CircularProgressIndicator(
|
|
value: total == 0 ? 1 : met / total,
|
|
strokeWidth: 6,
|
|
backgroundColor: const Color(0xFFDEEBE2),
|
|
valueColor: const AlwaysStoppedAnimation(
|
|
KrowPalette.blue,
|
|
),
|
|
),
|
|
Text(
|
|
'$met/$total',
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
color: KrowPalette.blue,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
met == total
|
|
? 'You are cleared to work'
|
|
: 'Some items still missing',
|
|
style: const TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.4,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
met == total
|
|
? 'Everything ${shift.business} asks for is on file.'
|
|
: 'Clear them from the compliance hub to apply.',
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
height: 1.45,
|
|
color: KrowPalette.mute,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
KrowCard(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
child: Column(children: rows),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Actions extends ConsumerWidget {
|
|
const _Actions({required this.shift});
|
|
|
|
final Shift shift;
|
|
|
|
Future<void> _confirmCancel(BuildContext context, WidgetRef ref) async {
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Give this shift back?'),
|
|
content: const Text(
|
|
'It goes back on the open market straight away, and the '
|
|
'cancellation counts against your trust index.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('Keep it'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
|
|
child: const Text('Cancel shift'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (ok != true || !context.mounted) return;
|
|
ref.read(shiftsProvider.notifier).cancel(shift.id);
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('Shift cancelled')));
|
|
context.pop();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
switch (shift.status) {
|
|
case ShiftStatus.open:
|
|
return FilledButton.icon(
|
|
onPressed: () {
|
|
ref.read(shiftsProvider.notifier).apply(shift.id);
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('Shift booked')));
|
|
},
|
|
icon: const Icon(LucideIcons.check, size: 18),
|
|
label: const Text('Apply for this shift'),
|
|
);
|
|
case ShiftStatus.booked:
|
|
return Column(
|
|
children: [
|
|
FilledButton.icon(
|
|
onPressed: () => context.go('/clock'),
|
|
icon: const Icon(LucideIcons.clock, size: 18),
|
|
label: const Text('Go to clock in'),
|
|
),
|
|
const SizedBox(height: 10),
|
|
OutlinedButton(
|
|
onPressed: () => _confirmCancel(context, ref),
|
|
child: const Text('Cancel this shift'),
|
|
),
|
|
],
|
|
);
|
|
case ShiftStatus.active:
|
|
return FilledButton.icon(
|
|
onPressed: () => context.go('/clock'),
|
|
icon: const Icon(LucideIcons.squareCheck, size: 18),
|
|
label: const Text('You are on shift - clock out'),
|
|
);
|
|
default:
|
|
return OutlinedButton.icon(
|
|
onPressed: () => context.push('/support'),
|
|
icon: const Icon(LucideIcons.lifeBuoy, size: 18),
|
|
label: const Text('Something wrong? Get help'),
|
|
);
|
|
}
|
|
}
|
|
}
|