Initial commit: Krow Worker App

This commit is contained in:
R-Bharathraj
2026-09-17 22:26:11 +05:30
commit cafffd27e0
113 changed files with 13067 additions and 0 deletions

View File

@@ -0,0 +1,650 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/theme.dart';
import '../state/session_controller.dart';
import '../widgets/common.dart';
/// Step 1 of 2: the mobile number a shift manager would text.
class PhoneScreen extends ConsumerStatefulWidget {
const PhoneScreen({super.key});
@override
ConsumerState<PhoneScreen> createState() => _PhoneScreenState();
}
class _PhoneScreenState extends ConsumerState<PhoneScreen> {
final _controller = TextEditingController();
bool get _valid =>
_controller.text.replaceAll(RegExp(r'\D'), '').length >= 10;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _send() {
final phone = '+1 ${_controller.text.trim()}';
ref.read(sessionProvider.notifier).sendCode(phone);
context.push('/otp');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(leading: const BackButton()),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionLabel('Step 1 of 2'),
const Text(
"What's your\nmobile number?",
style: TextStyle(
fontSize: 29,
fontWeight: FontWeight.w700,
height: 1.1,
letterSpacing: -1,
),
),
const SizedBox(height: 10),
const Text(
'Shift managers text you here, and we will send a 6-digit code '
'to confirm it is yours.',
style: TextStyle(
fontSize: 14,
height: 1.55,
color: KrowPalette.mute,
),
),
const SizedBox(height: 24),
TextField(
controller: _controller,
autofocus: true,
keyboardType: TextInputType.phone,
onChanged: (_) => setState(() {}),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9 ]')),
LengthLimitingTextInputFormatter(14),
],
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
),
decoration: const InputDecoration(
hintText: '408 555 0148',
prefixIcon: Padding(
padding: EdgeInsets.fromLTRB(16, 15, 10, 15),
child: Text(
'+1',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
prefixIconConstraints: BoxConstraints(minWidth: 0),
),
),
const SizedBox(height: 10),
const Text(
'Message and data rates may apply. Reply STOP any time to opt '
'out.',
style: TextStyle(
fontSize: 12,
height: 1.5,
color: Color(0xFF949FA5),
),
),
const SizedBox(height: 26),
FilledButton(
onPressed: _valid ? _send : null,
child: const Text('Send code'),
),
const SizedBox(height: 10),
Center(
child: TextButton(
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Email sign-in is coming soon'),
),
),
child: const Text('Verify with email instead'),
),
),
],
),
),
),
);
}
}
/// Step 2 of 2: the code, with a resend countdown and a limited try budget.
class OtpScreen extends ConsumerStatefulWidget {
const OtpScreen({super.key});
@override
ConsumerState<OtpScreen> createState() => _OtpScreenState();
}
class _OtpScreenState extends ConsumerState<OtpScreen> {
final _controller = TextEditingController();
Timer? _timer;
int _secondsLeft = 30;
int _triesLeft = 3;
String? _error;
@override
void initState() {
super.initState();
_startCountdown();
}
@override
void dispose() {
_timer?.cancel();
_controller.dispose();
super.dispose();
}
void _startCountdown() {
_timer?.cancel();
setState(() => _secondsLeft = 30);
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
if (!mounted) return;
if (_secondsLeft <= 1) {
t.cancel();
setState(() => _secondsLeft = 0);
} else {
setState(() => _secondsLeft--);
}
});
}
void _verify() {
final ok = ref.read(sessionProvider.notifier).verifyCode(_controller.text);
if (ok) {
context.go('/verified');
return;
}
setState(() {
_triesLeft--;
_error = _triesLeft > 0
? 'That code does not match. Check the most recent text - older '
'codes stop working. $_triesLeft tries left.'
: 'Too many attempts. Request a new code to try again.';
});
}
@override
Widget build(BuildContext context) {
final phone = ref.watch(sessionProvider).phone ?? '';
final canVerify = _controller.text.length == 6 && _triesLeft > 0;
return Scaffold(
appBar: AppBar(leading: const BackButton()),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionLabel('Step 2 of 2'),
const Text(
'Enter the code',
style: TextStyle(
fontSize: 29,
fontWeight: FontWeight.w700,
letterSpacing: -1,
),
),
const SizedBox(height: 10),
Row(
children: [
Text(
'Texted to $phone',
style: const TextStyle(
fontSize: 13.5,
color: KrowPalette.mute,
),
),
TextButton(
onPressed: () => context.pop(),
child: const Text('Change'),
),
],
),
const SizedBox(height: 8),
TextField(
controller: _controller,
autofocus: true,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
onChanged: (_) => setState(() => _error = null),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.w700,
letterSpacing: 14,
),
decoration: InputDecoration(
counterText: '',
hintText: '------',
errorText: _error,
errorMaxLines: 3,
hintStyle: const TextStyle(
fontSize: 30,
letterSpacing: 14,
color: Color(0xFFC4D4C9),
),
),
),
const SizedBox(height: 14),
const InfoBanner('Demo build: the code is always 123456.'),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Didn't get the text?",
style: TextStyle(fontSize: 13, color: KrowPalette.mute),
),
if (_secondsLeft > 0)
Text(
'Resend in 0:${_secondsLeft.toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 12.5,
color: Color(0xFF949FA5),
),
)
else
TextButton(
onPressed: () {
setState(() {
_triesLeft = 3;
_error = null;
});
_startCountdown();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('New code sent')),
);
},
child: const Text('Resend code'),
),
],
),
const SizedBox(height: 18),
FilledButton(
onPressed: canVerify ? _verify : null,
child: const Text('Verify number'),
),
],
),
),
),
);
}
}
/// Confirmation, plus the two things still standing between the worker and
/// their first shift.
class VerifiedScreen extends ConsumerWidget {
const VerifiedScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final phone = ref.watch(sessionProvider).phone ?? '';
return Scaffold(
backgroundColor: KrowPalette.blue,
body: Column(
children: [
Expanded(
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 26),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 86,
height: 86,
decoration: const BoxDecoration(
color: KrowPalette.yellow,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.check,
size: 40,
color: KrowPalette.ink,
),
),
const SizedBox(height: 26),
const Text(
'Number verified',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.w700,
letterSpacing: -1,
color: Colors.white,
),
),
const SizedBox(height: 10),
Text(
phone,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: KrowPalette.yellow,
),
),
const SizedBox(height: 14),
Text(
'Two things left before you can accept shifts.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
height: 1.55,
color: Colors.white.withValues(alpha: 0.62),
),
),
const SizedBox(height: 26),
const _TodoRow(
title: 'Upload your ID',
subtitle: 'State ID or passport',
badge: '2 min',
),
const SizedBox(height: 10),
const _TodoRow(
title: 'Set your availability',
subtitle: 'So we only send shifts you can work',
badge: '1 min',
),
],
),
),
),
),
Container(
width: double.infinity,
decoration: const BoxDecoration(
color: KrowPalette.sage,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 22, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton(
onPressed: () => context.go('/permissions'),
child: const Text('Continue'),
),
],
),
),
),
),
],
),
);
}
}
class _TodoRow extends StatelessWidget {
const _TodoRow({
required this.title,
required this.subtitle,
required this.badge,
});
final String title;
final String subtitle;
final String badge;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white24),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 12.5,
color: Colors.white.withValues(alpha: 0.55),
),
),
],
),
),
StatusChip(badge, tone: ChipTone.yellow),
],
),
);
}
}
class _Permission {
const _Permission(this.icon, this.step, this.title, this.body, this.reasons);
final IconData icon;
final String step;
final String title;
final String body;
final List<String> reasons;
}
/// The three-step permission run. Each grant is recorded in the session so the
/// app knows what it is allowed to do later.
class PermissionsScreen extends ConsumerStatefulWidget {
const PermissionsScreen({super.key});
@override
ConsumerState<PermissionsScreen> createState() => _PermissionsScreenState();
}
class _PermissionsScreenState extends ConsumerState<PermissionsScreen> {
int _step = 0;
static const _permissions = [
_Permission(
LucideIcons.mapPin,
'Step 1 of 3',
'Let KROW see where you are',
'Clock-in is locked until you are within 150 m of the venue. Without '
'location you cannot start a shift.',
[
'Unlocks clock in when you arrive',
'Sorts shifts by distance from you',
'Only read while a shift is active',
],
),
_Permission(
LucideIcons.bell,
'Step 2 of 3',
'Turn on notifications',
'Shift offers are first-come. If your phone is silent someone else takes '
'the shift.',
[
'New shift offers matched to you',
'Clock-in reminders 30 min before',
'Pay alerts when money lands',
],
),
_Permission(
LucideIcons.camera,
'Step 3 of 3',
'Camera and photos',
'Attire and document checks need a photo of the real item. Nothing is '
'uploaded until you tap send.',
[
'Photograph your uniform items',
'Capture ID and certificates',
'Nothing is stored on our servers unsent',
],
),
];
void _advance({required bool granted}) {
final notifier = ref.read(sessionProvider.notifier);
if (granted) {
switch (_step) {
case 0:
notifier.grantLocation();
case 1:
notifier.grantNotifications();
case 2:
notifier.grantCamera();
}
}
if (_step == _permissions.length - 1) {
context.go('/today');
} else {
setState(() => _step++);
}
}
@override
Widget build(BuildContext context) {
final p = _permissions[_step];
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 24, 22, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// The copy scrolls if it has to; the actions stay pinned, so a
// short screen or a large text scale can never push them off.
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: KrowPalette.blue,
borderRadius: BorderRadius.circular(18),
),
child: Icon(p.icon, size: 27, color: Colors.white),
),
SectionLabel(p.step),
Text(
p.title,
style: const TextStyle(
fontSize: 25,
fontWeight: FontWeight.w700,
height: 1.15,
letterSpacing: -0.9,
),
),
const SizedBox(height: 10),
Text(
p.body,
style: const TextStyle(
fontSize: 14,
height: 1.55,
color: KrowPalette.mute,
),
),
const SizedBox(height: 18),
for (final reason in p.reasons)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 20,
height: 20,
// Nudged to sit on the first line's optical
// centre, so wrapped reasons stay top-aligned.
margin: const EdgeInsets.only(top: 1),
decoration: BoxDecoration(
color: KrowPalette.blueTint,
borderRadius: BorderRadius.circular(7),
),
child: const Icon(
LucideIcons.check,
size: 12,
color: KrowPalette.blue,
),
),
const SizedBox(width: 11),
Expanded(
child: Text(
reason,
style: const TextStyle(
fontSize: 13,
height: 1.45,
color: KrowPalette.mute,
),
),
),
],
),
),
],
),
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => _advance(granted: true),
child: Text(switch (_step) {
0 => 'Allow location',
1 => 'Turn on notifications',
_ => 'Allow camera',
}),
),
const SizedBox(height: 4),
// Full width so the secondary action shares the primary's edges
// rather than hugging the left.
TextButton(
onPressed: () => _advance(granted: false),
style: TextButton.styleFrom(
minimumSize: const Size.fromHeight(44),
),
child: const Text('Not now'),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,337 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/formatters.dart';
import '../core/theme.dart';
import '../models/availability.dart';
import '../state/availability_controller.dart';
import '../widgets/common.dart';
import '../widgets/week_strip.dart';
/// Auto-Match only offers shifts inside the blocks switched on here.
class AvailabilityScreen extends ConsumerStatefulWidget {
const AvailabilityScreen({super.key});
@override
ConsumerState<AvailabilityScreen> createState() => _AvailabilityScreenState();
}
class _AvailabilityScreenState extends ConsumerState<AvailabilityScreen> {
DateTime _selected = DateTime.now();
List<DateTime> get _week {
final now = DateTime.now();
return [for (var i = 0; i < 7; i++) now.add(Duration(days: i))];
}
@override
Widget build(BuildContext context) {
final controller = ref.watch(availabilityProvider.notifier);
ref.watch(availabilityProvider);
final blocks = controller.blocksFor(_selected);
final hours = blocks.fold<int>(0, (sum, b) => sum + b.hours);
final marked = <int>{
for (var i = 0; i < _week.length; i++)
if (controller.blocksFor(_week[i]).isNotEmpty) i,
};
return Scaffold(
body: ListView(
padding: EdgeInsets.zero,
children: [
BrandHeader(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
HeaderIconButton(
icon: LucideIcons.arrowLeft,
onPressed: () => Navigator.of(context).pop(),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Availability',
style: TextStyle(
fontSize: 25,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
color: Colors.white,
),
),
),
],
),
const SizedBox(height: 18),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_Preset(
label: 'All week',
onTap: () =>
controller.applyPreset(_week, test: (_) => true),
),
_Preset(
label: 'Weekdays',
onTap: () => controller.applyPreset(
_week,
test: (d) => d.weekday <= DateTime.friday,
),
),
_Preset(
label: 'Weekends',
onTap: () => controller.applyPreset(
_week,
test: (d) => d.weekday >= DateTime.saturday,
),
),
_Preset(
label: 'Clear',
accent: true,
onTap: () => controller.clearWeek(_week),
),
],
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
WeekStrip(
days: _week,
selected: _selected,
onSelect: (d) => setState(() => _selected = d),
marked: marked,
),
const SizedBox(height: 16),
KrowCard(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Fmt.dayShort(_selected),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
const SizedBox(height: 2),
Text(
blocks.isEmpty
? 'Marked as a day off'
: 'Available · $hours hrs',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w500,
color: blocks.isEmpty
? KrowPalette.mute
: KrowPalette.blue,
),
),
],
),
),
Switch(
value: blocks.isNotEmpty,
onChanged: (v) =>
controller.setDayAvailable(_selected, v),
),
],
),
const SizedBox(height: 14),
for (final block in DayBlock.values)
Padding(
padding: const EdgeInsets.only(bottom: 9),
child: _BlockRow(
block: block,
on: blocks.contains(block),
onTap: () =>
controller.toggleBlock(_selected, block),
),
),
],
),
),
const SizedBox(height: 14),
InfoBanner(
blocks.isEmpty
? 'With every block off, Auto-Match will not send you '
'anything on this day.'
: 'Saved. Auto-Match will only offer you shifts inside '
'these blocks.',
icon: blocks.isEmpty ? null : LucideIcons.check,
),
],
),
),
],
),
);
}
}
class _Preset extends StatelessWidget {
const _Preset({
required this.label,
required this.onTap,
this.accent = false,
});
final String label;
final VoidCallback onTap;
final bool accent;
@override
Widget build(BuildContext context) {
// Built by hand rather than as a Chip: the app's ChipTheme paints a white
// fill, which would hide white-on-blue header text.
return Material(
color: Colors.white.withValues(alpha: accent ? 0.06 : 0.16),
borderRadius: BorderRadius.circular(9),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(9),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
child: Text(
label,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: accent ? KrowPalette.yellow : Colors.white,
),
),
),
),
);
}
}
class _BlockRow extends StatelessWidget {
const _BlockRow({required this.block, required this.on, required this.onTap});
final DayBlock block;
final bool on;
final VoidCallback onTap;
IconData get _icon => switch (block) {
DayBlock.morning => LucideIcons.sunrise,
DayBlock.afternoon => LucideIcons.sun,
DayBlock.evening => LucideIcons.moon,
};
@override
Widget build(BuildContext context) {
const duration = Duration(milliseconds: 260);
const curve = Curves.easeOutCubic;
return GestureDetector(
onTap: onTap,
// Every part of the row animates together, so switching a block on is a
// single motion rather than four things changing at once.
child: AnimatedContainer(
duration: duration,
curve: curve,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: on ? KrowPalette.blueTint : const Color(0xFFE5F0E8),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: on
? KrowPalette.blue.withValues(alpha: 0.3)
: Colors.transparent,
),
),
child: Row(
children: [
AnimatedContainer(
duration: duration,
curve: curve,
width: 38,
height: 38,
decoration: BoxDecoration(
color: on ? KrowPalette.blue : Colors.white,
borderRadius: BorderRadius.circular(11),
border: Border.all(
color: on ? KrowPalette.blue : KrowPalette.line,
),
),
child: Icon(
_icon,
size: 18,
color: on ? Colors.white : const Color(0xFF949FA5),
),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedDefaultTextStyle(
duration: duration,
curve: curve,
style: TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: on ? KrowPalette.ink : KrowPalette.mute,
),
child: Text(block.label),
),
AnimatedDefaultTextStyle(
duration: duration,
curve: curve,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: on ? KrowPalette.blue : const Color(0xFF949FA5),
),
child: Text(block.window),
),
],
),
),
AnimatedContainer(
duration: duration,
curve: curve,
width: 24,
height: 24,
decoration: BoxDecoration(
color: on ? KrowPalette.blue : Colors.transparent,
borderRadius: BorderRadius.circular(7),
border: Border.all(
color: on ? KrowPalette.blue : const Color(0xFFC4D4C9),
width: 1.5,
),
),
child: AnimatedScale(
scale: on ? 1 : 0,
duration: duration,
curve: Curves.easeOutBack,
child: const Icon(
LucideIcons.check,
size: 14,
color: Colors.white,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,397 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/shift.dart';
import '../state/session_controller.dart';
import '../state/shifts_controller.dart';
import '../widgets/collapsing_header.dart';
import '../widgets/common.dart';
import '../widgets/shift_card.dart';
/// Clock in and out. The button unlocks 30 minutes before the shift starts,
/// and only once the worker is at the venue.
class ClockScreen extends ConsumerStatefulWidget {
const ClockScreen({super.key});
@override
ConsumerState<ClockScreen> createState() => _ClockScreenState();
}
class _ClockScreenState extends ConsumerState<ClockScreen> {
Timer? _ticker;
/// Stands in for the geofence check while there is no GPS plugin wired up.
bool _atVenue = false;
/// Last minute we repainted for. Off shift, only the countdown moves, so
/// rebuilding every second would just churn the tree — and a rebuild landing
/// between a tap's down and up swallows it.
int _lastMinute = -1;
@override
void initState() {
super.initState();
// Drives the countdown and the running stopwatch.
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
final running = ref.read(activeShiftProvider) != null;
final minute = DateTime.now().minute;
if (running || minute != _lastMinute) {
_lastMinute = minute;
setState(() {});
}
});
}
@override
void dispose() {
_ticker?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final active = ref.watch(activeShiftProvider);
final next = ref.watch(nextShiftProvider);
final shift = active ?? next;
final locationGranted = ref.watch(sessionProvider).locationGranted;
return Scaffold(
body: CustomScrollView(
slivers: [
// Already compact, so it pins at one height rather than collapsing.
// Sized with headroom over the 58px of content, so a larger system
// text scale does not push it over.
KrowSliverHeader(
expandedHeight: 96,
collapsedHeight: 96,
builder: (context, t) => Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Fmt.dayFull(DateTime.now()).toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.5,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5),
),
),
const SizedBox(height: 4),
const Text(
'Clock in',
style: TextStyle(
fontSize: 27,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
color: Colors.white,
),
),
],
),
),
HeaderIconButton(
icon: LucideIcons.fileText,
tooltip: 'Timecards',
onPressed: () => context.push('/timecards'),
),
],
),
),
),
if (shift == null)
const SliverPadding(
padding: EdgeInsets.fromLTRB(20, 40, 20, 20),
sliver: SliverToBoxAdapter(
child: EmptyState(
icon: LucideIcons.clock,
title: 'No shift to clock into',
message:
'Book a shift and the clock unlocks 30 minutes before '
'it starts.',
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(20, 28, 20, 24),
sliver: SliverList.list(
children: [
_ClockDial(shift: shift, atVenue: _atVenue),
const SizedBox(height: 22),
if (shift.status == ShiftStatus.active)
FilledButton.icon(
onPressed: () => _clockOut(shift),
style: FilledButton.styleFrom(
backgroundColor: KrowPalette.ink,
),
icon: const Icon(LucideIcons.logOut, size: 18),
label: const Text('Clock out'),
)
else
FilledButton.icon(
onPressed: _canClockIn(shift, locationGranted)
? () => _clockIn(shift)
: null,
icon: const Icon(LucideIcons.logIn, size: 18),
label: Text(
_canClockIn(shift, locationGranted)
? 'Clock in'
: 'Clock in · ${Fmt.untilStart(shift.start)}',
),
),
const SizedBox(height: 12),
_GeofenceCard(
shift: shift,
atVenue: _atVenue,
locationGranted: locationGranted,
onChanged: (v) => setState(() => _atVenue = v),
),
const SectionLabel("Today's shift"),
ShiftCard(
shift: shift,
onTap: () => context.push('/shift/${shift.id}'),
),
const SizedBox(height: 14),
OutlinedButton.icon(
onPressed: () => context.push('/timecards'),
icon: const Icon(LucideIcons.fileText, size: 18),
label: const Text('View timecards'),
),
],
),
),
],
),
);
}
bool _canClockIn(Shift shift, bool locationGranted) {
if (shift.status != ShiftStatus.booked) return false;
if (!_atVenue) return false;
final minutesToStart = shift.start.difference(DateTime.now()).inMinutes;
return minutesToStart <= 30;
}
void _clockIn(Shift shift) {
// Starting and ending a shift are the two moments that matter most in the
// app; both get a firm confirmation you can feel without looking.
HapticFeedback.mediumImpact();
ref.read(shiftsProvider.notifier).clockIn(shift.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Clocked in at ${Fmt.time(DateTime.now())}')),
);
}
Future<void> _clockOut(Shift shift) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clock out?'),
content: Text(
'Your timecard is filed automatically and sent to '
'${shift.business} for approval.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Not yet'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
child: const Text('Clock out'),
),
],
),
);
if (confirm != true || !mounted) return;
HapticFeedback.mediumImpact();
final card = ref.read(shiftsProvider.notifier).clockOut(shift.id);
if (card == null || !mounted) return;
setState(() => _atVenue = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Timecard filed · ${Fmt.money(card.amount)}'),
action: SnackBarAction(
label: 'Review',
textColor: KrowPalette.yellow,
onPressed: () => context.push('/timecard/${card.id}'),
),
),
);
}
}
/// The big circular status dial: a countdown before the shift, a running
/// stopwatch during it.
class _ClockDial extends StatelessWidget {
const _ClockDial({required this.shift, required this.atVenue});
final Shift shift;
final bool atVenue;
@override
Widget build(BuildContext context) {
final running = shift.status == ShiftStatus.active;
final elapsed = running
? DateTime.now().difference(shift.clockIn!)
: Duration.zero;
final progress = running
? (elapsed.inSeconds / shift.end.difference(shift.start).inSeconds)
.clamp(0.0, 1.0)
: 0.0;
return Center(
child: SizedBox(
width: 208,
height: 208,
child: Stack(
alignment: Alignment.center,
children: [
// The ring fills from zero when the shift starts, and the track
// warms from sage to yellow as it does.
TweenAnimationBuilder<double>(
tween: Tween(end: running ? progress : 0),
duration: const Duration(milliseconds: 700),
curve: Curves.easeOutCubic,
builder: (context, value, _) => SizedBox(
width: 208,
height: 208,
child: CircularProgressIndicator(
value: running ? value : 1,
strokeWidth: 12,
strokeCap: StrokeCap.round,
backgroundColor: const Color(0xFFDEEBE2),
valueColor: AlwaysStoppedAnimation(
running ? KrowPalette.yellow : const Color(0xFFDEEBE2),
),
),
),
),
AnimatedScale(
// A small swell on clock-in: the dial acknowledges the tap.
scale: running ? 1 : 0.96,
duration: const Duration(milliseconds: 420),
curve: Curves.easeOutBack,
child: Container(
width: 176,
height: 176,
decoration: const BoxDecoration(
color: KrowPalette.blue,
shape: BoxShape.circle,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
// Only the label swaps; the ticking digits must not
// re-animate every second.
child: Text(
running ? Fmt.stopwatch(elapsed) : 'Clock in',
key: ValueKey(running),
style: TextStyle(
fontSize: running ? 27 : 23,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
color: Colors.white,
),
),
),
const SizedBox(height: 6),
Text(
running
? 'ON SHIFT SINCE ${Fmt.time(shift.clockIn!)}'
: Fmt.untilStart(shift.start).toUpperCase(),
style: const TextStyle(
fontSize: 10,
letterSpacing: 1.3,
fontWeight: FontWeight.w600,
color: KrowPalette.yellow,
),
),
],
),
),
),
],
),
),
);
}
}
/// Shows why the clock is locked, and lets the demo simulate arriving on site.
class _GeofenceCard extends StatelessWidget {
const _GeofenceCard({
required this.shift,
required this.atVenue,
required this.locationGranted,
required this.onChanged,
});
final Shift shift;
final bool atVenue;
final bool locationGranted;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
if (shift.status == ShiftStatus.active) {
return InfoBanner(
'Break of ${shift.breakMinutes} minutes is deducted automatically when '
'you clock out.',
);
}
return KrowCard(
padding: const EdgeInsets.fromLTRB(16, 8, 12, 8),
child: Row(
children: [
const Icon(LucideIcons.mapPin, size: 18, color: KrowPalette.blue),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
atVenue
? 'At the venue'
: 'You are ${shift.distanceMiles} mi away',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
Text(
locationGranted
? 'Get within 150 m to unlock the button'
: 'Location is off - turn it on in Settings',
style: const TextStyle(fontSize: 12, color: KrowPalette.mute),
),
],
),
),
Switch(value: atVenue, onChanged: onChanged),
],
),
);
}
}

View File

@@ -0,0 +1,565 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/theme.dart';
import '../models/compliance.dart';
import '../state/compliance_controller.dart';
import '../widgets/common.dart';
IconData _sectionIcon(ComplianceSection s) => switch (s) {
ComplianceSection.documents => LucideIcons.fileText,
ComplianceSection.certificates => LucideIcons.award,
ComplianceSection.taxForms => LucideIcons.receipt,
ComplianceSection.wardrobe => LucideIcons.shirt,
};
ChipTone _toneFor(ComplianceState state) => switch (state) {
ComplianceState.verified => ChipTone.neutral,
ComplianceState.pending => ChipTone.blue,
ComplianceState.missing => ChipTone.yellow,
};
/// Everything a business must see before you can work.
class ComplianceHubScreen extends ConsumerWidget {
const ComplianceHubScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final (done, total) = ref.watch(complianceProgressProvider);
final remaining = total - done;
return Scaffold(
body: ListView(
padding: EdgeInsets.zero,
children: [
BrandHeader(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
HeaderIconButton(
icon: LucideIcons.arrowLeft,
onPressed: () => context.pop(),
),
StatusChip(
remaining == 0 ? 'All clear' : '$remaining left',
tone: remaining == 0 ? ChipTone.neutral : ChipTone.yellow,
),
],
),
const SizedBox(height: 18),
const Text(
'Compliance',
style: TextStyle(
fontSize: 27,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
color: Colors.white,
),
),
const SizedBox(height: 5),
Text(
'Everything a business must see before you work.',
style: TextStyle(
fontSize: 13.5,
color: Colors.white.withValues(alpha: 0.6),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
KrowCard(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Overall',
style: TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
),
),
Text(
'$done of $total',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 11),
KrowProgressBar(
total == 0 ? 1 : done / total,
color: done == total
? KrowPalette.blue
: KrowPalette.yellow,
),
],
),
),
const SectionLabel('Sections'),
TileGroup(
children: [
for (final section in ComplianceSection.values)
Consumer(
builder: (context, ref, _) {
final (d, t) = ref.watch(
sectionProgressProvider(section),
);
return NavTile(
icon: _sectionIcon(section),
title: section.title,
subtitle: section.subtitle,
badge: StatusChip(
'$d of $t',
tone: d == t ? ChipTone.neutral : ChipTone.yellow,
),
onTap: () =>
context.push('/compliance/${section.name}'),
);
},
),
],
),
const SizedBox(height: 16),
const InfoBanner(
'Documents are shared with a business only when you accept '
'one of their shifts.',
),
],
),
),
],
),
);
}
}
/// One compliance section: documents, certificates, tax forms or attire.
class ComplianceListScreen extends ConsumerStatefulWidget {
const ComplianceListScreen({super.key, required this.section});
final ComplianceSection section;
@override
ConsumerState<ComplianceListScreen> createState() =>
_ComplianceListScreenState();
}
class _ComplianceListScreenState extends ConsumerState<ComplianceListScreen> {
String _filter = 'All';
String _query = '';
static const _filters = ['All', 'Verified', 'Pending', 'Missing'];
@override
Widget build(BuildContext context) {
var items = ref.watch(complianceProvider).section(widget.section);
final (done, total) = ref.watch(sectionProgressProvider(widget.section));
if (_query.isNotEmpty) {
final q = _query.toLowerCase();
items = items.where((i) => i.title.toLowerCase().contains(q)).toList();
}
items = switch (_filter) {
'Verified' =>
items.where((i) => i.state == ComplianceState.verified).toList(),
'Pending' =>
items.where((i) => i.state == ComplianceState.pending).toList(),
'Missing' =>
items.where((i) => i.state == ComplianceState.missing).toList(),
_ => items,
};
final required = items.where((i) => i.required_).toList();
final optional = items.where((i) => !i.required_).toList();
return Scaffold(
appBar: AppBar(
title: Text(widget.section.title),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: StatusChip(
'$done of $total',
tone: done == total ? ChipTone.neutral : ChipTone.yellow,
),
),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
TextField(
onChanged: (v) => setState(() => _query = v),
decoration: InputDecoration(
hintText: 'Search ${widget.section.title.toLowerCase()}',
prefixIcon: const Icon(LucideIcons.search, size: 18),
),
),
const SizedBox(height: 14),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final f in _filters)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(f),
selected: f == _filter,
showCheckmark: false,
selectedColor: KrowPalette.blue,
labelStyle: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w500,
color: f == _filter ? Colors.white : KrowPalette.mute,
),
onSelected: (_) => setState(() => _filter = f),
),
),
],
),
),
if (required.isNotEmpty) ...[
const SectionLabel('Required'),
TileGroup(
children: [
for (final item in required)
_ItemTile(section: widget.section, item: item),
],
),
],
if (optional.isNotEmpty) ...[
const SectionLabel('Unlocks more shifts'),
TileGroup(
children: [
for (final item in optional)
_ItemTile(section: widget.section, item: item),
],
),
],
if (required.isEmpty && optional.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 24),
child: EmptyState(
icon: LucideIcons.search,
title: 'Nothing here',
message: 'Try a different filter.',
),
),
],
),
);
}
}
class _ItemTile extends StatelessWidget {
const _ItemTile({required this.section, required this.item});
final ComplianceSection section;
final ComplianceItem item;
@override
Widget build(BuildContext context) {
return NavTile(
icon: _sectionIcon(section),
title: item.title,
subtitle: item.subtitle,
badge: StatusChip(item.badge, tone: _toneFor(item.state)),
onTap: () => context.push('/compliance/${section.name}/${item.id}'),
);
}
}
/// A single item: the file on record, its expiry, and the actions on it.
class ComplianceDetailScreen extends ConsumerWidget {
const ComplianceDetailScreen({
super.key,
required this.section,
required this.itemId,
});
final ComplianceSection section;
final String itemId;
Future<void> _upload(BuildContext context, WidgetRef ref) async {
final choice = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.camera),
title: const Text('Take a photo'),
subtitle: const Text('It has to be the real item, worn by you'),
onTap: () => Navigator.pop(context, 'camera'),
),
ListTile(
leading: const Icon(LucideIcons.image),
title: const Text('Choose from library'),
onTap: () => Navigator.pop(context, 'library'),
),
ListTile(
leading: const Icon(LucideIcons.file),
title: const Text('Pick a PDF'),
onTap: () => Navigator.pop(context, 'file'),
),
],
),
),
);
if (choice == null || !context.mounted) return;
final ext = choice == 'file' ? 'pdf' : 'jpg';
final slug = itemId.toLowerCase();
ref
.read(complianceProvider.notifier)
.uploadFile(section, itemId, 'krow-$slug.$ext');
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Uploaded - sent for review')));
}
Future<void> _remove(BuildContext context, WidgetRef ref) async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove this file?'),
content: const Text(
'Your account goes on hold for anything that needs it until a '
'replacement is verified.',
),
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('Remove'),
),
],
),
);
if (ok != true || !context.mounted) return;
ref.read(complianceProvider.notifier).removeFile(section, itemId);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('File removed')));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(complianceProvider);
final item = ref.read(complianceProvider.notifier).find(section, itemId);
if (item == null) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: Text('Item not found.')),
);
}
final d = DateFormat('d MMM yyyy');
return Scaffold(
appBar: AppBar(
title: Text(section.title),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: StatusChip(item.badge, tone: _toneFor(item.state)),
),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
Text(
item.title,
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
),
),
const SizedBox(height: 6),
Text(
item.subtitle,
style: const TextStyle(fontSize: 13.5, color: KrowPalette.mute),
),
if (section == ComplianceSection.wardrobe) ...[
const SizedBox(height: 16),
const InfoBanner(
'The photo has to be you wearing the item. Catalogue shots and '
'images pulled from the web get rejected.',
warning: true,
),
],
const SectionLabel('File on record'),
if (item.fileName == null)
EmptyState(
icon: LucideIcons.upload,
title: 'Nothing uploaded',
message: 'JPEG, PNG or PDF, up to 10 MB.',
action: FilledButton.icon(
onPressed: () => _upload(context, ref),
icon: const Icon(LucideIcons.plus, size: 18),
label: const Text('Add a file'),
),
)
else
KrowCard(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFDEEBE2),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
LucideIcons.fileText,
size: 20,
color: Color(0xFF949FA5),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.fileName!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3),
Text(
item.fileMeta ?? '',
style: const TextStyle(
fontSize: 11.5,
color: Color(0xFF949FA5),
),
),
],
),
),
IconButton(
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Preview opens the file')),
),
icon: const Icon(LucideIcons.eye, size: 18),
),
],
),
),
const SectionLabel('Details'),
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
DetailRow('Status', item.badge),
const Divider(height: 1),
_ExpiryRow(section: section, item: item, format: d),
const Divider(height: 1),
const DetailRow('Shared with', '2 businesses'),
],
),
),
const SizedBox(height: 16),
InfoBanner(
item.required_
? 'Removing this puts your account on hold until a new file is '
'verified. You cannot work in the meantime.'
: 'Optional, but adding it opens up more shifts near you.',
),
const SizedBox(height: 22),
Row(
children: [
if (item.fileName != null) ...[
Expanded(
child: OutlinedButton.icon(
onPressed: () => _remove(context, ref),
icon: const Icon(LucideIcons.trash2, size: 18),
label: const Text('Remove'),
),
),
const SizedBox(width: 10),
],
Expanded(
child: FilledButton.icon(
onPressed: () => _upload(context, ref),
icon: const Icon(LucideIcons.camera, size: 18),
label: Text(item.fileName == null ? 'Upload' : 'Replace'),
),
),
],
),
],
),
);
}
}
class _ExpiryRow extends ConsumerWidget {
const _ExpiryRow({
required this.section,
required this.item,
required this.format,
});
final ComplianceSection section;
final ComplianceItem item;
final DateFormat format;
@override
Widget build(BuildContext context, WidgetRef ref) {
return InkWell(
onTap: () async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: item.expires ?? now.add(const Duration(days: 365)),
firstDate: now,
lastDate: DateTime(now.year + 20),
);
if (picked == null) return;
ref
.read(complianceProvider.notifier)
.setExpiry(section, item.id, picked);
},
child: DetailRow(
'Expires',
item.expires == null ? 'Tap to set' : format.format(item.expires!),
valueColor: item.expires == null ? KrowPalette.blue : null,
),
);
}
}

107
lib/screens/home_shell.dart Normal file
View File

@@ -0,0 +1,107 @@
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 '../state/timecards_controller.dart';
import '../widgets/krow_nav_bar.dart';
/// The five primary tabs. Each keeps its own navigation stack.
class HomeShell extends ConsumerWidget {
const HomeShell({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context, WidgetRef ref) {
final needsReview = ref.watch(timecardsNeedingReviewProvider).isNotEmpty;
return Scaffold(
body: _TabTransition(
index: navigationShell.currentIndex,
child: navigationShell,
),
bottomNavigationBar: KrowNavBar(
currentIndex: navigationShell.currentIndex,
onSelect: (i) => navigationShell.goBranch(
i,
initialLocation: i == navigationShell.currentIndex,
),
items: [
const KrowNavItem(icon: LucideIcons.house, label: 'Today'),
const KrowNavItem(icon: LucideIcons.briefcase, label: 'Shifts'),
KrowNavItem(
icon: LucideIcons.clock,
label: 'Clock',
showDot: needsReview,
),
const KrowNavItem(icon: LucideIcons.wallet, label: 'Pay'),
const KrowNavItem(icon: LucideIcons.user, label: 'Profile'),
],
),
);
}
}
/// Fades and lifts the tab content each time the tab changes.
///
/// Deliberately not an AnimatedSwitcher: that would hold two copies of the
/// shell in the tree at once, and the branch navigators are keyed by
/// GlobalKey, so duplicating them would throw. This keeps one subtree — the
/// branches stay alive and keep their scroll positions — and animates it in
/// place when the index changes.
class _TabTransition extends StatefulWidget {
const _TabTransition({required this.index, required this.child});
final int index;
final Widget child;
@override
State<_TabTransition> createState() => _TabTransitionState();
}
class _TabTransitionState extends State<_TabTransition>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 260),
value: 1,
);
late final Animation<double> _curve = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
);
@override
void didUpdateWidget(_TabTransition oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.index != widget.index) _controller.forward(from: 0);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _curve,
// The shell is passed through untouched so it is never rebuilt.
child: widget.child,
builder: (context, child) {
final t = _curve.value;
return Opacity(
// Never fully transparent: a tab that blinks out reads as a glitch.
opacity: 0.35 + 0.65 * t,
child: Transform.translate(
offset: Offset(0, (1 - t) * 10),
child: child,
),
);
},
);
}
}

View File

@@ -0,0 +1,448 @@
import 'dart:async';
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/theme.dart';
import '../state/session_controller.dart';
/// Brand wordmark. Drawn rather than shipped as an asset so it tints cleanly
/// on either ground.
class KrowWordmark extends StatelessWidget {
const KrowWordmark({super.key, this.size = 34, this.color});
final double size;
final Color? color;
@override
Widget build(BuildContext context) {
return Text(
'KROW',
style: TextStyle(
fontSize: size,
fontWeight: FontWeight.w800,
letterSpacing: size * 0.06,
color: color ?? KrowPalette.yellow,
),
);
}
}
/// Splash. Routes on to onboarding, sign-in or the app depending on how far
/// the worker got last time.
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@override
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends ConsumerState<SplashScreen> {
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer(const Duration(milliseconds: 1400), _go);
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _go() {
if (!mounted) return;
final s = ref.read(sessionProvider);
if (!s.onboardingSeen) {
context.go('/onboarding');
} else if (!s.signedIn) {
context.go('/welcome');
} else if (!s.permissionsDone) {
context.go('/permissions');
} else {
context.go('/today');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: KrowPalette.blue,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const KrowWordmark(size: 46),
const SizedBox(height: 22),
Text(
'WORK · HIRE · ELEVATE',
style: TextStyle(
fontSize: 10.5,
letterSpacing: 3,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.6),
),
),
const SizedBox(height: 44),
SizedBox(
width: 140,
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
minHeight: 3,
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation(KrowPalette.yellow),
),
),
),
],
),
),
);
}
}
class _Slide {
const _Slide(this.eyebrow, this.title, this.body, this.icon);
final String eyebrow;
final String title;
final String body;
final IconData icon;
}
const _slides = [
_Slide(
'Find work',
'Shifts that fit around your week.',
'Browse open hospitality shifts near you, filtered to the hours you said '
'you can work.',
LucideIcons.search,
),
_Slide(
'Know your pay',
'See the money before you say yes.',
'Every shift shows the hourly rate and the total. No surprises on the '
'payslip.',
LucideIcons.banknote,
),
_Slide(
'Get paid',
'Clock in on site. Paid on schedule.',
'Tap in when you arrive, tap out when you leave. Timecards submit '
'themselves.',
LucideIcons.clock,
),
];
class OnboardingScreen extends ConsumerStatefulWidget {
const OnboardingScreen({super.key});
@override
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
final _controller = PageController();
int _page = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _finish() {
ref.read(sessionProvider.notifier).completeOnboarding();
context.go('/welcome');
}
@override
Widget build(BuildContext context) {
final isLast = _page == _slides.length - 1;
return Scaffold(
backgroundColor: KrowPalette.blue,
body: Column(
children: [
SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 8, 12, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const KrowWordmark(size: 20),
TextButton(
onPressed: _finish,
style: TextButton.styleFrom(
foregroundColor: Colors.white70,
),
child: const Text('Skip'),
),
],
),
),
),
Expanded(
child: PageView.builder(
controller: _controller,
itemCount: _slides.length,
onPageChanged: (i) => setState(() => _page = i),
itemBuilder: (context, i) {
final slide = _slides[i];
return Center(
child: Container(
width: 190,
height: 190,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Icon(
slide.icon,
size: 76,
color: KrowPalette.yellow,
),
),
);
},
),
),
Container(
width: double.infinity,
decoration: const BoxDecoration(
color: KrowPalette.sage,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 26, 24, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_slides[_page].eyebrow.toUpperCase(),
style: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w600,
letterSpacing: 1.6,
color: Color(0xFF949FA5),
),
),
const SizedBox(height: 10),
Text(
_slides[_page].title,
style: const TextStyle(
fontSize: 29,
fontWeight: FontWeight.w700,
height: 1.1,
letterSpacing: -1,
),
),
const SizedBox(height: 12),
Text(
_slides[_page].body,
style: const TextStyle(
fontSize: 14.5,
height: 1.55,
color: KrowPalette.mute,
),
),
const SizedBox(height: 22),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < _slides.length; i++)
AnimatedContainer(
duration: const Duration(milliseconds: 220),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: i == _page ? 24 : 6,
height: 6,
decoration: BoxDecoration(
color: i == _page
? KrowPalette.blue
: const Color(0xFFC6D6CC),
borderRadius: BorderRadius.circular(3),
),
),
],
),
const SizedBox(height: 18),
FilledButton(
onPressed: () {
if (isLast) {
_finish();
} else {
_controller.nextPage(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOut,
);
}
},
child: Text(isLast ? 'Create your account' : 'Next'),
),
if (isLast)
Center(
child: TextButton(
onPressed: _finish,
child: const Text('I already have an account'),
),
),
],
),
),
),
),
],
),
);
}
}
class WelcomeScreen extends StatelessWidget {
const WelcomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: KrowPalette.blue,
body: Column(
children: [
Expanded(
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 30),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const KrowWordmark(size: 40),
const SizedBox(height: 34),
const Text(
'Work the shifts\nyou actually want.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 32,
height: 1.1,
letterSpacing: -1.1,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 14),
Text(
'Hospitality work in your city, priced up front and paid '
'on a fixed schedule.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.5,
height: 1.55,
color: Colors.white.withValues(alpha: 0.64),
),
),
const SizedBox(height: 34),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_WelcomeStat('4,200', 'Shifts this month'),
Container(
width: 1,
height: 38,
margin: const EdgeInsets.symmetric(horizontal: 26),
color: Colors.white24,
),
_WelcomeStat(r'$21+', 'Average rate'),
],
),
],
),
),
),
),
Container(
width: double.infinity,
decoration: const BoxDecoration(
color: KrowPalette.sage,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 24, 22, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton(
onPressed: () => context.push('/phone'),
child: const Text('Continue with mobile'),
),
const SizedBox(height: 10),
OutlinedButton(
onPressed: () =>
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Invite codes open in the next release',
),
),
),
child: const Text('I have an invite code'),
),
const SizedBox(height: 12),
const Text(
'By continuing you accept the Worker Terms.',
style: TextStyle(
fontSize: 11.5,
color: Color(0xFF949FA5),
),
),
],
),
),
),
),
],
),
);
}
}
class _WelcomeStat extends StatelessWidget {
const _WelcomeStat(this.value, this.caption);
final String value;
final String caption;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
value,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: -0.8,
color: KrowPalette.yellow,
),
),
const SizedBox(height: 3),
Text(
caption.toUpperCase(),
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.1,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.55),
),
),
],
);
}
}

View File

@@ -0,0 +1,620 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/formatters.dart';
import '../core/theme.dart';
import '../models/shift.dart';
import '../state/profile_controller.dart';
import '../state/shifts_controller.dart';
import '../widgets/collapsing_header.dart';
import '../widgets/common.dart';
import '../widgets/glass_segments.dart';
enum PayRange { week, month, year, all }
extension on PayRange {
String get label => switch (this) {
PayRange.week => 'Week',
PayRange.month => 'Month',
PayRange.year => 'Year',
PayRange.all => 'All',
};
Duration get window => switch (this) {
PayRange.week => const Duration(days: 7),
PayRange.month => const Duration(days: 30),
PayRange.year => const Duration(days: 365),
PayRange.all => const Duration(days: 3650),
};
}
class PayScreen extends ConsumerStatefulWidget {
const PayScreen({super.key});
@override
ConsumerState<PayScreen> createState() => _PayScreenState();
}
class _PayScreenState extends ConsumerState<PayScreen> {
PayRange _range = PayRange.month;
@override
Widget build(BuildContext context) {
final shifts = ref.watch(shiftsProvider);
final cutoff = DateTime.now().subtract(_range.window);
final completed = shifts
.where(
(s) => s.status == ShiftStatus.completed && s.start.isAfter(cutoff),
)
.toList();
final pendingShifts = ref.watch(bookedShiftsProvider);
final paid = completed.fold<double>(0, (sum, s) => sum + s.pay);
final pending = pendingShifts.fold<double>(
0,
(sum, s) => sum + s.scheduledPay,
);
final segments = GlassSegments<PayRange>(
segments: [for (final r in PayRange.values) (r, r.label)],
value: _range,
onChanged: (r) => setState(() => _range = r),
);
return Scaffold(
body: CustomScrollView(
slivers: [
KrowSliverHeader(
expandedHeight: 168,
// Deep enough to keep the range switcher: collapsing it away would
// mean scrolling back to the top to change what you are looking at.
collapsedHeight: 104,
builder: (context, t) => Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Expanded(
child: HeaderCrossFade(
t: t,
expandedHeight: 84,
expanded: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'ESTIMATED GROSS PAY · PRE-TAX',
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.5,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5),
),
),
const SizedBox(height: 5),
Text(
Fmt.money(paid + pending),
style: const TextStyle(
fontSize: 40,
fontWeight: FontWeight.w700,
letterSpacing: -1.8,
color: Colors.white,
),
),
],
),
collapsed: Align(
alignment: Alignment.centerLeft,
child: Text(
'${Fmt.money(paid + pending)} · pre-tax',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
color: Colors.white,
),
),
),
),
),
const SizedBox(height: 12),
segments,
],
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
sliver: SliverList.list(
children: [
KrowCard(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Flexible(
child: Text(
'EARNINGS TREND',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w600,
letterSpacing: 1.4,
color: Color(0xFF949FA5),
),
),
),
const SizedBox(width: 8),
Text(
_range.label,
maxLines: 1,
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: Color(0xFF949FA5),
),
),
],
),
const SizedBox(height: 16),
MiniBarChart(bars: _buildBars(shifts)),
const SizedBox(height: 14),
const Divider(height: 1),
const SizedBox(height: 12),
const Row(
children: [
LegendDot(KrowPalette.blue, 'Paid'),
SizedBox(width: 16),
LegendDot(KrowPalette.yellow, 'Pending'),
],
),
],
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: KrowCard(
padding: const EdgeInsets.all(15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const StatusChip('Pending', tone: ChipTone.yellow),
const SizedBox(height: 11),
Text(
Fmt.money(pending),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
),
),
],
),
),
),
const SizedBox(width: 10),
Expanded(
child: KrowCard(
padding: const EdgeInsets.all(15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const StatusChip('Paid', tone: ChipTone.neutral),
const SizedBox(height: 11),
Text(
Fmt.money(paid),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
),
),
],
),
),
),
],
),
const SizedBox(height: 12),
TileGroup(
children: [
NavTile(
icon: LucideIcons.calendarDays,
title: 'Payroll schedule',
subtitle: 'Semi-monthly, 11 days after each period',
onTap: () => context.push('/payroll'),
),
NavTile(
icon: LucideIcons.landmark,
title: 'Direct deposit',
subtitle: 'Where your pay lands',
badge: ref.watch(profileProvider).bank == null
? const StatusChip('Add', tone: ChipTone.yellow)
: const StatusChip('Set', tone: ChipTone.neutral),
onTap: () => context.push('/direct-deposit'),
),
],
),
const SectionLabel('Payments'),
if (completed.isEmpty)
const EmptyState(
icon: LucideIcons.banknote,
title: 'Nothing paid yet',
message: 'Finish a shift and the payment shows up here.',
)
else
for (final shift in completed)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _PaymentRow(shift: shift),
),
const SizedBox(height: 12),
const InfoBanner(
'Amounts are pre-tax. Your take-home differs after tax, '
'deductions and withholding from your payroll provider.',
),
],
),
),
],
),
);
}
/// Four buckets across the selected range, split paid vs still pending.
List<(String, double, bool)> _buildBars(List<Shift> shifts) {
final now = DateTime.now();
final span = _range.window.inDays;
final bucket = (span / 4).ceil();
final labels = DateFormat('d MMM');
return [
for (var i = 3; i >= 0; i--)
() {
final end = now.subtract(Duration(days: bucket * i));
final start = end.subtract(Duration(days: bucket));
final inBucket = shifts.where(
(s) => s.start.isAfter(start) && !s.start.isAfter(end),
);
final value = inBucket.fold<double>(0, (sum, s) => sum + s.pay);
final anyPaid = inBucket.any(
(s) => s.status == ShiftStatus.completed,
);
return (labels.format(end), value, anyPaid);
}(),
];
}
}
class _PaymentRow extends StatelessWidget {
const _PaymentRow({required this.shift});
final Shift shift;
@override
Widget build(BuildContext context) {
return KrowCard(
onTap: () => context.push('/payment/${shift.id}'),
padding: const EdgeInsets.all(14),
child: Row(
children: [
BusinessBadge(shift.logoLetter, size: 42),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${shift.role} · ${shift.business}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
),
const SizedBox(height: 2),
Text(
'${Fmt.dayShort(shift.start)} · '
'${Fmt.duration(shift.workedDuration ?? shift.scheduledDuration)}',
style: const TextStyle(fontSize: 12, color: KrowPalette.mute),
),
],
),
),
Text(
Fmt.money(shift.pay),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
),
),
],
),
);
}
}
/// Semi-monthly periods: each closes on the 15th and the month end, and pays
/// 11 days later.
class PayrollScheduleScreen extends ConsumerWidget {
const PayrollScheduleScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final now = DateTime.now();
final inFirstHalf = now.day <= 15;
final periodStart = DateTime(now.year, now.month, inFirstHalf ? 1 : 16);
final periodEnd = inFirstHalf
? DateTime(now.year, now.month, 15)
: DateTime(now.year, now.month + 1, 0);
final payDate = periodEnd.add(const Duration(days: 11));
final nextStart = periodEnd.add(const Duration(days: 1));
final nextEnd = inFirstHalf
? DateTime(now.year, now.month + 1, 0)
: DateTime(now.year, now.month + 1, 15);
final elapsed = now.difference(periodStart).inDays + 1;
final total = periodEnd.difference(periodStart).inDays + 1;
final earned = ref.watch(completedEarningsProvider);
final d = DateFormat('d MMM yyyy');
return Scaffold(
appBar: AppBar(
title: const Text('Payroll schedule'),
actions: const [
Padding(
padding: EdgeInsets.only(right: 16),
child: Center(
child: StatusChip('Semi-monthly', tone: ChipTone.yellow),
),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
const Text(
'Two pay dates a month, 11 days after each period closes.',
style: TextStyle(
fontSize: 13.5,
height: 1.5,
color: KrowPalette.mute,
),
),
const SectionLabel('Current period'),
KrowCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${d.format(periodStart)} - ${d.format(periodEnd)}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
const StatusChip('Open', tone: ChipTone.yellow),
],
),
const SizedBox(height: 4),
Text(
'Pay date ${d.format(payDate)}',
style: const TextStyle(
fontSize: 12.5,
color: KrowPalette.mute,
),
),
const SizedBox(height: 13),
KrowProgressBar(elapsed / total),
const SizedBox(height: 9),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'$elapsed of $total days elapsed',
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: Color(0xFF949FA5),
),
),
Text(
Fmt.money(earned),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
],
),
],
),
),
const SectionLabel('Next period'),
KrowCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${d.format(nextStart)} - ${d.format(nextEnd)}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
const StatusChip('Not started', tone: ChipTone.muted),
],
),
const SizedBox(height: 4),
Text(
'Pay date '
'${d.format(nextEnd.add(const Duration(days: 11)))}',
style: const TextStyle(
fontSize: 12.5,
color: KrowPalette.mute,
),
),
const SizedBox(height: 13),
const KrowProgressBar(0),
],
),
),
const SizedBox(height: 16),
const InfoBanner(
'Payments generate after the shift scheduled end time, not the '
'moment you clock out.',
),
],
),
);
}
}
class PaymentDetailScreen extends ConsumerWidget {
const PaymentDetailScreen({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('Payment not found.')),
);
}
final worked = shift.workedDuration ?? shift.scheduledDuration;
return Scaffold(
body: ListView(
padding: EdgeInsets.zero,
children: [
BrandHeader(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
HeaderIconButton(
icon: LucideIcons.arrowLeft,
onPressed: () => context.pop(),
),
const StatusChip('Pending', tone: ChipTone.yellow),
],
),
const SizedBox(height: 16),
Text(
'ESTIMATED GROSS · PRE-TAX',
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.5,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5),
),
),
const SizedBox(height: 4),
Text(
Fmt.money(shift.pay),
style: const TextStyle(
fontSize: 36,
fontWeight: FontWeight.w700,
letterSpacing: -1.6,
color: Colors.white,
),
),
const SizedBox(height: 6),
Text(
Fmt.dayShort(shift.start),
style: TextStyle(
fontSize: 12.5,
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(
'Clock in',
shift.clockIn == null ? '-' : Fmt.time(shift.clockIn!),
),
StatSlab(
'Clock out',
shift.clockOut == null ? '-' : Fmt.time(shift.clockOut!),
),
StatSlab('Hours', Fmt.duration(worked)),
],
),
const SectionLabel('Pay breakdown'),
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
DetailRow('Hourly rate', '${Fmt.money(shift.rate)}/hr'),
const Divider(height: 1),
DetailRow(
'Regular · ${Fmt.duration(worked)}',
Fmt.money(shift.pay),
),
const Divider(height: 1),
DetailRow('Break, unpaid', '${shift.breakMinutes} min'),
const Divider(height: 1),
DetailRow(
'Total, pre-tax',
Fmt.money(shift.pay),
emphasis: true,
),
],
),
),
const SectionLabel('Where'),
KrowCard(
child: Row(
children: [
const Icon(
LucideIcons.mapPin,
size: 18,
color: KrowPalette.blue,
),
const SizedBox(width: 12),
Expanded(child: Text(shift.address)),
],
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,736 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/theme.dart';
import '../models/compliance.dart';
import '../state/profile_controller.dart';
import '../state/seed.dart';
import '../widgets/common.dart';
/// Only called if something happens during a shift. Managers never see these.
class EmergencyContactsScreen extends ConsumerWidget {
const EmergencyContactsScreen({super.key});
Future<void> _edit(
BuildContext context,
WidgetRef ref, {
EmergencyContact? existing,
int? index,
}) async {
final name = TextEditingController(text: existing?.name ?? '');
final phone = TextEditingController(text: existing?.phone ?? '');
var relationship = existing?.relationship ?? 'Spouse';
const options = ['Spouse', 'Parent', 'Sibling', 'Child', 'Friend', 'Other'];
final saved = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: StatefulBuilder(
builder: (context, setSheetState) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
existing == null ? 'Add a contact' : 'Edit contact',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
TextField(
controller: name,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Full name',
prefixIcon: Icon(LucideIcons.user, size: 18),
),
),
const SizedBox(height: 12),
TextField(
controller: phone,
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9 +]')),
],
decoration: const InputDecoration(
labelText: 'Mobile',
prefixIcon: Icon(LucideIcons.phone, size: 18),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: relationship,
decoration: const InputDecoration(labelText: 'Relationship'),
items: [
for (final o in options)
DropdownMenuItem(value: o, child: Text(o)),
],
onChanged: (v) =>
setSheetState(() => relationship = v ?? relationship),
),
const SizedBox(height: 20),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Save contact'),
),
const SizedBox(height: 8),
],
),
),
),
);
if (saved != true) return;
if (name.text.trim().isEmpty || phone.text.trim().isEmpty) return;
ref
.read(profileProvider.notifier)
.saveContact(
EmergencyContact(
name: name.text.trim(),
phone: phone.text.trim(),
relationship: relationship,
),
index: index,
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final contacts = ref.watch(profileProvider).contacts;
return Scaffold(
appBar: AppBar(title: const Text('Emergency contacts')),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
const Text(
'We only call these people if something happens to you during a '
'shift. Managers never see them.',
style: TextStyle(
fontSize: 13.5,
height: 1.55,
color: KrowPalette.mute,
),
),
const SectionLabel('Your contacts'),
if (contacts.isEmpty)
const EmptyState(
icon: LucideIcons.phone,
title: 'No contact yet',
message: 'Add at least one before your first shift.',
)
else
for (var i = 0; i < contacts.length; i++)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: KrowCard(
onTap: () =>
_edit(context, ref, existing: contacts[i], index: i),
padding: const EdgeInsets.all(14),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: KrowPalette.blueTint,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
LucideIcons.user,
size: 19,
color: KrowPalette.blue,
),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
contacts[i].name,
style: const TextStyle(
fontSize: 15.5,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
),
),
const SizedBox(height: 2),
Text(
'${contacts[i].relationship} · '
'${contacts[i].phone}',
style: const TextStyle(
fontSize: 12.5,
color: KrowPalette.mute,
),
),
],
),
),
IconButton(
onPressed: () =>
ref.read(profileProvider.notifier).removeContact(i),
icon: const Icon(LucideIcons.trash2, size: 18),
color: const Color(0xFF949FA5),
),
],
),
),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _edit(context, ref),
icon: const Icon(LucideIcons.plus, size: 18),
label: const Text('Add a contact'),
),
],
),
);
}
}
/// More picks mean more shifts offered.
class SkillsScreen extends ConsumerStatefulWidget {
const SkillsScreen({super.key});
@override
ConsumerState<SkillsScreen> createState() => _SkillsScreenState();
}
class _SkillsScreenState extends ConsumerState<SkillsScreen> {
final _custom = TextEditingController();
@override
void dispose() {
_custom.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final profile = ref.watch(profileProvider);
final controller = ref.read(profileProvider.notifier);
final extras = profile.skills.where((s) => !Seed.skills.contains(s));
return Scaffold(
appBar: AppBar(
title: const Text('Experience & skills'),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: StatusChip(
'${profile.settings.length + profile.skills.length} selected',
tone: ChipTone.yellow,
),
),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
const Text(
"Where you've worked",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: -0.8,
),
),
const SizedBox(height: 8),
const Text(
'Pick every setting you have worked in. More picks mean more '
'shifts offered.',
style: TextStyle(
fontSize: 13.5,
height: 1.55,
color: KrowPalette.mute,
),
),
const SizedBox(height: 18),
Wrap(
spacing: 7,
runSpacing: 7,
children: [
for (final s in Seed.workSettings)
_Tag(
label: s,
selected: profile.settings.contains(s),
onTap: () => controller.toggleSetting(s),
),
],
),
const SectionLabel('Skills and trades'),
Wrap(
spacing: 7,
runSpacing: 7,
children: [
for (final s in Seed.skills)
_Tag(
label: s,
selected: profile.skills.contains(s),
onTap: () => controller.toggleSkill(s),
),
],
),
const SectionLabel('Anything else you do'),
Row(
children: [
Expanded(
child: TextField(
controller: _custom,
textInputAction: TextInputAction.done,
onSubmitted: (v) {
controller.addSkill(v);
_custom.clear();
},
decoration: const InputDecoration(
hintText: 'Type a skill...',
),
),
),
const SizedBox(width: 9),
SizedBox(
width: 52,
height: 52,
child: FilledButton(
onPressed: () {
controller.addSkill(_custom.text);
_custom.clear();
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(52, 52),
),
child: const Icon(LucideIcons.plus, size: 20),
),
),
],
),
if (extras.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 7,
runSpacing: 7,
children: [
for (final s in extras)
Chip(
label: Text(s),
backgroundColor: KrowPalette.yellow,
side: BorderSide.none,
labelStyle: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: KrowPalette.ink,
),
deleteIcon: const Icon(LucideIcons.x, size: 14),
onDeleted: () => controller.toggleSkill(s),
),
],
),
],
const SizedBox(height: 24),
FilledButton(
onPressed: () {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Skills saved')));
Navigator.of(context).pop();
},
child: const Text('Save skills'),
),
],
),
);
}
}
class _Tag extends StatelessWidget {
const _Tag({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return FilterChip(
label: Text(label),
selected: selected,
showCheckmark: false,
onSelected: (_) => onTap(),
selectedColor: KrowPalette.blue,
backgroundColor: Colors.white,
side: BorderSide(color: selected ? KrowPalette.blue : KrowPalette.line),
labelStyle: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w500,
color: selected ? Colors.white : KrowPalette.mute,
),
);
}
}
/// Where your pay lands. Needed before the first pay date.
class DirectDepositScreen extends ConsumerStatefulWidget {
const DirectDepositScreen({super.key});
@override
ConsumerState<DirectDepositScreen> createState() =>
_DirectDepositScreenState();
}
class _DirectDepositScreenState extends ConsumerState<DirectDepositScreen> {
final _bank = TextEditingController();
final _routing = TextEditingController();
final _account = TextEditingController();
String _type = 'Chequing';
bool _editing = false;
@override
void dispose() {
_bank.dispose();
_routing.dispose();
_account.dispose();
super.dispose();
}
void _save() {
if (_bank.text.trim().isEmpty ||
_routing.text.length != 9 ||
_account.text.length < 4) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Check the bank name, 9-digit routing and account'),
),
);
return;
}
ref
.read(profileProvider.notifier)
.saveBank(
BankAccount(
bankName: _bank.text.trim(),
routing: _routing.text,
accountLast4: _account.text.substring(_account.text.length - 4),
accountType: _type,
),
);
setState(() => _editing = false);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Account saved')));
}
@override
Widget build(BuildContext context) {
final bank = ref.watch(profileProvider).bank;
return Scaffold(
appBar: AppBar(title: const Text('Direct deposit')),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
const Text(
'Where your pay lands. You need this before the first pay date.',
style: TextStyle(
fontSize: 13.5,
height: 1.55,
color: KrowPalette.mute,
),
),
const SizedBox(height: 18),
KrowCard(
padding: const EdgeInsets.all(15),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: KrowPalette.blueTint,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
LucideIcons.shield,
size: 17,
color: KrowPalette.blue,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Bank-grade encryption',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 3),
Text(
'Account numbers are tokenised. KROW staff never see '
'them, and neither do the businesses you work for.',
style: TextStyle(
fontSize: 12,
height: 1.45,
color: KrowPalette.mute,
),
),
],
),
),
],
),
),
const SectionLabel('Your account'),
if (bank != null && !_editing)
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
DetailRow('Bank', bank.bankName),
const Divider(height: 1),
DetailRow('Type', bank.accountType),
const Divider(height: 1),
DetailRow('Routing', bank.routing),
const Divider(height: 1),
DetailRow('Account', '····${bank.accountLast4}'),
],
),
)
else if (!_editing)
EmptyState(
icon: LucideIcons.landmark,
title: 'No account added',
message: 'Add one before the next pay date to get paid on time.',
action: FilledButton.icon(
onPressed: () => setState(() => _editing = true),
icon: const Icon(LucideIcons.plus, size: 18),
label: const Text('Add an account'),
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _bank,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Bank name'),
),
const SizedBox(height: 12),
TextField(
controller: _routing,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(9),
],
decoration: const InputDecoration(
labelText: 'Routing number',
helperText: 'Nine digits, on the bottom of a cheque',
),
),
const SizedBox(height: 12),
TextField(
controller: _account,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(17),
],
decoration: const InputDecoration(
labelText: 'Account number',
helperText: 'Chequing or savings, in your own name',
),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _type,
decoration: const InputDecoration(labelText: 'Account type'),
items: const [
DropdownMenuItem(
value: 'Chequing',
child: Text('Chequing'),
),
DropdownMenuItem(value: 'Savings', child: Text('Savings')),
],
onChanged: (v) => setState(() => _type = v ?? _type),
),
],
),
const SizedBox(height: 22),
if (_editing)
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => setState(() => _editing = false),
child: const Text('Cancel'),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
onPressed: _save,
child: const Text('Save account'),
),
),
],
)
else if (bank != null)
OutlinedButton(
onPressed: () => setState(() => _editing = true),
child: const Text('Replace account'),
),
],
),
);
}
}
/// Policies and balances, per staffing vendor.
class BenefitsScreen extends ConsumerWidget {
const BenefitsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Sick-leave accrual starts after 30 days worked.
const daysWorked = 12;
const threshold = 30;
return Scaffold(
appBar: AppBar(
title: const Text('Your benefits'),
actions: const [
Padding(
padding: EdgeInsets.only(right: 16),
child: Center(
child: StatusChip('Legendary Events', tone: ChipTone.yellow),
),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
const Text(
'Policies and balances, per staffing vendor.',
style: TextStyle(fontSize: 13.5, color: KrowPalette.mute),
),
const SectionLabel('Active policies'),
Container(
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 30),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: KrowPalette.line),
),
child: Column(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: KrowPalette.line),
),
child: const Icon(
LucideIcons.gift,
size: 24,
color: Color(0xFF949FA5),
),
),
const SizedBox(height: 14),
const Text(
'No policy active yet',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 5),
const Text(
'Sick-leave accrual starts after 30 days worked. '
'You are at day $daysWorked.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
height: 1.5,
color: KrowPalette.mute,
),
),
const SizedBox(height: 16),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: KrowProgressBar(
daysWorked / threshold,
color: KrowPalette.yellow,
),
),
const SizedBox(height: 9),
const Text(
'${threshold - daysWorked} days to go',
style: TextStyle(fontSize: 11.5, color: Color(0xFF949FA5)),
),
],
),
),
const SectionLabel('Balance history'),
KrowCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'A read-only record of accruals, usage, adjustments and '
'reversals.',
style: TextStyle(
fontSize: 12.5,
height: 1.5,
color: KrowPalette.mute,
),
),
const SizedBox(height: 14),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: KrowPalette.sage,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'Nothing recorded yet',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: KrowPalette.mute),
),
),
],
),
),
const SizedBox(height: 16),
const InfoBanner(
'Working for a different vendor? Switch at the top to see their '
'policies.',
),
],
),
);
}
}

View File

@@ -0,0 +1,362 @@
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/theme.dart';
import '../state/compliance_controller.dart';
import '../state/profile_controller.dart';
import '../widgets/collapsing_header.dart';
import '../widgets/common.dart';
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
/// Avatar (84) + gap + name + role, plus 4/16 padding.
static const _expandedHeader = 192.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
final worker = ref.watch(workerStatsProvider);
final profile = ref.watch(profileProvider);
final (done, total) = ref.watch(complianceProgressProvider);
return Scaffold(
body: CustomScrollView(
slivers: [
KrowSliverHeader(
// Must clear the open content: avatar, name and role, plus the
// padding around them. The settings button overlays rather than
// stacking above, which is what keeps this under 200.
expandedHeight: _expandedHeader,
collapsedHeight: 60,
builder: (context, t) => HeaderCrossFade(
t: t,
expandedHeight: _expandedHeader,
collapsed: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
child: Row(
children: [
Expanded(
child: Text(
worker.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: Colors.white,
),
),
),
HeaderIconButton(
icon: LucideIcons.settings,
tooltip: 'Settings',
onPressed: () => context.push('/settings'),
),
],
),
),
expanded: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Stack(
// Without this the unpositioned column would shrink-wrap to
// the Stack's top-left corner instead of centring.
alignment: Alignment.topCenter,
children: [
// Overlaid rather than stacked above the avatar: as its own
// row it added 46px the header could not afford.
Positioned(
top: 0,
right: 0,
child: HeaderIconButton(
icon: LucideIcons.settings,
tooltip: 'Settings',
onPressed: () => context.push('/settings'),
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => _editName(context, ref, worker.name),
child: Stack(
children: [
Container(
width: 84,
height: 84,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: KrowPalette.yellow,
shape: BoxShape.circle,
),
child: Text(
worker.initials,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: KrowPalette.ink,
),
),
),
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(
color: KrowPalette.blue,
width: 2.5,
),
),
child: const Icon(
LucideIcons.pencil,
size: 12,
color: KrowPalette.blue,
),
),
),
],
),
),
const SizedBox(height: 10),
Text(
worker.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 23,
fontWeight: FontWeight.w700,
letterSpacing: -0.8,
color: Colors.white,
),
),
const SizedBox(height: 2),
Text(
'${worker.role} · ${worker.city}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
letterSpacing: 0.4,
color: Colors.white.withValues(alpha: 0.6),
),
),
],
),
],
),
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
sliver: SliverList.list(
children: [
StatRail(
stats: [
('Shifts', '${worker.shiftsWorked}'),
('Rating', worker.rating.toStringAsFixed(1)),
('On time', '${worker.onTimePercent}%'),
('No show', '${worker.noShows}'),
('Cancel', '${worker.cancellations}'),
],
),
const SizedBox(height: 11),
KrowCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Trust index',
style: TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
),
),
Text(
'${worker.trustIndex}%',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
),
),
],
),
const SizedBox(height: 11),
KrowProgressBar(
worker.trustIndex / 100,
color: worker.trustIndex < 45
? const Color(0xFFA6403F)
: KrowPalette.blue,
),
const SizedBox(height: 9),
const Text(
'Stay above 45% to keep picking up shifts. On-time '
'arrivals raise it fastest.',
style: TextStyle(
fontSize: 12,
height: 1.45,
color: KrowPalette.mute,
),
),
],
),
),
const SectionLabel('Compliance'),
TileGroup(
children: [
NavTile(
icon: LucideIcons.shieldCheck,
title: 'Compliance hub',
subtitle: 'Everything a business must see',
badge: StatusChip(
'$done of $total',
tone: done == total
? ChipTone.neutral
: ChipTone.yellow,
),
onTap: () => context.push('/compliance'),
),
],
),
const SectionLabel('Your details'),
TileGroup(
children: [
NavTile(
icon: LucideIcons.user,
title: 'Personal info',
subtitle: worker.phone,
onTap: () => _editName(context, ref, worker.name),
),
NavTile(
icon: LucideIcons.phone,
title: 'Emergency contact',
subtitle: 'Who we call if something happens',
badge: StatusChip(
'${profile.contacts.length}',
tone: ChipTone.neutral,
),
onTap: () => context.push('/emergency-contacts'),
),
NavTile(
icon: LucideIcons.briefcase,
title: 'Experience & skills',
subtitle: 'Work history and trades',
badge: StatusChip(
'${profile.skills.length}',
tone: ChipTone.neutral,
),
onTap: () => context.push('/skills'),
),
NavTile(
icon: LucideIcons.calendar,
title: 'Availability',
subtitle: 'The hours you can work',
onTap: () => context.push('/availability'),
),
NavTile(
icon: LucideIcons.gift,
title: 'Benefits',
subtitle: 'Policies and balance history',
onTap: () => context.push('/benefits'),
),
],
),
const SectionLabel('Money'),
TileGroup(
children: [
NavTile(
icon: LucideIcons.landmark,
title: 'Direct deposit',
subtitle: profile.bank == null
? 'Not set up yet'
: '${profile.bank!.bankName} '
'····${profile.bank!.accountLast4}',
badge: profile.bank == null
? const StatusChip('Add', tone: ChipTone.yellow)
: null,
onTap: () => context.push('/direct-deposit'),
),
NavTile(
icon: LucideIcons.fileText,
title: 'Timecards',
subtitle: 'Hours worked and disputes',
onTap: () => context.push('/timecards'),
),
],
),
const SectionLabel('Support'),
TileGroup(
children: [
NavTile(
icon: LucideIcons.lifeBuoy,
title: 'Contact support',
subtitle: 'Usually answered within 4 hours',
onTap: () => context.push('/support'),
),
NavTile(
icon: LucideIcons.circleHelp,
title: 'FAQs',
subtitle: 'Common questions answered',
onTap: () => context.push('/faqs'),
),
NavTile(
icon: LucideIcons.shield,
title: 'Privacy & security',
subtitle: 'Account and data settings',
onTap: () => context.push('/privacy'),
),
],
),
const SizedBox(height: 28),
],
),
),
],
),
);
}
Future<void> _editName(
BuildContext context,
WidgetRef ref,
String current,
) async {
final controller = TextEditingController(text: current);
final name = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Your name'),
content: TextField(
controller: controller,
autofocus: true,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(hintText: 'Full name'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
child: const Text('Save'),
),
],
),
);
if (name == null || name.isEmpty) return;
ref.read(profileProvider.notifier).updateName(name);
}
}

View File

@@ -0,0 +1,589 @@
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/theme.dart';
import '../state/profile_controller.dart';
import '../state/seed.dart';
import '../state/session_controller.dart';
import '../state/shifts_controller.dart';
import '../state/timecards_controller.dart';
import '../widgets/common.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final session = ref.watch(sessionProvider);
final notifier = ref.read(sessionProvider.notifier);
final profile = ref.watch(profileProvider);
final reviewCount = ref.watch(timecardsNeedingReviewProvider).length;
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
actions: const [
Padding(
padding: EdgeInsets.only(right: 16),
child: Center(child: StatusChip('v1.1.1', tone: ChipTone.yellow)),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
const SectionLabel('Permissions'),
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
_SwitchRow(
icon: LucideIcons.mapPin,
title: 'Location',
subtitle: 'Needed to clock in at the venue',
value: session.locationGranted,
onChanged: (v) => v ? notifier.grantLocation() : null,
),
const Divider(height: 1),
_SwitchRow(
icon: LucideIcons.bell,
title: 'Notifications',
subtitle: 'Shift offers, reminders and pay alerts',
value: session.notificationsGranted,
onChanged: (v) => v ? notifier.grantNotifications() : null,
),
const Divider(height: 1),
_SwitchRow(
icon: LucideIcons.camera,
title: 'Camera',
subtitle: 'Photograph documents and attire',
value: session.cameraGranted,
onChanged: (v) => v ? notifier.grantCamera() : null,
),
],
),
),
const SectionLabel('Money'),
TileGroup(
children: [
NavTile(
icon: LucideIcons.landmark,
title: 'Direct deposit',
subtitle: profile.bank == null
? 'Where your pay lands'
: profile.bank!.bankName,
badge: profile.bank == null
? const StatusChip('Add', tone: ChipTone.yellow)
: null,
onTap: () => context.push('/direct-deposit'),
),
NavTile(
icon: LucideIcons.clock,
title: 'Timecards',
subtitle: 'Hours worked and disputes',
badge: reviewCount > 0
? StatusChip('$reviewCount', tone: ChipTone.yellow)
: null,
onTap: () => context.push('/timecards'),
),
NavTile(
icon: LucideIcons.calendarDays,
title: 'Payroll schedule',
subtitle: 'When each period closes and pays',
onTap: () => context.push('/payroll'),
),
],
),
const SectionLabel('Support'),
TileGroup(
children: [
NavTile(
icon: LucideIcons.lifeBuoy,
title: 'Contact support',
subtitle: 'Usually answered within 4 hours',
onTap: () => context.push('/support'),
),
NavTile(
icon: LucideIcons.circleHelp,
title: 'FAQs',
subtitle: 'Common questions answered',
onTap: () => context.push('/faqs'),
),
NavTile(
icon: LucideIcons.shield,
title: 'Privacy & security',
subtitle: 'Account and data settings',
onTap: () => context.push('/privacy'),
),
],
),
const SizedBox(height: 22),
OutlinedButton.icon(
onPressed: () => _signOut(context, ref),
icon: const Icon(LucideIcons.logOut, size: 18),
label: const Text('Sign out'),
),
const SizedBox(height: 22),
const Center(
child: Text(
'VERSION 1.1.1 · BUILD 26',
style: TextStyle(
fontSize: 11,
letterSpacing: 1.2,
color: Color(0xFF949FA5),
),
),
),
],
),
);
}
Future<void> _signOut(BuildContext context, WidgetRef ref) async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Sign out?'),
content: const Text('You will need to verify your number again.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Stay'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
child: const Text('Sign out'),
),
],
),
);
if (ok != true || !context.mounted) return;
await ref.read(sessionProvider.notifier).signOut();
if (!context.mounted) return;
context.go('/welcome');
}
}
class _SwitchRow extends StatelessWidget {
const _SwitchRow({
required this.icon,
required this.title,
required this.subtitle,
required this.value,
required this.onChanged,
});
final IconData icon;
final String title;
final String subtitle;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: KrowPalette.blueTint,
borderRadius: BorderRadius.circular(11),
),
child: Icon(icon, size: 18, color: KrowPalette.blue),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
),
),
Text(
subtitle,
style: const TextStyle(fontSize: 12, color: KrowPalette.mute),
),
],
),
),
Switch(value: value, onChanged: onChanged),
],
),
);
}
}
/// Pick what it is about so it reaches the right person first time.
class SupportScreen extends ConsumerStatefulWidget {
const SupportScreen({super.key});
@override
ConsumerState<SupportScreen> createState() => _SupportScreenState();
}
class _SupportScreenState extends ConsumerState<SupportScreen> {
String _topic = Seed.supportTopics.first;
String? _shiftId;
final _message = TextEditingController();
@override
void dispose() {
_message.dispose();
super.dispose();
}
void _send() {
if (_message.text.trim().length < 10) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tell us a little more than that')),
);
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Sent to support · $_topic')));
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final shifts = ref.watch(shiftsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Get help'),
actions: const [
Padding(
padding: EdgeInsets.only(right: 16),
child: Center(child: StatusChip('~4 hr reply')),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
const Text(
'Pick what it is about so it reaches the right person first time.',
style: TextStyle(
fontSize: 13.5,
height: 1.55,
color: KrowPalette.mute,
),
),
const SectionLabel("What's it about?"),
Wrap(
spacing: 7,
runSpacing: 7,
children: [
for (final t in Seed.supportTopics)
ChoiceChip(
label: Text(t),
selected: t == _topic,
showCheckmark: false,
selectedColor: KrowPalette.blue,
labelStyle: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w500,
color: t == _topic ? Colors.white : KrowPalette.mute,
),
onSelected: (_) => setState(() => _topic = t),
),
],
),
const SectionLabel('Which shift?'),
DropdownButtonFormField<String>(
initialValue: _shiftId,
isExpanded: true,
decoration: const InputDecoration(
hintText: 'Not about a specific shift',
),
items: [
for (final s in shifts)
DropdownMenuItem(
value: s.id,
child: Text(
'${s.role} · ${s.business}',
overflow: TextOverflow.ellipsis,
),
),
],
onChanged: (v) => setState(() => _shiftId = v),
),
const SectionLabel('Tell us what happened'),
TextField(
controller: _message,
maxLines: 5,
maxLength: 500,
decoration: const InputDecoration(
hintText: 'The venue door was locked when I arrived...',
),
),
TextButton.icon(
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Attachments open the camera roll')),
),
icon: const Icon(LucideIcons.paperclip, size: 16),
label: const Text('Attach a photo'),
),
const SizedBox(height: 16),
FilledButton(onPressed: _send, child: const Text('Send to support')),
],
),
);
}
}
class FaqScreen extends StatefulWidget {
const FaqScreen({super.key});
@override
State<FaqScreen> createState() => _FaqScreenState();
}
class _FaqScreenState extends State<FaqScreen> {
String _query = '';
@override
Widget build(BuildContext context) {
final q = _query.toLowerCase();
return Scaffold(
appBar: AppBar(title: const Text('Questions')),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
TextField(
onChanged: (v) => setState(() => _query = v),
decoration: const InputDecoration(
hintText: 'Search help',
prefixIcon: Icon(LucideIcons.search, size: 18),
),
),
for (final entry in Seed.faqs.entries)
() {
final matches = entry.value
.where(
(f) =>
q.isEmpty ||
f.$1.toLowerCase().contains(q) ||
f.$2.toLowerCase().contains(q),
)
.toList();
if (matches.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SectionLabel(entry.key),
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
children: [
for (var i = 0; i < matches.length; i++) ...[
if (i > 0) const Divider(height: 1),
ExpansionTile(
shape: const Border(),
collapsedShape: const Border(),
tilePadding: const EdgeInsets.symmetric(
horizontal: 12,
),
childrenPadding: const EdgeInsets.fromLTRB(
12,
0,
12,
14,
),
title: Text(
matches[i].$1,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
matches[i].$2,
style: const TextStyle(
fontSize: 13,
height: 1.55,
color: KrowPalette.mute,
),
),
),
],
),
],
],
),
),
],
);
}(),
],
),
);
}
}
class PrivacyScreen extends ConsumerWidget {
const PrivacyScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profile = ref.watch(profileProvider);
return Scaffold(
appBar: AppBar(title: const Text('Privacy & security')),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
const SectionLabel('Legal'),
TileGroup(
children: [
NavTile(
icon: LucideIcons.fileText,
title: 'Terms of service',
subtitle: 'Updated 14 Jun 2026',
onTap: () => _open(context, 'Terms of service'),
),
NavTile(
icon: LucideIcons.shield,
title: 'Privacy policy',
subtitle: 'What we collect and why',
onTap: () => _open(context, 'Privacy policy'),
),
],
),
const SectionLabel('Who sees your data'),
TileGroup(
children: [
NavTile(
icon: LucideIcons.briefcase,
title: "Businesses you've worked for",
subtitle: '2 have access to your documents',
onTap: () => _open(context, 'Data sharing'),
),
],
),
const SectionLabel('Email'),
KrowCard(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: KrowPalette.blueTint,
borderRadius: BorderRadius.circular(11),
),
child: const Icon(
LucideIcons.mail,
size: 18,
color: KrowPalette.blue,
),
),
const SizedBox(width: 13),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'KROW newsletter',
style: TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
),
),
Text(
'Product news and operational tips',
style: TextStyle(fontSize: 12, color: KrowPalette.mute),
),
],
),
),
Switch(
value: profile.newsletter,
onChanged: (v) =>
ref.read(profileProvider.notifier).setNewsletter(v),
),
],
),
),
const SectionLabel('Account'),
KrowCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Delete your account',
style: TextStyle(fontSize: 14.5, fontWeight: FontWeight.w600),
),
const SizedBox(height: 3),
const Text(
'Permanent. Timecards and tax records are kept for 4 years '
'by law, then destroyed.',
style: TextStyle(
fontSize: 12,
height: 1.45,
color: KrowPalette.mute,
),
),
const SizedBox(height: 14),
OutlinedButton.icon(
onPressed: () => _confirmDelete(context),
icon: const Icon(LucideIcons.trash2, size: 18),
label: const Text('Delete account'),
),
],
),
),
],
),
);
}
void _open(BuildContext context, String title) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('$title opens in the browser')));
}
Future<void> _confirmDelete(BuildContext context) async {
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete your account?'),
content: const Text(
'This cannot be undone. To go ahead, contact support so we can '
'confirm it is really you.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Keep my account'),
),
FilledButton(
onPressed: () {
Navigator.pop(context);
context.push('/support');
},
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
child: const Text('Contact support'),
),
],
),
);
}
}

View 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'),
);
}
}
}

View File

@@ -0,0 +1,396 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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/shift.dart';
import '../state/shifts_controller.dart';
import '../widgets/common.dart';
import '../widgets/glass_segments.dart';
import '../widgets/shift_card.dart';
import '../widgets/week_strip.dart';
enum _Tab { booked, open, history }
class ShiftsScreen extends ConsumerStatefulWidget {
const ShiftsScreen({super.key});
@override
ConsumerState<ShiftsScreen> createState() => _ShiftsScreenState();
}
class _ShiftsScreenState extends ConsumerState<ShiftsScreen> {
_Tab _tab = _Tab.booked;
/// Where we came from, so the new list can slide in from the right side.
_Tab _previousTab = _Tab.booked;
DateTime _selectedDay = DateTime.now();
String _query = '';
String _filter = 'All';
void _selectTab(_Tab tab) {
if (tab == _tab) return;
setState(() {
_previousTab = _tab;
_tab = tab;
});
}
List<DateTime> get _week {
final now = DateTime.now();
return [for (var i = 0; i < 7; i++) now.add(Duration(days: i))];
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
BrandHeader(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Fmt.monthYear(DateTime.now()).toUpperCase(),
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.6,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5),
),
),
const SizedBox(height: 4),
const Text(
'Shifts',
style: TextStyle(
fontSize: 27,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
color: Colors.white,
),
),
const SizedBox(height: 18),
_HeaderSegments(value: _tab, onChanged: _selectTab),
],
),
),
Expanded(
// The list slides in from the side the new tab sits on, so the
// content moves the same way your eye just did.
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 280),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeIn,
layoutBuilder: (current, previous) => Stack(
alignment: Alignment.topCenter,
children: [...previous, ?current],
),
transitionBuilder: (child, animation) {
final incoming = child.key == ValueKey(_tab);
final direction = _tab.index >= _previousTab.index ? 1.0 : -1.0;
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween(
begin: Offset(incoming ? 0.08 * direction : 0, 0),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: KeyedSubtree(
key: ValueKey(_tab),
child: switch (_tab) {
_Tab.booked => _BookedTab(
week: _week,
selected: _selectedDay,
onSelect: (d) => setState(() => _selectedDay = d),
),
_Tab.open => _OpenTab(
query: _query,
filter: _filter,
onQuery: (q) => setState(() => _query = q),
onFilter: (f) => setState(() => _filter = f),
),
_Tab.history => const _HistoryTab(),
},
),
),
),
],
),
);
}
}
class _HeaderSegments extends StatelessWidget {
const _HeaderSegments({required this.value, required this.onChanged});
final _Tab value;
final ValueChanged<_Tab> onChanged;
@override
Widget build(BuildContext context) {
return GlassSegments<_Tab>(
segments: const [
(_Tab.booked, 'Booked'),
(_Tab.open, 'Open'),
(_Tab.history, 'History'),
],
value: value,
onChanged: onChanged,
);
}
}
class _BookedTab extends ConsumerWidget {
const _BookedTab({
required this.week,
required this.selected,
required this.onSelect,
});
final List<DateTime> week;
final DateTime selected;
final ValueChanged<DateTime> onSelect;
@override
Widget build(BuildContext context, WidgetRef ref) {
final booked = ref.watch(bookedShiftsProvider);
final forDay = booked.where((s) => Fmt.sameDay(s.start, selected)).toList();
final marked = <int>{
for (var i = 0; i < week.length; i++)
if (booked.any((s) => Fmt.sameDay(s.start, week[i]))) i,
};
return ListView(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
children: [
WeekStrip(
days: week,
selected: selected,
onSelect: onSelect,
marked: marked,
),
SectionLabel(
'${Fmt.dayFull(selected)} · ${forDay.length} '
'${forDay.length == 1 ? 'shift' : 'shifts'}',
),
if (forDay.isEmpty)
const EmptyState(
icon: LucideIcons.ticket,
title: 'Nothing booked this day',
message: 'Switch to Open above to claim a shift near you.',
)
else
for (final shift in forDay)
Padding(
padding: const EdgeInsets.only(bottom: 11),
child: ShiftCard(
shift: shift,
onTap: () => context.push('/shift/${shift.id}'),
),
),
const SizedBox(height: 6),
const InfoBanner(
'Auto-Match only offers you shifts inside the availability blocks you '
'have switched on.',
),
],
);
}
}
class _OpenTab extends ConsumerWidget {
const _OpenTab({
required this.query,
required this.filter,
required this.onQuery,
required this.onFilter,
});
final String query;
final String filter;
final ValueChanged<String> onQuery;
final ValueChanged<String> onFilter;
static const _filters = ['All', 'Today', 'This week', 'Top pay'];
@override
Widget build(BuildContext context, WidgetRef ref) {
var shifts = ref.watch(openShiftsProvider);
final now = DateTime.now();
if (query.isNotEmpty) {
final q = query.toLowerCase();
shifts = shifts
.where(
(s) =>
s.role.toLowerCase().contains(q) ||
s.business.toLowerCase().contains(q) ||
s.venue.toLowerCase().contains(q),
)
.toList();
}
shifts = switch (filter) {
'Today' => shifts.where((s) => Fmt.sameDay(s.start, now)).toList(),
'This week' =>
shifts.where((s) => s.start.difference(now).inDays < 7).toList(),
'Top pay' => shifts.where((s) => s.rate >= 22).toList(),
_ => shifts,
};
return ListView(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
children: [
TextField(
onChanged: onQuery,
decoration: const InputDecoration(
hintText: 'Role, business or area',
prefixIcon: Icon(LucideIcons.search, size: 18),
),
),
const SizedBox(height: 14),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final f in _filters)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(f),
selected: f == filter,
showCheckmark: false,
selectedColor: KrowPalette.blue,
labelStyle: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w500,
color: f == filter ? Colors.white : KrowPalette.mute,
),
onSelected: (_) => onFilter(f),
),
),
],
),
),
SectionLabel('${shifts.length} shifts · highest paying first'),
if (shifts.isEmpty)
const EmptyState(
icon: LucideIcons.search,
title: 'Nothing matches',
message: 'Try a different filter or clear your search.',
)
else
for (final shift in shifts)
Padding(
padding: const EdgeInsets.only(bottom: 11),
child: ShiftCard(
shift: shift,
showDate: true,
onTap: () => context.push('/shift/${shift.id}'),
trailingAction: _ApplyButton(shift: shift),
),
),
],
);
}
}
class _ApplyButton extends ConsumerWidget {
const _ApplyButton({required this.shift});
final Shift shift;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
Fmt.money(shift.scheduledPay),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
),
),
const SizedBox(width: 10),
FilledButton(
onPressed: () {
HapticFeedback.selectionClick();
ref.read(shiftsProvider.notifier).apply(shift.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${shift.role} at ${shift.business} booked for '
'${Fmt.dayShort(shift.start)}',
),
),
);
},
style: FilledButton.styleFrom(
minimumSize: const Size(0, 34),
padding: const EdgeInsets.symmetric(horizontal: 16),
textStyle: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
),
),
child: const Text('Apply'),
),
],
);
}
}
class _HistoryTab extends ConsumerWidget {
const _HistoryTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final history = ref.watch(historyShiftsProvider);
final completed = history
.where((s) => s.status == ShiftStatus.completed)
.length;
final cancelled = history
.where((s) => s.status == ShiftStatus.cancelled)
.length;
final logged = ref.watch(hoursLoggedProvider);
return ListView(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
children: [
StatRail(
stats: [
('Completed', '$completed'),
('Cancelled', '$cancelled'),
('Logged', Fmt.duration(logged)),
],
),
const SectionLabel('Everything you have worked'),
if (history.isEmpty)
const EmptyState(
icon: LucideIcons.history,
title: 'No history yet',
message: 'Shifts you finish or cancel will show up here.',
)
else
for (final shift in history)
Padding(
padding: const EdgeInsets.only(bottom: 11),
child: ShiftCard(
shift: shift,
showDate: true,
onTap: () => context.push('/shift/${shift.id}'),
),
),
],
);
}
}

View File

@@ -0,0 +1,388 @@
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/timecard.dart';
import '../state/timecards_controller.dart';
import '../widgets/common.dart';
class TimecardsScreen extends ConsumerWidget {
const TimecardsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cards = ref.watch(timecardsProvider);
final pending = cards.where((c) => c.status == TimecardStatus.needsReview);
return Scaffold(
appBar: AppBar(
title: const Text('Your timecards'),
actions: [
if (pending.isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: StatusChip(
'${pending.length} to review',
tone: ChipTone.yellow,
),
),
),
],
),
body: cards.isEmpty
? const Padding(
padding: EdgeInsets.all(20),
child: EmptyState(
icon: LucideIcons.fileText,
title: 'No timecards yet',
message: 'One is filed automatically every time you clock out.',
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
itemCount: cards.length,
separatorBuilder: (_, _) => const SizedBox(height: 11),
itemBuilder: (context, i) => _TimecardTile(card: cards[i]),
),
);
}
}
class _TimecardTile extends StatelessWidget {
const _TimecardTile({required this.card});
final Timecard card;
@override
Widget build(BuildContext context) {
final (label, tone) = switch (card.status) {
TimecardStatus.needsReview => ('Needs review', ChipTone.yellow),
TimecardStatus.approved => ('Approved', ChipTone.neutral),
TimecardStatus.disputed => ('Disputed', ChipTone.danger),
};
return KrowCard(
onTap: () => context.push('/timecard/${card.id}'),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
BusinessBadge(card.business[0], size: 40),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.role,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
),
),
Text(
'${card.business} · ${Fmt.dayShort(card.date)}',
style: const TextStyle(
fontSize: 12.5,
color: KrowPalette.mute,
),
),
],
),
),
StatusChip(label, tone: tone),
],
),
const SizedBox(height: 14),
const Divider(height: 1),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _MiniStat(
LucideIcons.calendar,
'Scheduled',
Fmt.window(card.scheduledStart, card.scheduledEnd),
),
),
Container(width: 1, height: 34, color: KrowPalette.line),
Expanded(
child: _MiniStat(
LucideIcons.clock,
'Clocked',
Fmt.window(card.clockIn, card.clockOut),
highlight: true,
),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Filed ${Fmt.time(card.submittedAt)} · '
'${Fmt.duration(card.worked)}',
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: Color(0xFF949FA5),
),
),
Text(
Fmt.money(card.amount),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
),
),
],
),
],
),
);
}
}
class _MiniStat extends StatelessWidget {
const _MiniStat(this.icon, this.label, this.value, {this.highlight = false});
final IconData icon;
final String label;
final String value;
final bool highlight;
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: KrowPalette.blueTint,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 16, color: KrowPalette.blue),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: KrowPalette.mute,
),
),
Text(
value,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: highlight ? KrowPalette.blue : KrowPalette.ink,
),
),
],
),
),
],
);
}
}
/// Review a filed timecard: confirm it, or raise an issue before it locks for
/// payroll.
class TimecardReviewScreen extends ConsumerWidget {
const TimecardReviewScreen({super.key, required this.timecardId});
final String timecardId;
Future<void> _dispute(BuildContext context, WidgetRef ref) async {
final controller = TextEditingController();
final note = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Report an issue'),
content: TextField(
controller: controller,
autofocus: true,
maxLines: 4,
decoration: const InputDecoration(
hintText: 'What is wrong with these hours?',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
child: const Text('Send'),
),
],
),
);
if (note == null || note.isEmpty || !context.mounted) return;
ref.read(timecardsProvider.notifier).dispute(timecardId, note);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Issue raised with payroll')));
context.pop();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final card = ref.watch(timecardsProvider.notifier).byId(timecardId);
if (card == null) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: Text('Timecard not found.')),
);
}
final variance = card.varianceMinutes;
final open = card.status == TimecardStatus.needsReview;
return Scaffold(
appBar: AppBar(
title: const Text('Review timecard'),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: StatusChip(
card.status.label,
tone: switch (card.status) {
TimecardStatus.needsReview => ChipTone.yellow,
TimecardStatus.approved => ChipTone.neutral,
TimecardStatus.disputed => ChipTone.danger,
},
),
),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
Text(
'${card.role} at ${card.business}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: -0.8,
),
),
const SizedBox(height: 4),
Text(
Fmt.dayFull(card.date),
style: const TextStyle(fontSize: 13.5, color: KrowPalette.mute),
),
const SizedBox(height: 16),
InfoBanner(
open
? 'Sent automatically when you clocked out. You have 7 days to '
'raise an issue before it locks for payroll.'
: 'This timecard is ${card.status.label.toLowerCase()}.',
),
const SectionLabel('Shift'),
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
DetailRow('Date', Fmt.dayShort(card.date)),
const Divider(height: 1),
DetailRow(
'Scheduled',
Fmt.window(card.scheduledStart, card.scheduledEnd),
),
const Divider(height: 1),
DetailRow(
'Clocked',
Fmt.window(card.clockIn, card.clockOut),
valueColor: KrowPalette.blue,
),
const Divider(height: 1),
DetailRow(
'Variance',
variance == 0
? 'On time'
: '${variance > 0 ? '+' : ''}$variance min',
valueColor: variance > 5
? const Color(0xFFA6403F)
: KrowPalette.blue,
),
],
),
),
const SectionLabel('Hours and pay'),
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
DetailRow('Base rate', '${Fmt.money(card.rate)}/hr'),
const Divider(height: 1),
DetailRow('Break, unpaid', '${card.breakMinutes} min'),
const Divider(height: 1),
DetailRow(
'Regular · ${Fmt.duration(card.worked)}',
Fmt.money(card.amount),
),
const Divider(height: 1),
DetailRow(
'Total, pre-tax',
Fmt.money(card.amount),
emphasis: true,
),
],
),
),
if (card.disputeNote != null) ...[
const SectionLabel('Your note'),
KrowCard(child: Text(card.disputeNote!)),
],
const SizedBox(height: 22),
if (open)
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => _dispute(context, ref),
child: const Text('Report an issue'),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
onPressed: () {
ref.read(timecardsProvider.notifier).approve(card.id);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Timecard approved')),
);
context.pop();
},
child: const Text('Looks right'),
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,421 @@
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/shift.dart';
import '../state/availability_controller.dart';
import '../state/compliance_controller.dart';
import '../state/profile_controller.dart';
import '../state/shifts_controller.dart';
import '../widgets/collapsing_header.dart';
import '../widgets/common.dart';
import '../widgets/shift_card.dart';
class TodayScreen extends ConsumerWidget {
const TodayScreen({super.key});
String _greeting() {
final h = DateTime.now().hour;
if (h < 12) return 'Good morning';
if (h < 18) return 'Good afternoon';
return 'Good evening';
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final worker = ref.watch(workerStatsProvider);
final next = ref.watch(nextShiftProvider);
final open = ref.watch(openShiftsProvider);
final scheduled = ref.watch(scheduledEarningsProvider);
final hours = ref.watch(hoursLoggedProvider);
final daysSet = ref.watch(daysSetProvider);
final compliant = ref.watch(isCompliantProvider);
final (done, total) = ref.watch(complianceProgressProvider);
return Scaffold(
body: CustomScrollView(
slivers: [
KrowSliverHeader(
expandedHeight: 152,
collapsedHeight: 60,
builder: (context, t) => HeaderCrossFade(
t: t,
expandedHeight: 152,
expanded: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_Avatar(worker.initials),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_greeting().toUpperCase(),
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.6,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5),
),
),
const SizedBox(height: 2),
Text(
worker.name,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: -0.7,
color: Colors.white,
),
),
],
),
),
HeaderIconButton(
icon: LucideIcons.messageCircle,
tooltip: 'Support',
onPressed: () => context.push('/support'),
),
],
),
const SizedBox(height: 18),
Row(
children: [
// Flexible, not free-sized: on a 320px screen the two
// captions plus the rule are wider than the header.
Flexible(
child: _HeaderStat(
Fmt.money(scheduled),
'This period',
),
),
Container(
width: 1,
height: 34,
margin: const EdgeInsets.symmetric(horizontal: 18),
color: Colors.white24,
),
Flexible(
child: _HeaderStat(
Fmt.duration(hours),
'Hours logged',
),
),
],
),
],
),
),
// Once collapsed, keep who you are and the figure that matters,
// plus the one control that was in the open state.
collapsed: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
child: Row(
children: [
_Avatar(worker.initials, size: 32),
const SizedBox(width: 10),
Expanded(
child: Text(
worker.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
color: Colors.white,
),
),
),
Text(
Fmt.money(scheduled),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: KrowPalette.yellow,
),
),
const SizedBox(width: 10),
HeaderIconButton(
icon: LucideIcons.messageCircle,
tooltip: 'Support',
onPressed: () => context.push('/support'),
),
],
),
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
sliver: SliverList.list(
children: [
if (!compliant) ...[
const SizedBox(height: 16),
KrowCard(
onTap: () => context.push('/compliance'),
padding: const EdgeInsets.all(14),
child: Row(
children: [
const Icon(
LucideIcons.triangleAlert,
size: 20,
color: KrowPalette.ink,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Finish your compliance',
style: TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'$done of $total items cleared',
style: const TextStyle(
fontSize: 12,
color: KrowPalette.mute,
),
),
],
),
),
const StatusChip('Action', tone: ChipTone.yellow),
],
),
),
],
const SectionLabel('Up next'),
if (next == null)
EmptyState(
icon: LucideIcons.ticket,
title: 'Nothing booked',
message:
'Open shifts near you start at '
'${open.isEmpty ? '-' : Fmt.money(open.last.rate)}/hr.',
action: FilledButton(
onPressed: () => context.go('/shifts'),
child: const Text('Browse open shifts'),
),
)
else
ShiftCard(
shift: next,
showDate: !Fmt.sameDay(next.start, DateTime.now()),
onTap: () => context.push('/shift/${next.id}'),
),
if (next != null && next.status == ShiftStatus.active) ...[
const SizedBox(height: 10),
FilledButton.icon(
onPressed: () => context.go('/clock'),
icon: const Icon(LucideIcons.squareCheck, size: 18),
label: const Text('You are on shift - go to clock'),
),
],
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: _QuickTile(
icon: LucideIcons.search,
label: 'Find shifts',
caption: '${open.length} OPEN',
onTap: () => context.go('/shifts'),
),
),
const SizedBox(width: 10),
Expanded(
child: _QuickTile(
icon: LucideIcons.calendar,
label: 'Availability',
caption: '$daysSet DAYS SET',
onTap: () => context.push('/availability'),
),
),
const SizedBox(width: 10),
Expanded(
child: _QuickTile(
icon: LucideIcons.banknote,
label: 'Earnings',
caption: 'PENDING',
onTap: () => context.go('/pay'),
),
),
],
),
SectionLabel(
'Matched to you',
trailing: TextButton(
onPressed: () => context.go('/shifts'),
child: const Text('See all'),
),
),
if (open.isEmpty)
const InfoBanner(
'No open shifts right now. Turn on more availability blocks '
'and we will match you to more work.',
)
else
for (final shift in open.take(3))
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ShiftCard(
shift: shift,
showDate: true,
onTap: () => context.push('/shift/${shift.id}'),
),
),
],
),
),
],
),
);
}
}
/// The worker's initials, used at two sizes as the header collapses.
class _Avatar extends StatelessWidget {
const _Avatar(this.initials, {this.size = 44});
final String initials;
final double size;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: KrowPalette.yellow,
borderRadius: BorderRadius.circular(size / 3.4),
),
child: Text(
initials,
style: TextStyle(
fontSize: size * 0.39,
fontWeight: FontWeight.w700,
color: KrowPalette.ink,
),
),
);
}
}
class _HeaderStat extends StatelessWidget {
const _HeaderStat(this.value, this.caption);
final String value;
final String caption;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Both lines hold to one line and shrink to fit: a wrapped caption
// would push the header past its pinned height.
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
value,
maxLines: 1,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
letterSpacing: -0.8,
color: Colors.white,
),
),
),
const SizedBox(height: 3),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
caption.toUpperCase(),
maxLines: 1,
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.4,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5),
),
),
),
],
);
}
}
class _QuickTile extends StatelessWidget {
const _QuickTile({
required this.icon,
required this.label,
required this.caption,
required this.onTap,
});
final IconData icon;
final String label;
final String caption;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return KrowCard(
onTap: onTap,
padding: const EdgeInsets.fromLTRB(12, 14, 12, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: KrowPalette.blueTint,
borderRadius: BorderRadius.circular(11),
),
child: Icon(icon, size: 17, color: KrowPalette.blue),
),
const SizedBox(height: 22),
Text(
label,
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
),
const SizedBox(height: 2),
Text(
caption,
style: const TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: Color(0xFF949FA5),
),
),
],
),
);
}
}