Initial commit: Krow Worker App
This commit is contained in:
379
lib/screens/shift_detail_screen.dart
Normal file
379
lib/screens/shift_detail_screen.dart
Normal file
@@ -0,0 +1,379 @@
|
||||
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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user