Files
krow-worker-app/lib/screens/profile_detail_screens.dart
2026-09-17 22:26:11 +05:30

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