402 lines
14 KiB
Dart
402 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 '../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<ClockScreen> createState() => _ClockScreenState();
|
|
}
|
|
|
|
/// The two halves of the Clock tab, as the prototype splits them.
|
|
enum _ClockTab { today, timecards }
|
|
|
|
class _ClockScreenState extends ConsumerState<ClockScreen> {
|
|
_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<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),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|