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'; import '../widgets/krow_wordmark.dart'; /// Declared in the order the segments are shown, which is the prototype's: /// All first, then narrowing. enum PayRange { all, week, month, year } extension on PayRange { String get label => switch (this) { PayRange.all => 'All', PayRange.week => 'Week', PayRange.month => 'Month', PayRange.year => 'Year', }; Duration get window => switch (this) { PayRange.all => const Duration(days: 3650), PayRange.week => const Duration(days: 7), PayRange.month => const Duration(days: 30), PayRange.year => const Duration(days: 365), }; } class PayScreen extends ConsumerStatefulWidget { const PayScreen({super.key}); @override ConsumerState createState() => _PayScreenState(); } class _PayScreenState extends ConsumerState { 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(0, (sum, s) => sum + s.pay); final pending = pendingShifts.fold( 0, (sum, s) => sum + s.scheduledPay, ); final segments = GlassSegments( 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: [ const HeaderBrandLine( trailing: HeaderEyebrow('Gross pay · pre-tax'), ), const SizedBox(height: 6), 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 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(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)), ], ), ), ], ), ), ], ), ); } }