import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.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/shift.dart'; import '../state/session_controller.dart'; import '../state/shifts_controller.dart'; import '../state/timecards_controller.dart'; import '../widgets/collapsing_header.dart'; import '../widgets/common.dart'; import '../widgets/glass_segments.dart'; import '../widgets/krow_wordmark.dart'; import '../widgets/shift_card.dart'; import 'timecard_screens.dart'; /// Clock in and out. The button unlocks 30 minutes before the shift starts, /// and only once the worker is at the venue. class ClockScreen extends ConsumerStatefulWidget { const ClockScreen({super.key}); @override ConsumerState createState() => _ClockScreenState(); } /// The two halves of the Clock tab, as the prototype splits them. enum _ClockTab { today, timecards } class _ClockScreenState extends ConsumerState { _ClockTab _tab = _ClockTab.today; Timer? _ticker; /// Stands in for the geofence check while there is no GPS plugin wired up. bool _atVenue = false; /// Last minute we repainted for. Off shift, only the countdown moves, so /// rebuilding every second would just churn the tree — and a rebuild landing /// between a tap's down and up swallows it. int _lastMinute = -1; @override void initState() { super.initState(); // Drives the countdown and the running stopwatch. _ticker = Timer.periodic(const Duration(seconds: 1), (_) { if (!mounted) return; final running = ref.read(activeShiftProvider) != null; final minute = DateTime.now().minute; if (running || minute != _lastMinute) { _lastMinute = minute; setState(() {}); } }); } @override void dispose() { _ticker?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final active = ref.watch(activeShiftProvider); final next = ref.watch(nextShiftProvider); final shift = active ?? next; final locationGranted = ref.watch(sessionProvider).locationGranted; return Scaffold( body: CustomScrollView( slivers: [ // Pins at one height: the two segments have to stay reachable, so // there is nothing worth collapsing away. Sized with headroom for a // large system text scale, which the eyebrow and title both grow with. KrowSliverHeader( expandedHeight: 170, collapsedHeight: 170, builder: (context, t) => Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), child: Column( mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.start, children: [ HeaderBrandLine( // The caption reports whichever half you are looking at. trailing: HeaderEyebrow( _tab == _ClockTab.today ? Fmt.dayFull(DateTime.now()) : '${Fmt.monthYear(DateTime.now())} · ' '${Fmt.duration(ref.watch(hoursLoggedProvider))} logged', ), ), const SizedBox(height: 8), const Text( 'Clock in', style: TextStyle( fontSize: 27, fontWeight: FontWeight.w700, letterSpacing: -0.9, color: Colors.white, ), ), const SizedBox(height: 14), GlassSegments<_ClockTab>( segments: const [ (_ClockTab.today, 'Today'), (_ClockTab.timecards, 'Timecards'), ], value: _tab, onChanged: (v) => setState(() => _tab = v), ), ], ), ), ), if (_tab == _ClockTab.timecards) SliverPadding( padding: const EdgeInsets.fromLTRB(20, 18, 20, 28), sliver: SliverList.list( children: timecardItems(ref.watch(timecardsProvider)), ), ) else if (shift == null) const SliverPadding( padding: EdgeInsets.fromLTRB(20, 40, 20, 20), sliver: SliverToBoxAdapter( child: EmptyState( icon: LucideIcons.clock, title: 'No shift to clock into', message: 'Book a shift and the clock unlocks 30 minutes before ' 'it starts.', ), ), ) else SliverPadding( padding: const EdgeInsets.fromLTRB(20, 28, 20, 24), sliver: SliverList.list( children: [ _ClockDial(shift: shift, atVenue: _atVenue), const SizedBox(height: 22), if (shift.status == ShiftStatus.active) FilledButton.icon( onPressed: () => _clockOut(shift), style: FilledButton.styleFrom( backgroundColor: KrowPalette.ink, ), icon: const Icon(LucideIcons.logOut, size: 18), label: const Text('Clock out'), ) else FilledButton.icon( onPressed: _canClockIn(shift, locationGranted) ? () => _clockIn(shift) : null, icon: const Icon(LucideIcons.logIn, size: 18), label: Text( _canClockIn(shift, locationGranted) ? 'Clock in' : 'Clock in · ${Fmt.untilStart(shift.start)}', ), ), const SizedBox(height: 12), _GeofenceCard( shift: shift, atVenue: _atVenue, locationGranted: locationGranted, onChanged: (v) => setState(() => _atVenue = v), ), const SectionLabel("Today's shift"), ShiftCard( shift: shift, onTap: () => context.push('/shift/${shift.id}'), ), ], ), ), ], ), ); } bool _canClockIn(Shift shift, bool locationGranted) { if (shift.status != ShiftStatus.booked) return false; if (!_atVenue) return false; final minutesToStart = shift.start.difference(DateTime.now()).inMinutes; return minutesToStart <= 30; } void _clockIn(Shift shift) { // Starting and ending a shift are the two moments that matter most in the // app; both get a firm confirmation you can feel without looking. HapticFeedback.mediumImpact(); ref.read(shiftsProvider.notifier).clockIn(shift.id); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Clocked in at ${Fmt.time(DateTime.now())}')), ); } Future _clockOut(Shift shift) async { final confirm = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Clock out?'), content: Text( 'Your timecard is filed automatically and sent to ' '${shift.business} for approval.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Not yet'), ), FilledButton( onPressed: () => Navigator.pop(context, true), style: FilledButton.styleFrom(minimumSize: const Size(0, 44)), child: const Text('Clock out'), ), ], ), ); if (confirm != true || !mounted) return; HapticFeedback.mediumImpact(); final card = ref.read(shiftsProvider.notifier).clockOut(shift.id); if (card == null || !mounted) return; setState(() => _atVenue = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Timecard filed · ${Fmt.money(card.amount)}'), action: SnackBarAction( label: 'Review', textColor: KrowPalette.yellow, onPressed: () => context.push('/timecard/${card.id}'), ), ), ); } } /// The big circular status dial: a countdown before the shift, a running /// stopwatch during it. class _ClockDial extends StatelessWidget { const _ClockDial({required this.shift, required this.atVenue}); final Shift shift; final bool atVenue; @override Widget build(BuildContext context) { final running = shift.status == ShiftStatus.active; final elapsed = running ? DateTime.now().difference(shift.clockIn!) : Duration.zero; final progress = running ? (elapsed.inSeconds / shift.end.difference(shift.start).inSeconds) .clamp(0.0, 1.0) : 0.0; return Center( child: SizedBox( width: 208, height: 208, child: Stack( alignment: Alignment.center, children: [ // The ring fills from zero when the shift starts, and the track // warms from sage to yellow as it does. TweenAnimationBuilder( tween: Tween(end: running ? progress : 0), duration: const Duration(milliseconds: 700), curve: Curves.easeOutCubic, builder: (context, value, _) => SizedBox( width: 208, height: 208, child: CircularProgressIndicator( value: running ? value : 1, strokeWidth: 12, strokeCap: StrokeCap.round, backgroundColor: const Color(0xFFDEEBE2), valueColor: AlwaysStoppedAnimation( running ? KrowPalette.yellow : const Color(0xFFDEEBE2), ), ), ), ), AnimatedScale( // A small swell on clock-in: the dial acknowledges the tap. scale: running ? 1 : 0.96, duration: const Duration(milliseconds: 420), curve: Curves.easeOutBack, child: Container( width: 176, height: 176, decoration: const BoxDecoration( color: KrowPalette.blue, shape: BoxShape.circle, ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ AnimatedSwitcher( duration: const Duration(milliseconds: 300), // Only the label swaps; the ticking digits must not // re-animate every second. child: Text( running ? Fmt.stopwatch(elapsed) : 'Clock in', key: ValueKey(running), style: TextStyle( fontSize: running ? 27 : 23, fontWeight: FontWeight.w700, letterSpacing: -0.6, color: Colors.white, ), ), ), const SizedBox(height: 6), Text( running ? 'ON SHIFT SINCE ${Fmt.time(shift.clockIn!)}' : Fmt.untilStart(shift.start).toUpperCase(), style: const TextStyle( fontSize: 10, letterSpacing: 1.3, fontWeight: FontWeight.w600, color: KrowPalette.yellow, ), ), ], ), ), ), ], ), ), ); } } /// Shows why the clock is locked, and lets the demo simulate arriving on site. class _GeofenceCard extends StatelessWidget { const _GeofenceCard({ required this.shift, required this.atVenue, required this.locationGranted, required this.onChanged, }); final Shift shift; final bool atVenue; final bool locationGranted; final ValueChanged onChanged; @override Widget build(BuildContext context) { if (shift.status == ShiftStatus.active) { return InfoBanner( 'Break of ${shift.breakMinutes} minutes is deducted automatically when ' 'you clock out.', ); } return KrowCard( padding: const EdgeInsets.fromLTRB(16, 8, 12, 8), child: Row( children: [ const Icon(LucideIcons.mapPin, size: 18, color: KrowPalette.blue), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( atVenue ? 'At the venue' : 'You are ${shift.distanceMiles} mi away', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, ), ), Text( locationGranted ? 'Get within 150 m to unlock the button' : 'Location is off - turn it on in Settings', style: const TextStyle(fontSize: 12, color: KrowPalette.mute), ), ], ), ), Switch(value: atVenue, onChanged: onChanged), ], ), ); } }