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/formatters.dart'; import '../core/theme.dart'; import '../models/timecard.dart'; import '../state/timecards_controller.dart'; import '../widgets/common.dart'; class TimecardsScreen extends ConsumerWidget { const TimecardsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final cards = ref.watch(timecardsProvider); final pending = cards.where((c) => c.status == TimecardStatus.needsReview); return Scaffold( appBar: AppBar( title: const Text('Your timecards'), actions: [ if (pending.isNotEmpty) Padding( padding: const EdgeInsets.only(right: 16), child: Center( child: StatusChip( '${pending.length} to review', tone: ChipTone.yellow, ), ), ), ], ), body: ListView( padding: const EdgeInsets.fromLTRB(20, 16, 20, 28), children: timecardItems(cards), ), ); } } /// The timecard list as plain children, so the Clock tab can drop them into /// its sliver list and the pushed screen into a ListView, with one layout. List timecardItems(List cards) { if (cards.isEmpty) { return const [ EmptyState( icon: LucideIcons.fileText, title: 'No timecards yet', message: 'One is filed automatically every time you clock out.', ), ]; } return [ for (var i = 0; i < cards.length; i++) ...[ if (i > 0) const SizedBox(height: 11), TimecardTile(card: cards[i]), ], ]; } class TimecardTile extends StatelessWidget { const TimecardTile({super.key, required this.card}); final Timecard card; @override Widget build(BuildContext context) { final (label, tone) = switch (card.status) { TimecardStatus.needsReview => ('Needs review', ChipTone.yellow), TimecardStatus.approved => ('Approved', ChipTone.neutral), TimecardStatus.disputed => ('Disputed', ChipTone.danger), }; return KrowCard( onTap: () => context.push('/timecard/${card.id}'), padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ BusinessBadge(card.business[0], size: 40), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( card.role, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, letterSpacing: -0.4, ), ), Text( '${card.business} · ${Fmt.dayShort(card.date)}', style: const TextStyle( fontSize: 12.5, color: KrowPalette.mute, ), ), ], ), ), StatusChip(label, tone: tone), ], ), const SizedBox(height: 14), const Divider(height: 1), const SizedBox(height: 12), Row( children: [ Expanded( child: _MiniStat( LucideIcons.calendar, 'Scheduled', Fmt.window(card.scheduledStart, card.scheduledEnd), ), ), Container(width: 1, height: 34, color: KrowPalette.line), Expanded( child: _MiniStat( LucideIcons.clock, 'Clocked', Fmt.window(card.clockIn, card.clockOut), highlight: true, ), ), ], ), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Filed ${Fmt.time(card.submittedAt)} · ' '${Fmt.duration(card.worked)}', style: const TextStyle( fontSize: 11.5, fontWeight: FontWeight.w600, color: Color(0xFF949FA5), ), ), Text( Fmt.money(card.amount), style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w700, letterSpacing: -0.6, ), ), ], ), ], ), ); } } class _MiniStat extends StatelessWidget { const _MiniStat(this.icon, this.label, this.value, {this.highlight = false}); final IconData icon; final String label; final String value; final bool highlight; @override Widget build(BuildContext context) { return Row( children: [ Container( width: 34, height: 34, decoration: BoxDecoration( color: KrowPalette.blueTint, borderRadius: BorderRadius.circular(10), ), child: Icon(icon, size: 16, color: KrowPalette.blue), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: KrowPalette.mute, ), ), Text( value, style: TextStyle( fontSize: 12.5, fontWeight: FontWeight.w700, letterSpacing: -0.3, color: highlight ? KrowPalette.blue : KrowPalette.ink, ), ), ], ), ), ], ); } } /// Review a filed timecard: confirm it, or raise an issue before it locks for /// payroll. class TimecardReviewScreen extends ConsumerWidget { const TimecardReviewScreen({super.key, required this.timecardId}); final String timecardId; Future _dispute(BuildContext context, WidgetRef ref) async { final controller = TextEditingController(); final note = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Report an issue'), content: TextField( controller: controller, autofocus: true, maxLines: 4, decoration: const InputDecoration( hintText: 'What is wrong with these hours?', ), ), 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('Send'), ), ], ), ); if (note == null || note.isEmpty || !context.mounted) return; ref.read(timecardsProvider.notifier).dispute(timecardId, note); ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Issue raised with payroll'))); context.pop(); } @override Widget build(BuildContext context, WidgetRef ref) { final card = ref.watch(timecardsProvider.notifier).byId(timecardId); if (card == null) { return Scaffold( appBar: AppBar(), body: const Center(child: Text('Timecard not found.')), ); } final variance = card.varianceMinutes; final open = card.status == TimecardStatus.needsReview; return Scaffold( appBar: AppBar( title: const Text('Review timecard'), actions: [ Padding( padding: const EdgeInsets.only(right: 16), child: Center( child: StatusChip( card.status.label, tone: switch (card.status) { TimecardStatus.needsReview => ChipTone.yellow, TimecardStatus.approved => ChipTone.neutral, TimecardStatus.disputed => ChipTone.danger, }, ), ), ), ], ), body: ListView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 28), children: [ Text( '${card.role} at ${card.business}', style: const TextStyle( fontSize: 24, fontWeight: FontWeight.w700, letterSpacing: -0.8, ), ), const SizedBox(height: 4), Text( Fmt.dayFull(card.date), style: const TextStyle(fontSize: 13.5, color: KrowPalette.mute), ), const SizedBox(height: 16), InfoBanner( open ? 'Sent automatically when you clocked out. You have 7 days to ' 'raise an issue before it locks for payroll.' : 'This timecard is ${card.status.label.toLowerCase()}.', ), const SectionLabel('Shift'), KrowCard( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ DetailRow('Date', Fmt.dayShort(card.date)), const Divider(height: 1), DetailRow( 'Scheduled', Fmt.window(card.scheduledStart, card.scheduledEnd), ), const Divider(height: 1), DetailRow( 'Clocked', Fmt.window(card.clockIn, card.clockOut), valueColor: KrowPalette.blue, ), const Divider(height: 1), DetailRow( 'Variance', variance == 0 ? 'On time' : '${variance > 0 ? '+' : ''}$variance min', valueColor: variance > 5 ? const Color(0xFFA6403F) : KrowPalette.blue, ), ], ), ), const SectionLabel('Hours and pay'), KrowCard( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ DetailRow('Base rate', '${Fmt.money(card.rate)}/hr'), const Divider(height: 1), DetailRow('Break, unpaid', '${card.breakMinutes} min'), const Divider(height: 1), DetailRow( 'Regular · ${Fmt.duration(card.worked)}', Fmt.money(card.amount), ), const Divider(height: 1), DetailRow( 'Total, pre-tax', Fmt.money(card.amount), emphasis: true, ), ], ), ), if (card.disputeNote != null) ...[ const SectionLabel('Your note'), KrowCard(child: Text(card.disputeNote!)), ], const SizedBox(height: 22), if (open) Row( children: [ Expanded( child: OutlinedButton( onPressed: () => _dispute(context, ref), child: const Text('Report an issue'), ), ), const SizedBox(width: 10), Expanded( child: FilledButton( onPressed: () { ref.read(timecardsProvider.notifier).approve(card.id); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Timecard approved')), ); context.pop(); }, child: const Text('Looks right'), ), ), ], ), ], ), ); } }