621 lines
22 KiB
Dart
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)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|