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

398 lines
14 KiB
Dart

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 '../widgets/collapsing_header.dart';
import '../widgets/common.dart';
import '../widgets/shift_card.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<ClockScreen> createState() => _ClockScreenState();
}
class _ClockScreenState extends ConsumerState<ClockScreen> {
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: [
// Already compact, so it pins at one height rather than collapsing.
// Sized with headroom over the 58px of content, so a larger system
// text scale does not push it over.
KrowSliverHeader(
expandedHeight: 96,
collapsedHeight: 96,
builder: (context, t) => Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Fmt.dayFull(DateTime.now()).toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.5,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5),
),
),
const SizedBox(height: 4),
const Text(
'Clock in',
style: TextStyle(
fontSize: 27,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
color: Colors.white,
),
),
],
),
),
HeaderIconButton(
icon: LucideIcons.fileText,
tooltip: 'Timecards',
onPressed: () => context.push('/timecards'),
),
],
),
),
),
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}'),
),
const SizedBox(height: 14),
OutlinedButton.icon(
onPressed: () => context.push('/timecards'),
icon: const Icon(LucideIcons.fileText, size: 18),
label: const Text('View timecards'),
),
],
),
),
],
),
);
}
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<void> _clockOut(Shift shift) async {
final confirm = await showDialog<bool>(
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<double>(
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<bool> 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),
],
),
);
}
}