Initial commit: Krow Worker App
This commit is contained in:
620
lib/screens/pay_screens.dart
Normal file
620
lib/screens/pay_screens.dart
Normal file
@@ -0,0 +1,620 @@
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user