363 lines
14 KiB
Dart
363 lines
14 KiB
Dart
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);
|
|
}
|
|
}
|