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