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