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

621 lines
22 KiB
Dart

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