737 lines
24 KiB
Dart
737 lines
24 KiB
Dart
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.',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|