Initial commit: Krow Worker App

This commit is contained in:
R-Bharathraj
2026-09-17 22:26:11 +05:30
commit cafffd27e0
113 changed files with 13067 additions and 0 deletions

51
lib/core/formatters.dart Normal file
View File

@@ -0,0 +1,51 @@
import 'package:intl/intl.dart';
final _money = NumberFormat.currency(locale: 'en_US', symbol: r'$');
final _time = DateFormat('h:mm a');
final _dayFull = DateFormat('EEEE d MMMM');
final _dayShort = DateFormat('EEE, d MMM yyyy');
final _monthYear = DateFormat('MMMM yyyy');
abstract final class Fmt {
static String money(num v) => _money.format(v);
static String time(DateTime d) => _time.format(d);
static String window(DateTime start, DateTime end) =>
'${_time.format(start)} - ${_time.format(end)}';
static String dayFull(DateTime d) => _dayFull.format(d);
static String dayShort(DateTime d) => _dayShort.format(d);
static String monthYear(DateTime d) => _monthYear.format(d);
/// `5h 48m`, the form used on every hours figure.
static String duration(Duration d) {
final h = d.inHours;
final m = d.inMinutes.remainder(60);
if (h == 0) return '${m}m';
return '${h}h ${m.toString().padLeft(2, '0')}m';
}
/// `05:48:12`, for the running clock-in timer.
static String stopwatch(Duration d) {
final h = d.inHours.toString().padLeft(2, '0');
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$h:$m:$s';
}
/// A human countdown to a shift start: `in 41 min`, `in 3 hr`, `tomorrow`.
static String untilStart(DateTime start, {DateTime? now}) {
final from = now ?? DateTime.now();
final diff = start.difference(from);
if (diff.isNegative) return 'Started';
if (diff.inMinutes < 60) return 'In ${diff.inMinutes} min';
if (diff.inHours < 24) return 'In ${diff.inHours} hr';
return 'In ${diff.inDays} d';
}
static bool sameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
}

137
lib/core/router.dart Normal file
View File

@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../screens/auth_screens.dart';
import '../screens/availability_screen.dart';
import '../screens/clock_screen.dart';
import '../screens/compliance_screens.dart';
import '../screens/home_shell.dart';
import '../screens/onboarding_screens.dart';
import '../screens/pay_screens.dart';
import '../screens/profile_detail_screens.dart';
import '../screens/profile_screen.dart';
import '../screens/settings_screens.dart';
import '../screens/shift_detail_screen.dart';
import '../screens/shifts_screen.dart';
import '../screens/timecard_screens.dart';
import '../screens/today_screen.dart';
import '../state/compliance_controller.dart';
import 'transitions.dart';
final _rootKey = GlobalKey<NavigatorState>();
ComplianceSection _sectionFromName(String name) =>
ComplianceSection.values.firstWhere(
(s) => s.name == name,
orElse: () => ComplianceSection.documents,
);
/// A screen that replaces what came before it.
GoRoute _fadeRoute(String path, Widget Function() build) => GoRoute(
path: path,
pageBuilder: (_, state) => Transitions.fade(state, build()),
);
/// A screen pushed on top of the shell.
GoRoute _detailRoute(
String path,
Widget Function(GoRouterState) build, {
List<RouteBase> routes = const [],
}) => GoRoute(
path: path,
parentNavigatorKey: _rootKey,
pageBuilder: (_, state) => Transitions.push(state, build(state)),
routes: routes,
);
/// Sign-in lives outside the shell; the five tabs each keep their own stack,
/// and detail screens push over the whole shell.
final router = GoRouter(
navigatorKey: _rootKey,
initialLocation: '/',
routes: [
_fadeRoute('/', SplashScreen.new),
_fadeRoute('/onboarding', OnboardingScreen.new),
_fadeRoute('/welcome', WelcomeScreen.new),
_fadeRoute('/verified', VerifiedScreen.new),
_fadeRoute('/permissions', PermissionsScreen.new),
// The two sign-in steps are pushed, so back gestures read correctly.
_detailRoute('/phone', (_) => const PhoneScreen()),
_detailRoute('/otp', (_) => const OtpScreen()),
StatefulShellRoute.indexedStack(
builder: (_, _, shell) => HomeShell(navigationShell: shell),
branches: [
StatefulShellBranch(
routes: [
GoRoute(path: '/today', builder: (_, _) => const TodayScreen()),
],
),
StatefulShellBranch(
routes: [
GoRoute(path: '/shifts', builder: (_, _) => const ShiftsScreen()),
],
),
StatefulShellBranch(
routes: [
GoRoute(path: '/clock', builder: (_, _) => const ClockScreen()),
],
),
StatefulShellBranch(
routes: [GoRoute(path: '/pay', builder: (_, _) => const PayScreen())],
),
StatefulShellBranch(
routes: [
GoRoute(path: '/profile', builder: (_, _) => const ProfileScreen()),
],
),
],
),
_detailRoute(
'/shift/:id',
(state) => ShiftDetailScreen(shiftId: state.pathParameters['id']!),
),
_detailRoute('/availability', (_) => const AvailabilityScreen()),
_detailRoute('/timecards', (_) => const TimecardsScreen()),
_detailRoute(
'/timecard/:id',
(state) => TimecardReviewScreen(timecardId: state.pathParameters['id']!),
),
_detailRoute('/payroll', (_) => const PayrollScheduleScreen()),
_detailRoute(
'/payment/:id',
(state) => PaymentDetailScreen(shiftId: state.pathParameters['id']!),
),
_detailRoute(
'/compliance',
(_) => const ComplianceHubScreen(),
routes: [
_detailRoute(
':section',
(state) => ComplianceListScreen(
section: _sectionFromName(state.pathParameters['section']!),
),
routes: [
_detailRoute(
':itemId',
(state) => ComplianceDetailScreen(
section: _sectionFromName(state.pathParameters['section']!),
itemId: state.pathParameters['itemId']!,
),
),
],
),
],
),
_detailRoute('/emergency-contacts', (_) => const EmergencyContactsScreen()),
_detailRoute('/skills', (_) => const SkillsScreen()),
_detailRoute('/direct-deposit', (_) => const DirectDepositScreen()),
_detailRoute('/benefits', (_) => const BenefitsScreen()),
_detailRoute('/settings', (_) => const SettingsScreen()),
_detailRoute('/support', (_) => const SupportScreen()),
_detailRoute('/faqs', (_) => const FaqScreen()),
_detailRoute('/privacy', (_) => const PrivacyScreen()),
],
);

144
lib/core/theme.dart Normal file
View File

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// KROW brand palette: 286CP blue carries every surface and action, 106CP
/// yellow marks money and anything waiting on you, 621CP sage is the canvas.
abstract final class KrowPalette {
static const blue = Color(0xFF3752A8);
static const blueDeep = Color(0xFF22366F);
static const blueTint = Color(0xFFE1E6F4);
static const yellow = Color(0xFFF5C842);
static const yellowSoft = Color(0xFFF7E4AE);
static const sage = Color(0xFFE9F3EC);
static const ink = Color(0xFF26353E);
static const line = Color(0xFFCFDDD3);
static const mute = Color(0xFF65727A);
}
ThemeData buildKrowTheme() {
final scheme = ColorScheme.fromSeed(
seedColor: KrowPalette.blue,
primary: KrowPalette.blue,
onPrimary: Colors.white,
secondary: KrowPalette.yellow,
onSecondary: KrowPalette.ink,
surface: Colors.white,
onSurface: KrowPalette.ink,
brightness: Brightness.light,
);
final text = GoogleFonts.poppinsTextTheme().apply(
bodyColor: KrowPalette.ink,
displayColor: KrowPalette.ink,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
textTheme: text,
scaffoldBackgroundColor: KrowPalette.sage,
appBarTheme: AppBarTheme(
backgroundColor: KrowPalette.sage,
surfaceTintColor: Colors.transparent,
foregroundColor: KrowPalette.ink,
elevation: 0,
centerTitle: false,
titleTextStyle: GoogleFonts.poppins(
fontSize: 18,
fontWeight: FontWeight.w700,
color: KrowPalette.ink,
),
),
cardTheme: CardThemeData(
color: Colors.white,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: KrowPalette.line),
),
),
dividerTheme: const DividerThemeData(
color: KrowPalette.line,
thickness: 1,
space: 1,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
backgroundColor: KrowPalette.blue,
foregroundColor: Colors.white,
disabledBackgroundColor: const Color(0xFFD5E2D9),
disabledForegroundColor: const Color(0xFF949FA5),
textStyle: GoogleFonts.poppins(
fontSize: 15,
fontWeight: FontWeight.w600,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(52),
foregroundColor: KrowPalette.ink,
backgroundColor: Colors.white,
side: const BorderSide(color: KrowPalette.line),
textStyle: GoogleFonts.poppins(
fontSize: 15,
fontWeight: FontWeight.w600,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: KrowPalette.blue,
textStyle: GoogleFonts.poppins(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
hintStyle: GoogleFonts.poppins(
fontSize: 14,
color: const Color(0xFF949FA5),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: KrowPalette.line),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: KrowPalette.line),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: KrowPalette.blue, width: 1.6),
),
),
chipTheme: ChipThemeData(
backgroundColor: Colors.white,
side: const BorderSide(color: KrowPalette.line),
labelStyle: GoogleFonts.poppins(
fontSize: 12.5,
fontWeight: FontWeight.w500,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
// Bottom navigation is KrowNavBar, not Material's NavigationBar, so there
// is no navigationBarTheme to configure — its styling lives in the widget.
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: KrowPalette.ink,
contentTextStyle: GoogleFonts.poppins(
fontSize: 13.5,
color: Colors.white,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
);
}

84
lib/core/transitions.dart Normal file
View File

@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// Page transitions, kept in one place so every screen arrives the same way.
///
/// Two kinds only. Moving *deeper* (a shift, a document, a timecard) slides
/// forward, and the screen behind it slides back a little so the stack reads
/// as a stack. Moving *sideways* — between steps of sign-up, or replacing the
/// whole screen — cross-fades, because nothing is being pushed onto anything.
abstract final class Transitions {
static const _forward = Duration(milliseconds: 320);
static const _back = Duration(milliseconds: 260);
/// Pushed detail screens.
static CustomTransitionPage<T> push<T>(GoRouterState state, Widget child) {
return CustomTransitionPage<T>(
key: state.pageKey,
child: child,
transitionDuration: _forward,
reverseTransitionDuration: _back,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final incoming = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
// The outgoing screen eases back rather than sitting still, which is
// what gives the push its depth.
final outgoing = CurvedAnimation(
parent: secondaryAnimation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
// secondaryAnimation rests at 0 while this page is on top, so the
// resting state has to be `begin`. Reversing these two leaves every
// pushed screen sitting 12% off to the left.
return SlideTransition(
position: Tween(
begin: Offset.zero,
end: const Offset(-0.12, 0),
).animate(outgoing),
child: FadeTransition(
opacity: Tween(begin: 1.0, end: 0.0).animate(outgoing),
child: SlideTransition(
position: Tween(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(incoming),
child: child,
),
),
);
},
);
}
/// Screens that replace what came before: splash, onboarding, sign-in steps.
static CustomTransitionPage<T> fade<T>(GoRouterState state, Widget child) {
return CustomTransitionPage<T>(
key: state.pageKey,
child: child,
transitionDuration: _forward,
reverseTransitionDuration: _back,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
);
return FadeTransition(
opacity: curved,
// A few pixels of rise stops the cross-fade feeling inert.
child: SlideTransition(
position: Tween(
begin: const Offset(0, 0.015),
end: Offset.zero,
).animate(curved),
child: child,
),
);
},
);
}
}

73
lib/main.dart Normal file
View File

@@ -0,0 +1,73 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'core/router.dart';
import 'core/theme.dart';
import 'state/session_controller.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
),
);
runApp(
ProviderScope(
overrides: [prefsProvider.overrideWithValue(prefs)],
child: const KrowApp(),
),
);
}
class KrowApp extends StatelessWidget {
const KrowApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'KROW Worker',
debugShowCheckedModeBanner: false,
theme: buildKrowTheme(),
scrollBehavior: const KrowScrollBehavior(),
routerConfig: router,
);
}
}
/// Scrolling feel, applied everywhere.
class KrowScrollBehavior extends MaterialScrollBehavior {
const KrowScrollBehavior();
/// Bouncing on every platform. The lists here sit under a pinned header, and
/// a rubber-band edge shows you have reached the end of the content rather
/// than the end of the screen — clamping just stops dead against the header.
@override
ScrollPhysics getScrollPhysics(BuildContext context) =>
const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics());
/// Let the web and desktop builds be dragged with a mouse or trackpad, not
/// only scrolled with a wheel.
@override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
PointerDeviceKind.stylus,
};
/// The bounce is the end-of-list signal; a glow on top of it is noise.
@override
Widget buildOverscrollIndicator(
BuildContext context,
Widget child,
ScrollableDetails details,
) => child;
}

View File

@@ -0,0 +1,21 @@
enum DayBlock { morning, afternoon, evening }
extension DayBlockX on DayBlock {
String get label => switch (this) {
DayBlock.morning => 'Morning',
DayBlock.afternoon => 'Afternoon',
DayBlock.evening => 'Evening',
};
String get window => switch (this) {
DayBlock.morning => '04:00 - 12:00',
DayBlock.afternoon => '12:00 - 18:00',
DayBlock.evening => '18:00 - 24:00',
};
int get hours => switch (this) {
DayBlock.morning => 8,
DayBlock.afternoon => 6,
DayBlock.evening => 6,
};
}

View File

@@ -0,0 +1,82 @@
enum ComplianceState { verified, pending, missing }
extension ComplianceStateX on ComplianceState {
String get label => switch (this) {
ComplianceState.verified => 'Verified',
ComplianceState.pending => 'Pending',
ComplianceState.missing => 'Missing',
};
}
/// A single thing a business must see before you can work: a document, a
/// certificate, a tax form or a piece of attire.
class ComplianceItem {
ComplianceItem({
required this.id,
required this.title,
required this.subtitle,
required this.state,
this.statusLabel,
this.fileName,
this.fileMeta,
this.expires,
this.required_ = true,
});
final String id;
final String title;
final String subtitle;
final ComplianceState state;
final String? statusLabel;
final String? fileName;
final String? fileMeta;
final DateTime? expires;
final bool required_;
String get badge => statusLabel ?? state.label;
ComplianceItem copyWith({
ComplianceState? state,
String? statusLabel,
String? fileName,
String? fileMeta,
DateTime? expires,
bool clearFile = false,
}) => ComplianceItem(
id: id,
title: title,
subtitle: subtitle,
state: state ?? this.state,
statusLabel: statusLabel ?? this.statusLabel,
fileName: clearFile ? null : (fileName ?? this.fileName),
fileMeta: clearFile ? null : (fileMeta ?? this.fileMeta),
expires: expires ?? this.expires,
required_: required_,
);
}
class EmergencyContact {
EmergencyContact({
required this.name,
required this.phone,
required this.relationship,
});
final String name;
final String phone;
final String relationship;
}
class BankAccount {
BankAccount({
required this.bankName,
required this.routing,
required this.accountLast4,
required this.accountType,
});
final String bankName;
final String routing;
final String accountLast4;
final String accountType;
}

95
lib/models/shift.dart Normal file
View File

@@ -0,0 +1,95 @@
enum ShiftStatus { open, booked, active, completed, cancelled, noShow }
extension ShiftStatusX on ShiftStatus {
String get label => switch (this) {
ShiftStatus.open => 'Open',
ShiftStatus.booked => 'Confirmed',
ShiftStatus.active => 'In progress',
ShiftStatus.completed => 'Completed',
ShiftStatus.cancelled => 'Cancelled',
ShiftStatus.noShow => 'No show',
};
}
class Shift {
Shift({
required this.id,
required this.role,
required this.business,
required this.venue,
required this.address,
required this.start,
required this.end,
required this.rate,
required this.status,
this.breakMinutes = 30,
this.distanceMiles = 2.4,
this.manager,
this.clockIn,
this.clockOut,
this.requirements = const [],
});
final String id;
final String role;
final String business;
final String venue;
final String address;
final DateTime start;
final DateTime end;
final double rate;
final ShiftStatus status;
final int breakMinutes;
final double distanceMiles;
final String? manager;
final DateTime? clockIn;
final DateTime? clockOut;
final List<String> requirements;
/// Paid duration: the scheduled window less the unpaid break.
Duration get scheduledDuration =>
end.difference(start) - Duration(minutes: breakMinutes);
/// What was actually worked, once clocked out.
Duration? get workedDuration {
if (clockIn == null || clockOut == null) return null;
final d = clockOut!.difference(clockIn!) - Duration(minutes: breakMinutes);
return d.isNegative ? Duration.zero : d;
}
double get scheduledPay => rate * scheduledDuration.inMinutes / 60;
/// Gross, pre-tax. Falls back to the scheduled figure until clocked out.
double get pay {
if (status == ShiftStatus.cancelled || status == ShiftStatus.noShow) {
return 0;
}
final worked = workedDuration;
if (worked != null) return rate * worked.inMinutes / 60;
return scheduledPay;
}
String get logoLetter => business.isEmpty ? '?' : business[0].toUpperCase();
Shift copyWith({
ShiftStatus? status,
DateTime? clockIn,
DateTime? clockOut,
}) => Shift(
id: id,
role: role,
business: business,
venue: venue,
address: address,
start: start,
end: end,
rate: rate,
status: status ?? this.status,
breakMinutes: breakMinutes,
distanceMiles: distanceMiles,
manager: manager,
clockIn: clockIn ?? this.clockIn,
clockOut: clockOut ?? this.clockOut,
requirements: requirements,
);
}

70
lib/models/timecard.dart Normal file
View File

@@ -0,0 +1,70 @@
enum TimecardStatus { needsReview, approved, disputed }
extension TimecardStatusX on TimecardStatus {
String get label => switch (this) {
TimecardStatus.needsReview => 'Needs review',
TimecardStatus.approved => 'Approved',
TimecardStatus.disputed => 'Disputed',
};
}
class Timecard {
Timecard({
required this.id,
required this.shiftId,
required this.role,
required this.business,
required this.scheduledStart,
required this.scheduledEnd,
required this.clockIn,
required this.clockOut,
required this.breakMinutes,
required this.rate,
required this.status,
required this.submittedAt,
this.disputeNote,
});
final String id;
final String shiftId;
final String role;
final String business;
final DateTime scheduledStart;
final DateTime scheduledEnd;
final DateTime clockIn;
final DateTime clockOut;
final int breakMinutes;
final double rate;
final TimecardStatus status;
final DateTime submittedAt;
final String? disputeNote;
DateTime get date =>
DateTime(scheduledStart.year, scheduledStart.month, scheduledStart.day);
Duration get worked {
final d = clockOut.difference(clockIn) - Duration(minutes: breakMinutes);
return d.isNegative ? Duration.zero : d;
}
double get amount => rate * worked.inMinutes / 60;
/// Minutes late (positive) or early (negative) against the schedule.
int get varianceMinutes => clockIn.difference(scheduledStart).inMinutes;
Timecard copyWith({TimecardStatus? status, String? disputeNote}) => Timecard(
id: id,
shiftId: shiftId,
role: role,
business: business,
scheduledStart: scheduledStart,
scheduledEnd: scheduledEnd,
clockIn: clockIn,
clockOut: clockOut,
breakMinutes: breakMinutes,
rate: rate,
status: status ?? this.status,
submittedAt: submittedAt,
disputeNote: disputeNote ?? this.disputeNote,
);
}

57
lib/models/worker.dart Normal file
View File

@@ -0,0 +1,57 @@
class Worker {
const Worker({
required this.name,
required this.role,
required this.city,
required this.phone,
this.shiftsWorked = 0,
this.rating = 5.0,
this.onTimePercent = 100,
this.noShows = 0,
this.cancellations = 0,
});
final String name;
final String role;
final String city;
final String phone;
final int shiftsWorked;
final double rating;
final int onTimePercent;
final int noShows;
final int cancellations;
/// Stay above 45% to keep picking up shifts. On-time arrivals move it most.
int get trustIndex {
final base = 40 + onTimePercent ~/ 2 - noShows * 15 - cancellations * 5;
return base.clamp(0, 100);
}
String get initials => name
.split(' ')
.where((p) => p.isNotEmpty)
.map((p) => p[0].toUpperCase())
.take(2)
.join();
Worker copyWith({
String? name,
String? role,
String? city,
String? phone,
int? shiftsWorked,
int? onTimePercent,
int? noShows,
int? cancellations,
}) => Worker(
name: name ?? this.name,
role: role ?? this.role,
city: city ?? this.city,
phone: phone ?? this.phone,
shiftsWorked: shiftsWorked ?? this.shiftsWorked,
rating: rating,
onTimePercent: onTimePercent ?? this.onTimePercent,
noShows: noShows ?? this.noShows,
cancellations: cancellations ?? this.cancellations,
);
}

View File

@@ -0,0 +1,650 @@
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/theme.dart';
import '../state/session_controller.dart';
import '../widgets/common.dart';
/// Step 1 of 2: the mobile number a shift manager would text.
class PhoneScreen extends ConsumerStatefulWidget {
const PhoneScreen({super.key});
@override
ConsumerState<PhoneScreen> createState() => _PhoneScreenState();
}
class _PhoneScreenState extends ConsumerState<PhoneScreen> {
final _controller = TextEditingController();
bool get _valid =>
_controller.text.replaceAll(RegExp(r'\D'), '').length >= 10;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _send() {
final phone = '+1 ${_controller.text.trim()}';
ref.read(sessionProvider.notifier).sendCode(phone);
context.push('/otp');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(leading: const BackButton()),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionLabel('Step 1 of 2'),
const Text(
"What's your\nmobile number?",
style: TextStyle(
fontSize: 29,
fontWeight: FontWeight.w700,
height: 1.1,
letterSpacing: -1,
),
),
const SizedBox(height: 10),
const Text(
'Shift managers text you here, and we will send a 6-digit code '
'to confirm it is yours.',
style: TextStyle(
fontSize: 14,
height: 1.55,
color: KrowPalette.mute,
),
),
const SizedBox(height: 24),
TextField(
controller: _controller,
autofocus: true,
keyboardType: TextInputType.phone,
onChanged: (_) => setState(() {}),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9 ]')),
LengthLimitingTextInputFormatter(14),
],
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
),
decoration: const InputDecoration(
hintText: '408 555 0148',
prefixIcon: Padding(
padding: EdgeInsets.fromLTRB(16, 15, 10, 15),
child: Text(
'+1',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
prefixIconConstraints: BoxConstraints(minWidth: 0),
),
),
const SizedBox(height: 10),
const Text(
'Message and data rates may apply. Reply STOP any time to opt '
'out.',
style: TextStyle(
fontSize: 12,
height: 1.5,
color: Color(0xFF949FA5),
),
),
const SizedBox(height: 26),
FilledButton(
onPressed: _valid ? _send : null,
child: const Text('Send code'),
),
const SizedBox(height: 10),
Center(
child: TextButton(
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Email sign-in is coming soon'),
),
),
child: const Text('Verify with email instead'),
),
),
],
),
),
),
);
}
}
/// Step 2 of 2: the code, with a resend countdown and a limited try budget.
class OtpScreen extends ConsumerStatefulWidget {
const OtpScreen({super.key});
@override
ConsumerState<OtpScreen> createState() => _OtpScreenState();
}
class _OtpScreenState extends ConsumerState<OtpScreen> {
final _controller = TextEditingController();
Timer? _timer;
int _secondsLeft = 30;
int _triesLeft = 3;
String? _error;
@override
void initState() {
super.initState();
_startCountdown();
}
@override
void dispose() {
_timer?.cancel();
_controller.dispose();
super.dispose();
}
void _startCountdown() {
_timer?.cancel();
setState(() => _secondsLeft = 30);
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
if (!mounted) return;
if (_secondsLeft <= 1) {
t.cancel();
setState(() => _secondsLeft = 0);
} else {
setState(() => _secondsLeft--);
}
});
}
void _verify() {
final ok = ref.read(sessionProvider.notifier).verifyCode(_controller.text);
if (ok) {
context.go('/verified');
return;
}
setState(() {
_triesLeft--;
_error = _triesLeft > 0
? 'That code does not match. Check the most recent text - older '
'codes stop working. $_triesLeft tries left.'
: 'Too many attempts. Request a new code to try again.';
});
}
@override
Widget build(BuildContext context) {
final phone = ref.watch(sessionProvider).phone ?? '';
final canVerify = _controller.text.length == 6 && _triesLeft > 0;
return Scaffold(
appBar: AppBar(leading: const BackButton()),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionLabel('Step 2 of 2'),
const Text(
'Enter the code',
style: TextStyle(
fontSize: 29,
fontWeight: FontWeight.w700,
letterSpacing: -1,
),
),
const SizedBox(height: 10),
Row(
children: [
Text(
'Texted to $phone',
style: const TextStyle(
fontSize: 13.5,
color: KrowPalette.mute,
),
),
TextButton(
onPressed: () => context.pop(),
child: const Text('Change'),
),
],
),
const SizedBox(height: 8),
TextField(
controller: _controller,
autofocus: true,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
onChanged: (_) => setState(() => _error = null),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.w700,
letterSpacing: 14,
),
decoration: InputDecoration(
counterText: '',
hintText: '------',
errorText: _error,
errorMaxLines: 3,
hintStyle: const TextStyle(
fontSize: 30,
letterSpacing: 14,
color: Color(0xFFC4D4C9),
),
),
),
const SizedBox(height: 14),
const InfoBanner('Demo build: the code is always 123456.'),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Didn't get the text?",
style: TextStyle(fontSize: 13, color: KrowPalette.mute),
),
if (_secondsLeft > 0)
Text(
'Resend in 0:${_secondsLeft.toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 12.5,
color: Color(0xFF949FA5),
),
)
else
TextButton(
onPressed: () {
setState(() {
_triesLeft = 3;
_error = null;
});
_startCountdown();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('New code sent')),
);
},
child: const Text('Resend code'),
),
],
),
const SizedBox(height: 18),
FilledButton(
onPressed: canVerify ? _verify : null,
child: const Text('Verify number'),
),
],
),
),
),
);
}
}
/// Confirmation, plus the two things still standing between the worker and
/// their first shift.
class VerifiedScreen extends ConsumerWidget {
const VerifiedScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final phone = ref.watch(sessionProvider).phone ?? '';
return Scaffold(
backgroundColor: KrowPalette.blue,
body: Column(
children: [
Expanded(
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 26),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 86,
height: 86,
decoration: const BoxDecoration(
color: KrowPalette.yellow,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.check,
size: 40,
color: KrowPalette.ink,
),
),
const SizedBox(height: 26),
const Text(
'Number verified',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.w700,
letterSpacing: -1,
color: Colors.white,
),
),
const SizedBox(height: 10),
Text(
phone,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: KrowPalette.yellow,
),
),
const SizedBox(height: 14),
Text(
'Two things left before you can accept shifts.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
height: 1.55,
color: Colors.white.withValues(alpha: 0.62),
),
),
const SizedBox(height: 26),
const _TodoRow(
title: 'Upload your ID',
subtitle: 'State ID or passport',
badge: '2 min',
),
const SizedBox(height: 10),
const _TodoRow(
title: 'Set your availability',
subtitle: 'So we only send shifts you can work',
badge: '1 min',
),
],
),
),
),
),
Container(
width: double.infinity,
decoration: const BoxDecoration(
color: KrowPalette.sage,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 22, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton(
onPressed: () => context.go('/permissions'),
child: const Text('Continue'),
),
],
),
),
),
),
],
),
);
}
}
class _TodoRow extends StatelessWidget {
const _TodoRow({
required this.title,
required this.subtitle,
required this.badge,
});
final String title;
final String subtitle;
final String badge;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white24),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 12.5,
color: Colors.white.withValues(alpha: 0.55),
),
),
],
),
),
StatusChip(badge, tone: ChipTone.yellow),
],
),
);
}
}
class _Permission {
const _Permission(this.icon, this.step, this.title, this.body, this.reasons);
final IconData icon;
final String step;
final String title;
final String body;
final List<String> reasons;
}
/// The three-step permission run. Each grant is recorded in the session so the
/// app knows what it is allowed to do later.
class PermissionsScreen extends ConsumerStatefulWidget {
const PermissionsScreen({super.key});
@override
ConsumerState<PermissionsScreen> createState() => _PermissionsScreenState();
}
class _PermissionsScreenState extends ConsumerState<PermissionsScreen> {
int _step = 0;
static const _permissions = [
_Permission(
LucideIcons.mapPin,
'Step 1 of 3',
'Let KROW see where you are',
'Clock-in is locked until you are within 150 m of the venue. Without '
'location you cannot start a shift.',
[
'Unlocks clock in when you arrive',
'Sorts shifts by distance from you',
'Only read while a shift is active',
],
),
_Permission(
LucideIcons.bell,
'Step 2 of 3',
'Turn on notifications',
'Shift offers are first-come. If your phone is silent someone else takes '
'the shift.',
[
'New shift offers matched to you',
'Clock-in reminders 30 min before',
'Pay alerts when money lands',
],
),
_Permission(
LucideIcons.camera,
'Step 3 of 3',
'Camera and photos',
'Attire and document checks need a photo of the real item. Nothing is '
'uploaded until you tap send.',
[
'Photograph your uniform items',
'Capture ID and certificates',
'Nothing is stored on our servers unsent',
],
),
];
void _advance({required bool granted}) {
final notifier = ref.read(sessionProvider.notifier);
if (granted) {
switch (_step) {
case 0:
notifier.grantLocation();
case 1:
notifier.grantNotifications();
case 2:
notifier.grantCamera();
}
}
if (_step == _permissions.length - 1) {
context.go('/today');
} else {
setState(() => _step++);
}
}
@override
Widget build(BuildContext context) {
final p = _permissions[_step];
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 24, 22, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// The copy scrolls if it has to; the actions stay pinned, so a
// short screen or a large text scale can never push them off.
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: KrowPalette.blue,
borderRadius: BorderRadius.circular(18),
),
child: Icon(p.icon, size: 27, color: Colors.white),
),
SectionLabel(p.step),
Text(
p.title,
style: const TextStyle(
fontSize: 25,
fontWeight: FontWeight.w700,
height: 1.15,
letterSpacing: -0.9,
),
),
const SizedBox(height: 10),
Text(
p.body,
style: const TextStyle(
fontSize: 14,
height: 1.55,
color: KrowPalette.mute,
),
),
const SizedBox(height: 18),
for (final reason in p.reasons)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 20,
height: 20,
// Nudged to sit on the first line's optical
// centre, so wrapped reasons stay top-aligned.
margin: const EdgeInsets.only(top: 1),
decoration: BoxDecoration(
color: KrowPalette.blueTint,
borderRadius: BorderRadius.circular(7),
),
child: const Icon(
LucideIcons.check,
size: 12,
color: KrowPalette.blue,
),
),
const SizedBox(width: 11),
Expanded(
child: Text(
reason,
style: const TextStyle(
fontSize: 13,
height: 1.45,
color: KrowPalette.mute,
),
),
),
],
),
),
],
),
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => _advance(granted: true),
child: Text(switch (_step) {
0 => 'Allow location',
1 => 'Turn on notifications',
_ => 'Allow camera',
}),
),
const SizedBox(height: 4),
// Full width so the secondary action shares the primary's edges
// rather than hugging the left.
TextButton(
onPressed: () => _advance(granted: false),
style: TextButton.styleFrom(
minimumSize: const Size.fromHeight(44),
),
child: const Text('Not now'),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,337 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/formatters.dart';
import '../core/theme.dart';
import '../models/availability.dart';
import '../state/availability_controller.dart';
import '../widgets/common.dart';
import '../widgets/week_strip.dart';
/// Auto-Match only offers shifts inside the blocks switched on here.
class AvailabilityScreen extends ConsumerStatefulWidget {
const AvailabilityScreen({super.key});
@override
ConsumerState<AvailabilityScreen> createState() => _AvailabilityScreenState();
}
class _AvailabilityScreenState extends ConsumerState<AvailabilityScreen> {
DateTime _selected = DateTime.now();
List<DateTime> get _week {
final now = DateTime.now();
return [for (var i = 0; i < 7; i++) now.add(Duration(days: i))];
}
@override
Widget build(BuildContext context) {
final controller = ref.watch(availabilityProvider.notifier);
ref.watch(availabilityProvider);
final blocks = controller.blocksFor(_selected);
final hours = blocks.fold<int>(0, (sum, b) => sum + b.hours);
final marked = <int>{
for (var i = 0; i < _week.length; i++)
if (controller.blocksFor(_week[i]).isNotEmpty) i,
};
return Scaffold(
body: ListView(
padding: EdgeInsets.zero,
children: [
BrandHeader(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
HeaderIconButton(
icon: LucideIcons.arrowLeft,
onPressed: () => Navigator.of(context).pop(),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Availability',
style: TextStyle(
fontSize: 25,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
color: Colors.white,
),
),
),
],
),
const SizedBox(height: 18),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_Preset(
label: 'All week',
onTap: () =>
controller.applyPreset(_week, test: (_) => true),
),
_Preset(
label: 'Weekdays',
onTap: () => controller.applyPreset(
_week,
test: (d) => d.weekday <= DateTime.friday,
),
),
_Preset(
label: 'Weekends',
onTap: () => controller.applyPreset(
_week,
test: (d) => d.weekday >= DateTime.saturday,
),
),
_Preset(
label: 'Clear',
accent: true,
onTap: () => controller.clearWeek(_week),
),
],
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
WeekStrip(
days: _week,
selected: _selected,
onSelect: (d) => setState(() => _selected = d),
marked: marked,
),
const SizedBox(height: 16),
KrowCard(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Fmt.dayShort(_selected),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
const SizedBox(height: 2),
Text(
blocks.isEmpty
? 'Marked as a day off'
: 'Available · $hours hrs',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w500,
color: blocks.isEmpty
? KrowPalette.mute
: KrowPalette.blue,
),
),
],
),
),
Switch(
value: blocks.isNotEmpty,
onChanged: (v) =>
controller.setDayAvailable(_selected, v),
),
],
),
const SizedBox(height: 14),
for (final block in DayBlock.values)
Padding(
padding: const EdgeInsets.only(bottom: 9),
child: _BlockRow(
block: block,
on: blocks.contains(block),
onTap: () =>
controller.toggleBlock(_selected, block),
),
),
],
),
),
const SizedBox(height: 14),
InfoBanner(
blocks.isEmpty
? 'With every block off, Auto-Match will not send you '
'anything on this day.'
: 'Saved. Auto-Match will only offer you shifts inside '
'these blocks.',
icon: blocks.isEmpty ? null : LucideIcons.check,
),
],
),
),
],
),
);
}
}
class _Preset extends StatelessWidget {
const _Preset({
required this.label,
required this.onTap,
this.accent = false,
});
final String label;
final VoidCallback onTap;
final bool accent;
@override
Widget build(BuildContext context) {
// Built by hand rather than as a Chip: the app's ChipTheme paints a white
// fill, which would hide white-on-blue header text.
return Material(
color: Colors.white.withValues(alpha: accent ? 0.06 : 0.16),
borderRadius: BorderRadius.circular(9),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(9),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
child: Text(
label,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: accent ? KrowPalette.yellow : Colors.white,
),
),
),
),
);
}
}
class _BlockRow extends StatelessWidget {
const _BlockRow({required this.block, required this.on, required this.onTap});
final DayBlock block;
final bool on;
final VoidCallback onTap;
IconData get _icon => switch (block) {
DayBlock.morning => LucideIcons.sunrise,
DayBlock.afternoon => LucideIcons.sun,
DayBlock.evening => LucideIcons.moon,
};
@override
Widget build(BuildContext context) {
const duration = Duration(milliseconds: 260);
const curve = Curves.easeOutCubic;
return GestureDetector(
onTap: onTap,
// Every part of the row animates together, so switching a block on is a
// single motion rather than four things changing at once.
child: AnimatedContainer(
duration: duration,
curve: curve,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: on ? KrowPalette.blueTint : const Color(0xFFE5F0E8),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: on
? KrowPalette.blue.withValues(alpha: 0.3)
: Colors.transparent,
),
),
child: Row(
children: [
AnimatedContainer(
duration: duration,
curve: curve,
width: 38,
height: 38,
decoration: BoxDecoration(
color: on ? KrowPalette.blue : Colors.white,
borderRadius: BorderRadius.circular(11),
border: Border.all(
color: on ? KrowPalette.blue : KrowPalette.line,
),
),
child: Icon(
_icon,
size: 18,
color: on ? Colors.white : const Color(0xFF949FA5),
),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedDefaultTextStyle(
duration: duration,
curve: curve,
style: TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: on ? KrowPalette.ink : KrowPalette.mute,
),
child: Text(block.label),
),
AnimatedDefaultTextStyle(
duration: duration,
curve: curve,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: on ? KrowPalette.blue : const Color(0xFF949FA5),
),
child: Text(block.window),
),
],
),
),
AnimatedContainer(
duration: duration,
curve: curve,
width: 24,
height: 24,
decoration: BoxDecoration(
color: on ? KrowPalette.blue : Colors.transparent,
borderRadius: BorderRadius.circular(7),
border: Border.all(
color: on ? KrowPalette.blue : const Color(0xFFC4D4C9),
width: 1.5,
),
),
child: AnimatedScale(
scale: on ? 1 : 0,
duration: duration,
curve: Curves.easeOutBack,
child: const Icon(
LucideIcons.check,
size: 14,
color: Colors.white,
),
),
),
],
),
),
);
}
}

View 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),
],
),
);
}
}

View File

@@ -0,0 +1,565 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/theme.dart';
import '../models/compliance.dart';
import '../state/compliance_controller.dart';
import '../widgets/common.dart';
IconData _sectionIcon(ComplianceSection s) => switch (s) {
ComplianceSection.documents => LucideIcons.fileText,
ComplianceSection.certificates => LucideIcons.award,
ComplianceSection.taxForms => LucideIcons.receipt,
ComplianceSection.wardrobe => LucideIcons.shirt,
};
ChipTone _toneFor(ComplianceState state) => switch (state) {
ComplianceState.verified => ChipTone.neutral,
ComplianceState.pending => ChipTone.blue,
ComplianceState.missing => ChipTone.yellow,
};
/// Everything a business must see before you can work.
class ComplianceHubScreen extends ConsumerWidget {
const ComplianceHubScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final (done, total) = ref.watch(complianceProgressProvider);
final remaining = total - done;
return Scaffold(
body: ListView(
padding: EdgeInsets.zero,
children: [
BrandHeader(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
HeaderIconButton(
icon: LucideIcons.arrowLeft,
onPressed: () => context.pop(),
),
StatusChip(
remaining == 0 ? 'All clear' : '$remaining left',
tone: remaining == 0 ? ChipTone.neutral : ChipTone.yellow,
),
],
),
const SizedBox(height: 18),
const Text(
'Compliance',
style: TextStyle(
fontSize: 27,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
color: Colors.white,
),
),
const SizedBox(height: 5),
Text(
'Everything a business must see before you work.',
style: TextStyle(
fontSize: 13.5,
color: Colors.white.withValues(alpha: 0.6),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
KrowCard(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Overall',
style: TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
),
),
Text(
'$done of $total',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 11),
KrowProgressBar(
total == 0 ? 1 : done / total,
color: done == total
? KrowPalette.blue
: KrowPalette.yellow,
),
],
),
),
const SectionLabel('Sections'),
TileGroup(
children: [
for (final section in ComplianceSection.values)
Consumer(
builder: (context, ref, _) {
final (d, t) = ref.watch(
sectionProgressProvider(section),
);
return NavTile(
icon: _sectionIcon(section),
title: section.title,
subtitle: section.subtitle,
badge: StatusChip(
'$d of $t',
tone: d == t ? ChipTone.neutral : ChipTone.yellow,
),
onTap: () =>
context.push('/compliance/${section.name}'),
);
},
),
],
),
const SizedBox(height: 16),
const InfoBanner(
'Documents are shared with a business only when you accept '
'one of their shifts.',
),
],
),
),
],
),
);
}
}
/// One compliance section: documents, certificates, tax forms or attire.
class ComplianceListScreen extends ConsumerStatefulWidget {
const ComplianceListScreen({super.key, required this.section});
final ComplianceSection section;
@override
ConsumerState<ComplianceListScreen> createState() =>
_ComplianceListScreenState();
}
class _ComplianceListScreenState extends ConsumerState<ComplianceListScreen> {
String _filter = 'All';
String _query = '';
static const _filters = ['All', 'Verified', 'Pending', 'Missing'];
@override
Widget build(BuildContext context) {
var items = ref.watch(complianceProvider).section(widget.section);
final (done, total) = ref.watch(sectionProgressProvider(widget.section));
if (_query.isNotEmpty) {
final q = _query.toLowerCase();
items = items.where((i) => i.title.toLowerCase().contains(q)).toList();
}
items = switch (_filter) {
'Verified' =>
items.where((i) => i.state == ComplianceState.verified).toList(),
'Pending' =>
items.where((i) => i.state == ComplianceState.pending).toList(),
'Missing' =>
items.where((i) => i.state == ComplianceState.missing).toList(),
_ => items,
};
final required = items.where((i) => i.required_).toList();
final optional = items.where((i) => !i.required_).toList();
return Scaffold(
appBar: AppBar(
title: Text(widget.section.title),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: StatusChip(
'$done of $total',
tone: done == total ? ChipTone.neutral : ChipTone.yellow,
),
),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
TextField(
onChanged: (v) => setState(() => _query = v),
decoration: InputDecoration(
hintText: 'Search ${widget.section.title.toLowerCase()}',
prefixIcon: const Icon(LucideIcons.search, size: 18),
),
),
const SizedBox(height: 14),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final f in _filters)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(f),
selected: f == _filter,
showCheckmark: false,
selectedColor: KrowPalette.blue,
labelStyle: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w500,
color: f == _filter ? Colors.white : KrowPalette.mute,
),
onSelected: (_) => setState(() => _filter = f),
),
),
],
),
),
if (required.isNotEmpty) ...[
const SectionLabel('Required'),
TileGroup(
children: [
for (final item in required)
_ItemTile(section: widget.section, item: item),
],
),
],
if (optional.isNotEmpty) ...[
const SectionLabel('Unlocks more shifts'),
TileGroup(
children: [
for (final item in optional)
_ItemTile(section: widget.section, item: item),
],
),
],
if (required.isEmpty && optional.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 24),
child: EmptyState(
icon: LucideIcons.search,
title: 'Nothing here',
message: 'Try a different filter.',
),
),
],
),
);
}
}
class _ItemTile extends StatelessWidget {
const _ItemTile({required this.section, required this.item});
final ComplianceSection section;
final ComplianceItem item;
@override
Widget build(BuildContext context) {
return NavTile(
icon: _sectionIcon(section),
title: item.title,
subtitle: item.subtitle,
badge: StatusChip(item.badge, tone: _toneFor(item.state)),
onTap: () => context.push('/compliance/${section.name}/${item.id}'),
);
}
}
/// A single item: the file on record, its expiry, and the actions on it.
class ComplianceDetailScreen extends ConsumerWidget {
const ComplianceDetailScreen({
super.key,
required this.section,
required this.itemId,
});
final ComplianceSection section;
final String itemId;
Future<void> _upload(BuildContext context, WidgetRef ref) async {
final choice = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.camera),
title: const Text('Take a photo'),
subtitle: const Text('It has to be the real item, worn by you'),
onTap: () => Navigator.pop(context, 'camera'),
),
ListTile(
leading: const Icon(LucideIcons.image),
title: const Text('Choose from library'),
onTap: () => Navigator.pop(context, 'library'),
),
ListTile(
leading: const Icon(LucideIcons.file),
title: const Text('Pick a PDF'),
onTap: () => Navigator.pop(context, 'file'),
),
],
),
),
);
if (choice == null || !context.mounted) return;
final ext = choice == 'file' ? 'pdf' : 'jpg';
final slug = itemId.toLowerCase();
ref
.read(complianceProvider.notifier)
.uploadFile(section, itemId, 'krow-$slug.$ext');
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Uploaded - sent for review')));
}
Future<void> _remove(BuildContext context, WidgetRef ref) async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove this file?'),
content: const Text(
'Your account goes on hold for anything that needs it until a '
'replacement is verified.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Keep it'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
child: const Text('Remove'),
),
],
),
);
if (ok != true || !context.mounted) return;
ref.read(complianceProvider.notifier).removeFile(section, itemId);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('File removed')));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(complianceProvider);
final item = ref.read(complianceProvider.notifier).find(section, itemId);
if (item == null) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: Text('Item not found.')),
);
}
final d = DateFormat('d MMM yyyy');
return Scaffold(
appBar: AppBar(
title: Text(section.title),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: StatusChip(item.badge, tone: _toneFor(item.state)),
),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
Text(
item.title,
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
),
),
const SizedBox(height: 6),
Text(
item.subtitle,
style: const TextStyle(fontSize: 13.5, color: KrowPalette.mute),
),
if (section == ComplianceSection.wardrobe) ...[
const SizedBox(height: 16),
const InfoBanner(
'The photo has to be you wearing the item. Catalogue shots and '
'images pulled from the web get rejected.',
warning: true,
),
],
const SectionLabel('File on record'),
if (item.fileName == null)
EmptyState(
icon: LucideIcons.upload,
title: 'Nothing uploaded',
message: 'JPEG, PNG or PDF, up to 10 MB.',
action: FilledButton.icon(
onPressed: () => _upload(context, ref),
icon: const Icon(LucideIcons.plus, size: 18),
label: const Text('Add a file'),
),
)
else
KrowCard(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFDEEBE2),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
LucideIcons.fileText,
size: 20,
color: Color(0xFF949FA5),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.fileName!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3),
Text(
item.fileMeta ?? '',
style: const TextStyle(
fontSize: 11.5,
color: Color(0xFF949FA5),
),
),
],
),
),
IconButton(
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Preview opens the file')),
),
icon: const Icon(LucideIcons.eye, size: 18),
),
],
),
),
const SectionLabel('Details'),
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
DetailRow('Status', item.badge),
const Divider(height: 1),
_ExpiryRow(section: section, item: item, format: d),
const Divider(height: 1),
const DetailRow('Shared with', '2 businesses'),
],
),
),
const SizedBox(height: 16),
InfoBanner(
item.required_
? 'Removing this puts your account on hold until a new file is '
'verified. You cannot work in the meantime.'
: 'Optional, but adding it opens up more shifts near you.',
),
const SizedBox(height: 22),
Row(
children: [
if (item.fileName != null) ...[
Expanded(
child: OutlinedButton.icon(
onPressed: () => _remove(context, ref),
icon: const Icon(LucideIcons.trash2, size: 18),
label: const Text('Remove'),
),
),
const SizedBox(width: 10),
],
Expanded(
child: FilledButton.icon(
onPressed: () => _upload(context, ref),
icon: const Icon(LucideIcons.camera, size: 18),
label: Text(item.fileName == null ? 'Upload' : 'Replace'),
),
),
],
),
],
),
);
}
}
class _ExpiryRow extends ConsumerWidget {
const _ExpiryRow({
required this.section,
required this.item,
required this.format,
});
final ComplianceSection section;
final ComplianceItem item;
final DateFormat format;
@override
Widget build(BuildContext context, WidgetRef ref) {
return InkWell(
onTap: () async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: item.expires ?? now.add(const Duration(days: 365)),
firstDate: now,
lastDate: DateTime(now.year + 20),
);
if (picked == null) return;
ref
.read(complianceProvider.notifier)
.setExpiry(section, item.id, picked);
},
child: DetailRow(
'Expires',
item.expires == null ? 'Tap to set' : format.format(item.expires!),
valueColor: item.expires == null ? KrowPalette.blue : null,
),
);
}
}

107
lib/screens/home_shell.dart Normal file
View File

@@ -0,0 +1,107 @@
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 '../state/timecards_controller.dart';
import '../widgets/krow_nav_bar.dart';
/// The five primary tabs. Each keeps its own navigation stack.
class HomeShell extends ConsumerWidget {
const HomeShell({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context, WidgetRef ref) {
final needsReview = ref.watch(timecardsNeedingReviewProvider).isNotEmpty;
return Scaffold(
body: _TabTransition(
index: navigationShell.currentIndex,
child: navigationShell,
),
bottomNavigationBar: KrowNavBar(
currentIndex: navigationShell.currentIndex,
onSelect: (i) => navigationShell.goBranch(
i,
initialLocation: i == navigationShell.currentIndex,
),
items: [
const KrowNavItem(icon: LucideIcons.house, label: 'Today'),
const KrowNavItem(icon: LucideIcons.briefcase, label: 'Shifts'),
KrowNavItem(
icon: LucideIcons.clock,
label: 'Clock',
showDot: needsReview,
),
const KrowNavItem(icon: LucideIcons.wallet, label: 'Pay'),
const KrowNavItem(icon: LucideIcons.user, label: 'Profile'),
],
),
);
}
}
/// Fades and lifts the tab content each time the tab changes.
///
/// Deliberately not an AnimatedSwitcher: that would hold two copies of the
/// shell in the tree at once, and the branch navigators are keyed by
/// GlobalKey, so duplicating them would throw. This keeps one subtree — the
/// branches stay alive and keep their scroll positions — and animates it in
/// place when the index changes.
class _TabTransition extends StatefulWidget {
const _TabTransition({required this.index, required this.child});
final int index;
final Widget child;
@override
State<_TabTransition> createState() => _TabTransitionState();
}
class _TabTransitionState extends State<_TabTransition>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 260),
value: 1,
);
late final Animation<double> _curve = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
);
@override
void didUpdateWidget(_TabTransition oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.index != widget.index) _controller.forward(from: 0);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _curve,
// The shell is passed through untouched so it is never rebuilt.
child: widget.child,
builder: (context, child) {
final t = _curve.value;
return Opacity(
// Never fully transparent: a tab that blinks out reads as a glitch.
opacity: 0.35 + 0.65 * t,
child: Transform.translate(
offset: Offset(0, (1 - t) * 10),
child: child,
),
);
},
);
}
}

View File

@@ -0,0 +1,448 @@
import 'dart:async';
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/theme.dart';
import '../state/session_controller.dart';
/// Brand wordmark. Drawn rather than shipped as an asset so it tints cleanly
/// on either ground.
class KrowWordmark extends StatelessWidget {
const KrowWordmark({super.key, this.size = 34, this.color});
final double size;
final Color? color;
@override
Widget build(BuildContext context) {
return Text(
'KROW',
style: TextStyle(
fontSize: size,
fontWeight: FontWeight.w800,
letterSpacing: size * 0.06,
color: color ?? KrowPalette.yellow,
),
);
}
}
/// Splash. Routes on to onboarding, sign-in or the app depending on how far
/// the worker got last time.
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@override
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends ConsumerState<SplashScreen> {
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer(const Duration(milliseconds: 1400), _go);
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _go() {
if (!mounted) return;
final s = ref.read(sessionProvider);
if (!s.onboardingSeen) {
context.go('/onboarding');
} else if (!s.signedIn) {
context.go('/welcome');
} else if (!s.permissionsDone) {
context.go('/permissions');
} else {
context.go('/today');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: KrowPalette.blue,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const KrowWordmark(size: 46),
const SizedBox(height: 22),
Text(
'WORK · HIRE · ELEVATE',
style: TextStyle(
fontSize: 10.5,
letterSpacing: 3,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.6),
),
),
const SizedBox(height: 44),
SizedBox(
width: 140,
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
minHeight: 3,
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation(KrowPalette.yellow),
),
),
),
],
),
),
);
}
}
class _Slide {
const _Slide(this.eyebrow, this.title, this.body, this.icon);
final String eyebrow;
final String title;
final String body;
final IconData icon;
}
const _slides = [
_Slide(
'Find work',
'Shifts that fit around your week.',
'Browse open hospitality shifts near you, filtered to the hours you said '
'you can work.',
LucideIcons.search,
),
_Slide(
'Know your pay',
'See the money before you say yes.',
'Every shift shows the hourly rate and the total. No surprises on the '
'payslip.',
LucideIcons.banknote,
),
_Slide(
'Get paid',
'Clock in on site. Paid on schedule.',
'Tap in when you arrive, tap out when you leave. Timecards submit '
'themselves.',
LucideIcons.clock,
),
];
class OnboardingScreen extends ConsumerStatefulWidget {
const OnboardingScreen({super.key});
@override
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
final _controller = PageController();
int _page = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _finish() {
ref.read(sessionProvider.notifier).completeOnboarding();
context.go('/welcome');
}
@override
Widget build(BuildContext context) {
final isLast = _page == _slides.length - 1;
return Scaffold(
backgroundColor: KrowPalette.blue,
body: Column(
children: [
SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 8, 12, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const KrowWordmark(size: 20),
TextButton(
onPressed: _finish,
style: TextButton.styleFrom(
foregroundColor: Colors.white70,
),
child: const Text('Skip'),
),
],
),
),
),
Expanded(
child: PageView.builder(
controller: _controller,
itemCount: _slides.length,
onPageChanged: (i) => setState(() => _page = i),
itemBuilder: (context, i) {
final slide = _slides[i];
return Center(
child: Container(
width: 190,
height: 190,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Icon(
slide.icon,
size: 76,
color: KrowPalette.yellow,
),
),
);
},
),
),
Container(
width: double.infinity,
decoration: const BoxDecoration(
color: KrowPalette.sage,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 26, 24, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_slides[_page].eyebrow.toUpperCase(),
style: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w600,
letterSpacing: 1.6,
color: Color(0xFF949FA5),
),
),
const SizedBox(height: 10),
Text(
_slides[_page].title,
style: const TextStyle(
fontSize: 29,
fontWeight: FontWeight.w700,
height: 1.1,
letterSpacing: -1,
),
),
const SizedBox(height: 12),
Text(
_slides[_page].body,
style: const TextStyle(
fontSize: 14.5,
height: 1.55,
color: KrowPalette.mute,
),
),
const SizedBox(height: 22),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < _slides.length; i++)
AnimatedContainer(
duration: const Duration(milliseconds: 220),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: i == _page ? 24 : 6,
height: 6,
decoration: BoxDecoration(
color: i == _page
? KrowPalette.blue
: const Color(0xFFC6D6CC),
borderRadius: BorderRadius.circular(3),
),
),
],
),
const SizedBox(height: 18),
FilledButton(
onPressed: () {
if (isLast) {
_finish();
} else {
_controller.nextPage(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOut,
);
}
},
child: Text(isLast ? 'Create your account' : 'Next'),
),
if (isLast)
Center(
child: TextButton(
onPressed: _finish,
child: const Text('I already have an account'),
),
),
],
),
),
),
),
],
),
);
}
}
class WelcomeScreen extends StatelessWidget {
const WelcomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: KrowPalette.blue,
body: Column(
children: [
Expanded(
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 30),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const KrowWordmark(size: 40),
const SizedBox(height: 34),
const Text(
'Work the shifts\nyou actually want.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 32,
height: 1.1,
letterSpacing: -1.1,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 14),
Text(
'Hospitality work in your city, priced up front and paid '
'on a fixed schedule.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.5,
height: 1.55,
color: Colors.white.withValues(alpha: 0.64),
),
),
const SizedBox(height: 34),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_WelcomeStat('4,200', 'Shifts this month'),
Container(
width: 1,
height: 38,
margin: const EdgeInsets.symmetric(horizontal: 26),
color: Colors.white24,
),
_WelcomeStat(r'$21+', 'Average rate'),
],
),
],
),
),
),
),
Container(
width: double.infinity,
decoration: const BoxDecoration(
color: KrowPalette.sage,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 24, 22, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton(
onPressed: () => context.push('/phone'),
child: const Text('Continue with mobile'),
),
const SizedBox(height: 10),
OutlinedButton(
onPressed: () =>
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Invite codes open in the next release',
),
),
),
child: const Text('I have an invite code'),
),
const SizedBox(height: 12),
const Text(
'By continuing you accept the Worker Terms.',
style: TextStyle(
fontSize: 11.5,
color: Color(0xFF949FA5),
),
),
],
),
),
),
),
],
),
);
}
}
class _WelcomeStat extends StatelessWidget {
const _WelcomeStat(this.value, this.caption);
final String value;
final String caption;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
value,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: -0.8,
color: KrowPalette.yellow,
),
),
const SizedBox(height: 3),
Text(
caption.toUpperCase(),
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.1,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.55),
),
),
],
);
}
}

View File

@@ -0,0 +1,620 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/formatters.dart';
import '../core/theme.dart';
import '../models/shift.dart';
import '../state/profile_controller.dart';
import '../state/shifts_controller.dart';
import '../widgets/collapsing_header.dart';
import '../widgets/common.dart';
import '../widgets/glass_segments.dart';
enum PayRange { week, month, year, all }
extension on PayRange {
String get label => switch (this) {
PayRange.week => 'Week',
PayRange.month => 'Month',
PayRange.year => 'Year',
PayRange.all => 'All',
};
Duration get window => switch (this) {
PayRange.week => const Duration(days: 7),
PayRange.month => const Duration(days: 30),
PayRange.year => const Duration(days: 365),
PayRange.all => const Duration(days: 3650),
};
}
class PayScreen extends ConsumerStatefulWidget {
const PayScreen({super.key});
@override
ConsumerState<PayScreen> createState() => _PayScreenState();
}
class _PayScreenState extends ConsumerState<PayScreen> {
PayRange _range = PayRange.month;
@override
Widget build(BuildContext context) {
final shifts = ref.watch(shiftsProvider);
final cutoff = DateTime.now().subtract(_range.window);
final completed = shifts
.where(
(s) => s.status == ShiftStatus.completed && s.start.isAfter(cutoff),
)
.toList();
final pendingShifts = ref.watch(bookedShiftsProvider);
final paid = completed.fold<double>(0, (sum, s) => sum + s.pay);
final pending = pendingShifts.fold<double>(
0,
(sum, s) => sum + s.scheduledPay,
);
final segments = GlassSegments<PayRange>(
segments: [for (final r in PayRange.values) (r, r.label)],
value: _range,
onChanged: (r) => setState(() => _range = r),
);
return Scaffold(
body: CustomScrollView(
slivers: [
KrowSliverHeader(
expandedHeight: 168,
// Deep enough to keep the range switcher: collapsing it away would
// mean scrolling back to the top to change what you are looking at.
collapsedHeight: 104,
builder: (context, t) => Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Expanded(
child: HeaderCrossFade(
t: t,
expandedHeight: 84,
expanded: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'ESTIMATED GROSS PAY · PRE-TAX',
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.5,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5),
),
),
const SizedBox(height: 5),
Text(
Fmt.money(paid + pending),
style: const TextStyle(
fontSize: 40,
fontWeight: FontWeight.w700,
letterSpacing: -1.8,
color: Colors.white,
),
),
],
),
collapsed: Align(
alignment: Alignment.centerLeft,
child: Text(
'${Fmt.money(paid + pending)} · pre-tax',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
color: Colors.white,
),
),
),
),
),
const SizedBox(height: 12),
segments,
],
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
sliver: SliverList.list(
children: [
KrowCard(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Flexible(
child: Text(
'EARNINGS TREND',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w600,
letterSpacing: 1.4,
color: Color(0xFF949FA5),
),
),
),
const SizedBox(width: 8),
Text(
_range.label,
maxLines: 1,
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: Color(0xFF949FA5),
),
),
],
),
const SizedBox(height: 16),
MiniBarChart(bars: _buildBars(shifts)),
const SizedBox(height: 14),
const Divider(height: 1),
const SizedBox(height: 12),
const Row(
children: [
LegendDot(KrowPalette.blue, 'Paid'),
SizedBox(width: 16),
LegendDot(KrowPalette.yellow, 'Pending'),
],
),
],
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: KrowCard(
padding: const EdgeInsets.all(15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const StatusChip('Pending', tone: ChipTone.yellow),
const SizedBox(height: 11),
Text(
Fmt.money(pending),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
),
),
],
),
),
),
const SizedBox(width: 10),
Expanded(
child: KrowCard(
padding: const EdgeInsets.all(15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const StatusChip('Paid', tone: ChipTone.neutral),
const SizedBox(height: 11),
Text(
Fmt.money(paid),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
),
),
],
),
),
),
],
),
const SizedBox(height: 12),
TileGroup(
children: [
NavTile(
icon: LucideIcons.calendarDays,
title: 'Payroll schedule',
subtitle: 'Semi-monthly, 11 days after each period',
onTap: () => context.push('/payroll'),
),
NavTile(
icon: LucideIcons.landmark,
title: 'Direct deposit',
subtitle: 'Where your pay lands',
badge: ref.watch(profileProvider).bank == null
? const StatusChip('Add', tone: ChipTone.yellow)
: const StatusChip('Set', tone: ChipTone.neutral),
onTap: () => context.push('/direct-deposit'),
),
],
),
const SectionLabel('Payments'),
if (completed.isEmpty)
const EmptyState(
icon: LucideIcons.banknote,
title: 'Nothing paid yet',
message: 'Finish a shift and the payment shows up here.',
)
else
for (final shift in completed)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _PaymentRow(shift: shift),
),
const SizedBox(height: 12),
const InfoBanner(
'Amounts are pre-tax. Your take-home differs after tax, '
'deductions and withholding from your payroll provider.',
),
],
),
),
],
),
);
}
/// Four buckets across the selected range, split paid vs still pending.
List<(String, double, bool)> _buildBars(List<Shift> shifts) {
final now = DateTime.now();
final span = _range.window.inDays;
final bucket = (span / 4).ceil();
final labels = DateFormat('d MMM');
return [
for (var i = 3; i >= 0; i--)
() {
final end = now.subtract(Duration(days: bucket * i));
final start = end.subtract(Duration(days: bucket));
final inBucket = shifts.where(
(s) => s.start.isAfter(start) && !s.start.isAfter(end),
);
final value = inBucket.fold<double>(0, (sum, s) => sum + s.pay);
final anyPaid = inBucket.any(
(s) => s.status == ShiftStatus.completed,
);
return (labels.format(end), value, anyPaid);
}(),
];
}
}
class _PaymentRow extends StatelessWidget {
const _PaymentRow({required this.shift});
final Shift shift;
@override
Widget build(BuildContext context) {
return KrowCard(
onTap: () => context.push('/payment/${shift.id}'),
padding: const EdgeInsets.all(14),
child: Row(
children: [
BusinessBadge(shift.logoLetter, size: 42),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${shift.role} · ${shift.business}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
),
const SizedBox(height: 2),
Text(
'${Fmt.dayShort(shift.start)} · '
'${Fmt.duration(shift.workedDuration ?? shift.scheduledDuration)}',
style: const TextStyle(fontSize: 12, color: KrowPalette.mute),
),
],
),
),
Text(
Fmt.money(shift.pay),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
),
),
],
),
);
}
}
/// Semi-monthly periods: each closes on the 15th and the month end, and pays
/// 11 days later.
class PayrollScheduleScreen extends ConsumerWidget {
const PayrollScheduleScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final now = DateTime.now();
final inFirstHalf = now.day <= 15;
final periodStart = DateTime(now.year, now.month, inFirstHalf ? 1 : 16);
final periodEnd = inFirstHalf
? DateTime(now.year, now.month, 15)
: DateTime(now.year, now.month + 1, 0);
final payDate = periodEnd.add(const Duration(days: 11));
final nextStart = periodEnd.add(const Duration(days: 1));
final nextEnd = inFirstHalf
? DateTime(now.year, now.month + 1, 0)
: DateTime(now.year, now.month + 1, 15);
final elapsed = now.difference(periodStart).inDays + 1;
final total = periodEnd.difference(periodStart).inDays + 1;
final earned = ref.watch(completedEarningsProvider);
final d = DateFormat('d MMM yyyy');
return Scaffold(
appBar: AppBar(
title: const Text('Payroll schedule'),
actions: const [
Padding(
padding: EdgeInsets.only(right: 16),
child: Center(
child: StatusChip('Semi-monthly', tone: ChipTone.yellow),
),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
const Text(
'Two pay dates a month, 11 days after each period closes.',
style: TextStyle(
fontSize: 13.5,
height: 1.5,
color: KrowPalette.mute,
),
),
const SectionLabel('Current period'),
KrowCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${d.format(periodStart)} - ${d.format(periodEnd)}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
const StatusChip('Open', tone: ChipTone.yellow),
],
),
const SizedBox(height: 4),
Text(
'Pay date ${d.format(payDate)}',
style: const TextStyle(
fontSize: 12.5,
color: KrowPalette.mute,
),
),
const SizedBox(height: 13),
KrowProgressBar(elapsed / total),
const SizedBox(height: 9),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'$elapsed of $total days elapsed',
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: Color(0xFF949FA5),
),
),
Text(
Fmt.money(earned),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
],
),
],
),
),
const SectionLabel('Next period'),
KrowCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${d.format(nextStart)} - ${d.format(nextEnd)}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
const StatusChip('Not started', tone: ChipTone.muted),
],
),
const SizedBox(height: 4),
Text(
'Pay date '
'${d.format(nextEnd.add(const Duration(days: 11)))}',
style: const TextStyle(
fontSize: 12.5,
color: KrowPalette.mute,
),
),
const SizedBox(height: 13),
const KrowProgressBar(0),
],
),
),
const SizedBox(height: 16),
const InfoBanner(
'Payments generate after the shift scheduled end time, not the '
'moment you clock out.',
),
],
),
);
}
}
class PaymentDetailScreen extends ConsumerWidget {
const PaymentDetailScreen({super.key, required this.shiftId});
final String shiftId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final shift = ref.watch(shiftsProvider.notifier).byId(shiftId);
if (shift == null) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: Text('Payment not found.')),
);
}
final worked = shift.workedDuration ?? shift.scheduledDuration;
return Scaffold(
body: ListView(
padding: EdgeInsets.zero,
children: [
BrandHeader(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
HeaderIconButton(
icon: LucideIcons.arrowLeft,
onPressed: () => context.pop(),
),
const StatusChip('Pending', tone: ChipTone.yellow),
],
),
const SizedBox(height: 16),
Text(
'ESTIMATED GROSS · PRE-TAX',
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.5,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5),
),
),
const SizedBox(height: 4),
Text(
Fmt.money(shift.pay),
style: const TextStyle(
fontSize: 36,
fontWeight: FontWeight.w700,
letterSpacing: -1.6,
color: Colors.white,
),
),
const SizedBox(height: 6),
Text(
Fmt.dayShort(shift.start),
style: TextStyle(
fontSize: 12.5,
color: Colors.white.withValues(alpha: 0.62),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
StatSlabRow(
slabs: [
StatSlab(
'Clock in',
shift.clockIn == null ? '-' : Fmt.time(shift.clockIn!),
),
StatSlab(
'Clock out',
shift.clockOut == null ? '-' : Fmt.time(shift.clockOut!),
),
StatSlab('Hours', Fmt.duration(worked)),
],
),
const SectionLabel('Pay breakdown'),
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
DetailRow('Hourly rate', '${Fmt.money(shift.rate)}/hr'),
const Divider(height: 1),
DetailRow(
'Regular · ${Fmt.duration(worked)}',
Fmt.money(shift.pay),
),
const Divider(height: 1),
DetailRow('Break, unpaid', '${shift.breakMinutes} min'),
const Divider(height: 1),
DetailRow(
'Total, pre-tax',
Fmt.money(shift.pay),
emphasis: true,
),
],
),
),
const SectionLabel('Where'),
KrowCard(
child: Row(
children: [
const Icon(
LucideIcons.mapPin,
size: 18,
color: KrowPalette.blue,
),
const SizedBox(width: 12),
Expanded(child: Text(shift.address)),
],
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,736 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/theme.dart';
import '../models/compliance.dart';
import '../state/profile_controller.dart';
import '../state/seed.dart';
import '../widgets/common.dart';
/// Only called if something happens during a shift. Managers never see these.
class EmergencyContactsScreen extends ConsumerWidget {
const EmergencyContactsScreen({super.key});
Future<void> _edit(
BuildContext context,
WidgetRef ref, {
EmergencyContact? existing,
int? index,
}) async {
final name = TextEditingController(text: existing?.name ?? '');
final phone = TextEditingController(text: existing?.phone ?? '');
var relationship = existing?.relationship ?? 'Spouse';
const options = ['Spouse', 'Parent', 'Sibling', 'Child', 'Friend', 'Other'];
final saved = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: StatefulBuilder(
builder: (context, setSheetState) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
existing == null ? 'Add a contact' : 'Edit contact',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
TextField(
controller: name,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Full name',
prefixIcon: Icon(LucideIcons.user, size: 18),
),
),
const SizedBox(height: 12),
TextField(
controller: phone,
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9 +]')),
],
decoration: const InputDecoration(
labelText: 'Mobile',
prefixIcon: Icon(LucideIcons.phone, size: 18),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: relationship,
decoration: const InputDecoration(labelText: 'Relationship'),
items: [
for (final o in options)
DropdownMenuItem(value: o, child: Text(o)),
],
onChanged: (v) =>
setSheetState(() => relationship = v ?? relationship),
),
const SizedBox(height: 20),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Save contact'),
),
const SizedBox(height: 8),
],
),
),
),
);
if (saved != true) return;
if (name.text.trim().isEmpty || phone.text.trim().isEmpty) return;
ref
.read(profileProvider.notifier)
.saveContact(
EmergencyContact(
name: name.text.trim(),
phone: phone.text.trim(),
relationship: relationship,
),
index: index,
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final contacts = ref.watch(profileProvider).contacts;
return Scaffold(
appBar: AppBar(title: const Text('Emergency contacts')),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
const Text(
'We only call these people if something happens to you during a '
'shift. Managers never see them.',
style: TextStyle(
fontSize: 13.5,
height: 1.55,
color: KrowPalette.mute,
),
),
const SectionLabel('Your contacts'),
if (contacts.isEmpty)
const EmptyState(
icon: LucideIcons.phone,
title: 'No contact yet',
message: 'Add at least one before your first shift.',
)
else
for (var i = 0; i < contacts.length; i++)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: KrowCard(
onTap: () =>
_edit(context, ref, existing: contacts[i], index: i),
padding: const EdgeInsets.all(14),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: KrowPalette.blueTint,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
LucideIcons.user,
size: 19,
color: KrowPalette.blue,
),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
contacts[i].name,
style: const TextStyle(
fontSize: 15.5,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
),
),
const SizedBox(height: 2),
Text(
'${contacts[i].relationship} · '
'${contacts[i].phone}',
style: const TextStyle(
fontSize: 12.5,
color: KrowPalette.mute,
),
),
],
),
),
IconButton(
onPressed: () =>
ref.read(profileProvider.notifier).removeContact(i),
icon: const Icon(LucideIcons.trash2, size: 18),
color: const Color(0xFF949FA5),
),
],
),
),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _edit(context, ref),
icon: const Icon(LucideIcons.plus, size: 18),
label: const Text('Add a contact'),
),
],
),
);
}
}
/// More picks mean more shifts offered.
class SkillsScreen extends ConsumerStatefulWidget {
const SkillsScreen({super.key});
@override
ConsumerState<SkillsScreen> createState() => _SkillsScreenState();
}
class _SkillsScreenState extends ConsumerState<SkillsScreen> {
final _custom = TextEditingController();
@override
void dispose() {
_custom.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final profile = ref.watch(profileProvider);
final controller = ref.read(profileProvider.notifier);
final extras = profile.skills.where((s) => !Seed.skills.contains(s));
return Scaffold(
appBar: AppBar(
title: const Text('Experience & skills'),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: StatusChip(
'${profile.settings.length + profile.skills.length} selected',
tone: ChipTone.yellow,
),
),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
const Text(
"Where you've worked",
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: -0.8,
),
),
const SizedBox(height: 8),
const Text(
'Pick every setting you have worked in. More picks mean more '
'shifts offered.',
style: TextStyle(
fontSize: 13.5,
height: 1.55,
color: KrowPalette.mute,
),
),
const SizedBox(height: 18),
Wrap(
spacing: 7,
runSpacing: 7,
children: [
for (final s in Seed.workSettings)
_Tag(
label: s,
selected: profile.settings.contains(s),
onTap: () => controller.toggleSetting(s),
),
],
),
const SectionLabel('Skills and trades'),
Wrap(
spacing: 7,
runSpacing: 7,
children: [
for (final s in Seed.skills)
_Tag(
label: s,
selected: profile.skills.contains(s),
onTap: () => controller.toggleSkill(s),
),
],
),
const SectionLabel('Anything else you do'),
Row(
children: [
Expanded(
child: TextField(
controller: _custom,
textInputAction: TextInputAction.done,
onSubmitted: (v) {
controller.addSkill(v);
_custom.clear();
},
decoration: const InputDecoration(
hintText: 'Type a skill...',
),
),
),
const SizedBox(width: 9),
SizedBox(
width: 52,
height: 52,
child: FilledButton(
onPressed: () {
controller.addSkill(_custom.text);
_custom.clear();
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(52, 52),
),
child: const Icon(LucideIcons.plus, size: 20),
),
),
],
),
if (extras.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 7,
runSpacing: 7,
children: [
for (final s in extras)
Chip(
label: Text(s),
backgroundColor: KrowPalette.yellow,
side: BorderSide.none,
labelStyle: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: KrowPalette.ink,
),
deleteIcon: const Icon(LucideIcons.x, size: 14),
onDeleted: () => controller.toggleSkill(s),
),
],
),
],
const SizedBox(height: 24),
FilledButton(
onPressed: () {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Skills saved')));
Navigator.of(context).pop();
},
child: const Text('Save skills'),
),
],
),
);
}
}
class _Tag extends StatelessWidget {
const _Tag({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return FilterChip(
label: Text(label),
selected: selected,
showCheckmark: false,
onSelected: (_) => onTap(),
selectedColor: KrowPalette.blue,
backgroundColor: Colors.white,
side: BorderSide(color: selected ? KrowPalette.blue : KrowPalette.line),
labelStyle: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w500,
color: selected ? Colors.white : KrowPalette.mute,
),
);
}
}
/// Where your pay lands. Needed before the first pay date.
class DirectDepositScreen extends ConsumerStatefulWidget {
const DirectDepositScreen({super.key});
@override
ConsumerState<DirectDepositScreen> createState() =>
_DirectDepositScreenState();
}
class _DirectDepositScreenState extends ConsumerState<DirectDepositScreen> {
final _bank = TextEditingController();
final _routing = TextEditingController();
final _account = TextEditingController();
String _type = 'Chequing';
bool _editing = false;
@override
void dispose() {
_bank.dispose();
_routing.dispose();
_account.dispose();
super.dispose();
}
void _save() {
if (_bank.text.trim().isEmpty ||
_routing.text.length != 9 ||
_account.text.length < 4) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Check the bank name, 9-digit routing and account'),
),
);
return;
}
ref
.read(profileProvider.notifier)
.saveBank(
BankAccount(
bankName: _bank.text.trim(),
routing: _routing.text,
accountLast4: _account.text.substring(_account.text.length - 4),
accountType: _type,
),
);
setState(() => _editing = false);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Account saved')));
}
@override
Widget build(BuildContext context) {
final bank = ref.watch(profileProvider).bank;
return Scaffold(
appBar: AppBar(title: const Text('Direct deposit')),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
const Text(
'Where your pay lands. You need this before the first pay date.',
style: TextStyle(
fontSize: 13.5,
height: 1.55,
color: KrowPalette.mute,
),
),
const SizedBox(height: 18),
KrowCard(
padding: const EdgeInsets.all(15),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: KrowPalette.blueTint,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
LucideIcons.shield,
size: 17,
color: KrowPalette.blue,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Bank-grade encryption',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: 3),
Text(
'Account numbers are tokenised. KROW staff never see '
'them, and neither do the businesses you work for.',
style: TextStyle(
fontSize: 12,
height: 1.45,
color: KrowPalette.mute,
),
),
],
),
),
],
),
),
const SectionLabel('Your account'),
if (bank != null && !_editing)
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
DetailRow('Bank', bank.bankName),
const Divider(height: 1),
DetailRow('Type', bank.accountType),
const Divider(height: 1),
DetailRow('Routing', bank.routing),
const Divider(height: 1),
DetailRow('Account', '····${bank.accountLast4}'),
],
),
)
else if (!_editing)
EmptyState(
icon: LucideIcons.landmark,
title: 'No account added',
message: 'Add one before the next pay date to get paid on time.',
action: FilledButton.icon(
onPressed: () => setState(() => _editing = true),
icon: const Icon(LucideIcons.plus, size: 18),
label: const Text('Add an account'),
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _bank,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(labelText: 'Bank name'),
),
const SizedBox(height: 12),
TextField(
controller: _routing,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(9),
],
decoration: const InputDecoration(
labelText: 'Routing number',
helperText: 'Nine digits, on the bottom of a cheque',
),
),
const SizedBox(height: 12),
TextField(
controller: _account,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(17),
],
decoration: const InputDecoration(
labelText: 'Account number',
helperText: 'Chequing or savings, in your own name',
),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _type,
decoration: const InputDecoration(labelText: 'Account type'),
items: const [
DropdownMenuItem(
value: 'Chequing',
child: Text('Chequing'),
),
DropdownMenuItem(value: 'Savings', child: Text('Savings')),
],
onChanged: (v) => setState(() => _type = v ?? _type),
),
],
),
const SizedBox(height: 22),
if (_editing)
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => setState(() => _editing = false),
child: const Text('Cancel'),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
onPressed: _save,
child: const Text('Save account'),
),
),
],
)
else if (bank != null)
OutlinedButton(
onPressed: () => setState(() => _editing = true),
child: const Text('Replace account'),
),
],
),
);
}
}
/// Policies and balances, per staffing vendor.
class BenefitsScreen extends ConsumerWidget {
const BenefitsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Sick-leave accrual starts after 30 days worked.
const daysWorked = 12;
const threshold = 30;
return Scaffold(
appBar: AppBar(
title: const Text('Your benefits'),
actions: const [
Padding(
padding: EdgeInsets.only(right: 16),
child: Center(
child: StatusChip('Legendary Events', tone: ChipTone.yellow),
),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
const Text(
'Policies and balances, per staffing vendor.',
style: TextStyle(fontSize: 13.5, color: KrowPalette.mute),
),
const SectionLabel('Active policies'),
Container(
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 30),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: KrowPalette.line),
),
child: Column(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: KrowPalette.line),
),
child: const Icon(
LucideIcons.gift,
size: 24,
color: Color(0xFF949FA5),
),
),
const SizedBox(height: 14),
const Text(
'No policy active yet',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 5),
const Text(
'Sick-leave accrual starts after 30 days worked. '
'You are at day $daysWorked.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
height: 1.5,
color: KrowPalette.mute,
),
),
const SizedBox(height: 16),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: KrowProgressBar(
daysWorked / threshold,
color: KrowPalette.yellow,
),
),
const SizedBox(height: 9),
const Text(
'${threshold - daysWorked} days to go',
style: TextStyle(fontSize: 11.5, color: Color(0xFF949FA5)),
),
],
),
),
const SectionLabel('Balance history'),
KrowCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'A read-only record of accruals, usage, adjustments and '
'reversals.',
style: TextStyle(
fontSize: 12.5,
height: 1.5,
color: KrowPalette.mute,
),
),
const SizedBox(height: 14),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: KrowPalette.sage,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'Nothing recorded yet',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: KrowPalette.mute),
),
),
],
),
),
const SizedBox(height: 16),
const InfoBanner(
'Working for a different vendor? Switch at the top to see their '
'policies.',
),
],
),
);
}
}

View File

@@ -0,0 +1,362 @@
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/theme.dart';
import '../state/compliance_controller.dart';
import '../state/profile_controller.dart';
import '../widgets/collapsing_header.dart';
import '../widgets/common.dart';
class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key});
/// Avatar (84) + gap + name + role, plus 4/16 padding.
static const _expandedHeader = 192.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
final worker = ref.watch(workerStatsProvider);
final profile = ref.watch(profileProvider);
final (done, total) = ref.watch(complianceProgressProvider);
return Scaffold(
body: CustomScrollView(
slivers: [
KrowSliverHeader(
// Must clear the open content: avatar, name and role, plus the
// padding around them. The settings button overlays rather than
// stacking above, which is what keeps this under 200.
expandedHeight: _expandedHeader,
collapsedHeight: 60,
builder: (context, t) => HeaderCrossFade(
t: t,
expandedHeight: _expandedHeader,
collapsed: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
child: Row(
children: [
Expanded(
child: Text(
worker.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: Colors.white,
),
),
),
HeaderIconButton(
icon: LucideIcons.settings,
tooltip: 'Settings',
onPressed: () => context.push('/settings'),
),
],
),
),
expanded: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Stack(
// Without this the unpositioned column would shrink-wrap to
// the Stack's top-left corner instead of centring.
alignment: Alignment.topCenter,
children: [
// Overlaid rather than stacked above the avatar: as its own
// row it added 46px the header could not afford.
Positioned(
top: 0,
right: 0,
child: HeaderIconButton(
icon: LucideIcons.settings,
tooltip: 'Settings',
onPressed: () => context.push('/settings'),
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => _editName(context, ref, worker.name),
child: Stack(
children: [
Container(
width: 84,
height: 84,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: KrowPalette.yellow,
shape: BoxShape.circle,
),
child: Text(
worker.initials,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: KrowPalette.ink,
),
),
),
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(
color: KrowPalette.blue,
width: 2.5,
),
),
child: const Icon(
LucideIcons.pencil,
size: 12,
color: KrowPalette.blue,
),
),
),
],
),
),
const SizedBox(height: 10),
Text(
worker.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 23,
fontWeight: FontWeight.w700,
letterSpacing: -0.8,
color: Colors.white,
),
),
const SizedBox(height: 2),
Text(
'${worker.role} · ${worker.city}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
letterSpacing: 0.4,
color: Colors.white.withValues(alpha: 0.6),
),
),
],
),
],
),
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
sliver: SliverList.list(
children: [
StatRail(
stats: [
('Shifts', '${worker.shiftsWorked}'),
('Rating', worker.rating.toStringAsFixed(1)),
('On time', '${worker.onTimePercent}%'),
('No show', '${worker.noShows}'),
('Cancel', '${worker.cancellations}'),
],
),
const SizedBox(height: 11),
KrowCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Trust index',
style: TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
),
),
Text(
'${worker.trustIndex}%',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
),
),
],
),
const SizedBox(height: 11),
KrowProgressBar(
worker.trustIndex / 100,
color: worker.trustIndex < 45
? const Color(0xFFA6403F)
: KrowPalette.blue,
),
const SizedBox(height: 9),
const Text(
'Stay above 45% to keep picking up shifts. On-time '
'arrivals raise it fastest.',
style: TextStyle(
fontSize: 12,
height: 1.45,
color: KrowPalette.mute,
),
),
],
),
),
const SectionLabel('Compliance'),
TileGroup(
children: [
NavTile(
icon: LucideIcons.shieldCheck,
title: 'Compliance hub',
subtitle: 'Everything a business must see',
badge: StatusChip(
'$done of $total',
tone: done == total
? ChipTone.neutral
: ChipTone.yellow,
),
onTap: () => context.push('/compliance'),
),
],
),
const SectionLabel('Your details'),
TileGroup(
children: [
NavTile(
icon: LucideIcons.user,
title: 'Personal info',
subtitle: worker.phone,
onTap: () => _editName(context, ref, worker.name),
),
NavTile(
icon: LucideIcons.phone,
title: 'Emergency contact',
subtitle: 'Who we call if something happens',
badge: StatusChip(
'${profile.contacts.length}',
tone: ChipTone.neutral,
),
onTap: () => context.push('/emergency-contacts'),
),
NavTile(
icon: LucideIcons.briefcase,
title: 'Experience & skills',
subtitle: 'Work history and trades',
badge: StatusChip(
'${profile.skills.length}',
tone: ChipTone.neutral,
),
onTap: () => context.push('/skills'),
),
NavTile(
icon: LucideIcons.calendar,
title: 'Availability',
subtitle: 'The hours you can work',
onTap: () => context.push('/availability'),
),
NavTile(
icon: LucideIcons.gift,
title: 'Benefits',
subtitle: 'Policies and balance history',
onTap: () => context.push('/benefits'),
),
],
),
const SectionLabel('Money'),
TileGroup(
children: [
NavTile(
icon: LucideIcons.landmark,
title: 'Direct deposit',
subtitle: profile.bank == null
? 'Not set up yet'
: '${profile.bank!.bankName} '
'····${profile.bank!.accountLast4}',
badge: profile.bank == null
? const StatusChip('Add', tone: ChipTone.yellow)
: null,
onTap: () => context.push('/direct-deposit'),
),
NavTile(
icon: LucideIcons.fileText,
title: 'Timecards',
subtitle: 'Hours worked and disputes',
onTap: () => context.push('/timecards'),
),
],
),
const SectionLabel('Support'),
TileGroup(
children: [
NavTile(
icon: LucideIcons.lifeBuoy,
title: 'Contact support',
subtitle: 'Usually answered within 4 hours',
onTap: () => context.push('/support'),
),
NavTile(
icon: LucideIcons.circleHelp,
title: 'FAQs',
subtitle: 'Common questions answered',
onTap: () => context.push('/faqs'),
),
NavTile(
icon: LucideIcons.shield,
title: 'Privacy & security',
subtitle: 'Account and data settings',
onTap: () => context.push('/privacy'),
),
],
),
const SizedBox(height: 28),
],
),
),
],
),
);
}
Future<void> _editName(
BuildContext context,
WidgetRef ref,
String current,
) async {
final controller = TextEditingController(text: current);
final name = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Your name'),
content: TextField(
controller: controller,
autofocus: true,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(hintText: 'Full name'),
),
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('Save'),
),
],
),
);
if (name == null || name.isEmpty) return;
ref.read(profileProvider.notifier).updateName(name);
}
}

View File

@@ -0,0 +1,589 @@
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/theme.dart';
import '../state/profile_controller.dart';
import '../state/seed.dart';
import '../state/session_controller.dart';
import '../state/shifts_controller.dart';
import '../state/timecards_controller.dart';
import '../widgets/common.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final session = ref.watch(sessionProvider);
final notifier = ref.read(sessionProvider.notifier);
final profile = ref.watch(profileProvider);
final reviewCount = ref.watch(timecardsNeedingReviewProvider).length;
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
actions: const [
Padding(
padding: EdgeInsets.only(right: 16),
child: Center(child: StatusChip('v1.1.1', tone: ChipTone.yellow)),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
const SectionLabel('Permissions'),
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
_SwitchRow(
icon: LucideIcons.mapPin,
title: 'Location',
subtitle: 'Needed to clock in at the venue',
value: session.locationGranted,
onChanged: (v) => v ? notifier.grantLocation() : null,
),
const Divider(height: 1),
_SwitchRow(
icon: LucideIcons.bell,
title: 'Notifications',
subtitle: 'Shift offers, reminders and pay alerts',
value: session.notificationsGranted,
onChanged: (v) => v ? notifier.grantNotifications() : null,
),
const Divider(height: 1),
_SwitchRow(
icon: LucideIcons.camera,
title: 'Camera',
subtitle: 'Photograph documents and attire',
value: session.cameraGranted,
onChanged: (v) => v ? notifier.grantCamera() : null,
),
],
),
),
const SectionLabel('Money'),
TileGroup(
children: [
NavTile(
icon: LucideIcons.landmark,
title: 'Direct deposit',
subtitle: profile.bank == null
? 'Where your pay lands'
: profile.bank!.bankName,
badge: profile.bank == null
? const StatusChip('Add', tone: ChipTone.yellow)
: null,
onTap: () => context.push('/direct-deposit'),
),
NavTile(
icon: LucideIcons.clock,
title: 'Timecards',
subtitle: 'Hours worked and disputes',
badge: reviewCount > 0
? StatusChip('$reviewCount', tone: ChipTone.yellow)
: null,
onTap: () => context.push('/timecards'),
),
NavTile(
icon: LucideIcons.calendarDays,
title: 'Payroll schedule',
subtitle: 'When each period closes and pays',
onTap: () => context.push('/payroll'),
),
],
),
const SectionLabel('Support'),
TileGroup(
children: [
NavTile(
icon: LucideIcons.lifeBuoy,
title: 'Contact support',
subtitle: 'Usually answered within 4 hours',
onTap: () => context.push('/support'),
),
NavTile(
icon: LucideIcons.circleHelp,
title: 'FAQs',
subtitle: 'Common questions answered',
onTap: () => context.push('/faqs'),
),
NavTile(
icon: LucideIcons.shield,
title: 'Privacy & security',
subtitle: 'Account and data settings',
onTap: () => context.push('/privacy'),
),
],
),
const SizedBox(height: 22),
OutlinedButton.icon(
onPressed: () => _signOut(context, ref),
icon: const Icon(LucideIcons.logOut, size: 18),
label: const Text('Sign out'),
),
const SizedBox(height: 22),
const Center(
child: Text(
'VERSION 1.1.1 · BUILD 26',
style: TextStyle(
fontSize: 11,
letterSpacing: 1.2,
color: Color(0xFF949FA5),
),
),
),
],
),
);
}
Future<void> _signOut(BuildContext context, WidgetRef ref) async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Sign out?'),
content: const Text('You will need to verify your number again.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Stay'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
child: const Text('Sign out'),
),
],
),
);
if (ok != true || !context.mounted) return;
await ref.read(sessionProvider.notifier).signOut();
if (!context.mounted) return;
context.go('/welcome');
}
}
class _SwitchRow extends StatelessWidget {
const _SwitchRow({
required this.icon,
required this.title,
required this.subtitle,
required this.value,
required this.onChanged,
});
final IconData icon;
final String title;
final String subtitle;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: KrowPalette.blueTint,
borderRadius: BorderRadius.circular(11),
),
child: Icon(icon, size: 18, color: KrowPalette.blue),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
),
),
Text(
subtitle,
style: const TextStyle(fontSize: 12, color: KrowPalette.mute),
),
],
),
),
Switch(value: value, onChanged: onChanged),
],
),
);
}
}
/// Pick what it is about so it reaches the right person first time.
class SupportScreen extends ConsumerStatefulWidget {
const SupportScreen({super.key});
@override
ConsumerState<SupportScreen> createState() => _SupportScreenState();
}
class _SupportScreenState extends ConsumerState<SupportScreen> {
String _topic = Seed.supportTopics.first;
String? _shiftId;
final _message = TextEditingController();
@override
void dispose() {
_message.dispose();
super.dispose();
}
void _send() {
if (_message.text.trim().length < 10) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tell us a little more than that')),
);
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Sent to support · $_topic')));
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final shifts = ref.watch(shiftsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Get help'),
actions: const [
Padding(
padding: EdgeInsets.only(right: 16),
child: Center(child: StatusChip('~4 hr reply')),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
const Text(
'Pick what it is about so it reaches the right person first time.',
style: TextStyle(
fontSize: 13.5,
height: 1.55,
color: KrowPalette.mute,
),
),
const SectionLabel("What's it about?"),
Wrap(
spacing: 7,
runSpacing: 7,
children: [
for (final t in Seed.supportTopics)
ChoiceChip(
label: Text(t),
selected: t == _topic,
showCheckmark: false,
selectedColor: KrowPalette.blue,
labelStyle: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w500,
color: t == _topic ? Colors.white : KrowPalette.mute,
),
onSelected: (_) => setState(() => _topic = t),
),
],
),
const SectionLabel('Which shift?'),
DropdownButtonFormField<String>(
initialValue: _shiftId,
isExpanded: true,
decoration: const InputDecoration(
hintText: 'Not about a specific shift',
),
items: [
for (final s in shifts)
DropdownMenuItem(
value: s.id,
child: Text(
'${s.role} · ${s.business}',
overflow: TextOverflow.ellipsis,
),
),
],
onChanged: (v) => setState(() => _shiftId = v),
),
const SectionLabel('Tell us what happened'),
TextField(
controller: _message,
maxLines: 5,
maxLength: 500,
decoration: const InputDecoration(
hintText: 'The venue door was locked when I arrived...',
),
),
TextButton.icon(
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Attachments open the camera roll')),
),
icon: const Icon(LucideIcons.paperclip, size: 16),
label: const Text('Attach a photo'),
),
const SizedBox(height: 16),
FilledButton(onPressed: _send, child: const Text('Send to support')),
],
),
);
}
}
class FaqScreen extends StatefulWidget {
const FaqScreen({super.key});
@override
State<FaqScreen> createState() => _FaqScreenState();
}
class _FaqScreenState extends State<FaqScreen> {
String _query = '';
@override
Widget build(BuildContext context) {
final q = _query.toLowerCase();
return Scaffold(
appBar: AppBar(title: const Text('Questions')),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
TextField(
onChanged: (v) => setState(() => _query = v),
decoration: const InputDecoration(
hintText: 'Search help',
prefixIcon: Icon(LucideIcons.search, size: 18),
),
),
for (final entry in Seed.faqs.entries)
() {
final matches = entry.value
.where(
(f) =>
q.isEmpty ||
f.$1.toLowerCase().contains(q) ||
f.$2.toLowerCase().contains(q),
)
.toList();
if (matches.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SectionLabel(entry.key),
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
children: [
for (var i = 0; i < matches.length; i++) ...[
if (i > 0) const Divider(height: 1),
ExpansionTile(
shape: const Border(),
collapsedShape: const Border(),
tilePadding: const EdgeInsets.symmetric(
horizontal: 12,
),
childrenPadding: const EdgeInsets.fromLTRB(
12,
0,
12,
14,
),
title: Text(
matches[i].$1,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
matches[i].$2,
style: const TextStyle(
fontSize: 13,
height: 1.55,
color: KrowPalette.mute,
),
),
),
],
),
],
],
),
),
],
);
}(),
],
),
);
}
}
class PrivacyScreen extends ConsumerWidget {
const PrivacyScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profile = ref.watch(profileProvider);
return Scaffold(
appBar: AppBar(title: const Text('Privacy & security')),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
const SectionLabel('Legal'),
TileGroup(
children: [
NavTile(
icon: LucideIcons.fileText,
title: 'Terms of service',
subtitle: 'Updated 14 Jun 2026',
onTap: () => _open(context, 'Terms of service'),
),
NavTile(
icon: LucideIcons.shield,
title: 'Privacy policy',
subtitle: 'What we collect and why',
onTap: () => _open(context, 'Privacy policy'),
),
],
),
const SectionLabel('Who sees your data'),
TileGroup(
children: [
NavTile(
icon: LucideIcons.briefcase,
title: "Businesses you've worked for",
subtitle: '2 have access to your documents',
onTap: () => _open(context, 'Data sharing'),
),
],
),
const SectionLabel('Email'),
KrowCard(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: KrowPalette.blueTint,
borderRadius: BorderRadius.circular(11),
),
child: const Icon(
LucideIcons.mail,
size: 18,
color: KrowPalette.blue,
),
),
const SizedBox(width: 13),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'KROW newsletter',
style: TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
),
),
Text(
'Product news and operational tips',
style: TextStyle(fontSize: 12, color: KrowPalette.mute),
),
],
),
),
Switch(
value: profile.newsletter,
onChanged: (v) =>
ref.read(profileProvider.notifier).setNewsletter(v),
),
],
),
),
const SectionLabel('Account'),
KrowCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Delete your account',
style: TextStyle(fontSize: 14.5, fontWeight: FontWeight.w600),
),
const SizedBox(height: 3),
const Text(
'Permanent. Timecards and tax records are kept for 4 years '
'by law, then destroyed.',
style: TextStyle(
fontSize: 12,
height: 1.45,
color: KrowPalette.mute,
),
),
const SizedBox(height: 14),
OutlinedButton.icon(
onPressed: () => _confirmDelete(context),
icon: const Icon(LucideIcons.trash2, size: 18),
label: const Text('Delete account'),
),
],
),
),
],
),
);
}
void _open(BuildContext context, String title) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('$title opens in the browser')));
}
Future<void> _confirmDelete(BuildContext context) async {
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete your account?'),
content: const Text(
'This cannot be undone. To go ahead, contact support so we can '
'confirm it is really you.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Keep my account'),
),
FilledButton(
onPressed: () {
Navigator.pop(context);
context.push('/support');
},
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
child: const Text('Contact support'),
),
],
),
);
}
}

View File

@@ -0,0 +1,379 @@
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/compliance.dart';
import '../models/shift.dart';
import '../state/compliance_controller.dart';
import '../state/shifts_controller.dart';
import '../widgets/common.dart';
class ShiftDetailScreen extends ConsumerWidget {
const ShiftDetailScreen({super.key, required this.shiftId});
final String shiftId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final shift = ref.watch(shiftsProvider.notifier).byId(shiftId);
if (shift == null) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: Text('That shift is no longer listed.')),
);
}
return Scaffold(
body: ListView(
padding: EdgeInsets.zero,
children: [
BrandHeader(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
HeaderIconButton(
icon: LucideIcons.arrowLeft,
onPressed: () => context.pop(),
),
HeaderIconButton(
icon: LucideIcons.messageCircle,
tooltip: 'Message the manager',
onPressed: () => context.push('/support'),
),
],
),
const SizedBox(height: 18),
StatusChip(
shift.status.label,
tone: switch (shift.status) {
ShiftStatus.active || ShiftStatus.booked => ChipTone.yellow,
ShiftStatus.noShow => ChipTone.danger,
_ => ChipTone.neutral,
},
),
const SizedBox(height: 12),
Text(
shift.role,
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.w700,
letterSpacing: -1.1,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'${shift.business} · ${Fmt.dayShort(shift.start)}',
style: TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: 0.62),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
StatSlabRow(
slabs: [
StatSlab(
shift.status == ShiftStatus.completed ? 'Paid' : 'Est.',
Fmt.money(shift.pay),
),
StatSlab('Rate', '${Fmt.money(shift.rate)}/hr'),
StatSlab(
'Hours',
Fmt.duration(
shift.workedDuration ?? shift.scheduledDuration,
),
),
],
),
const SectionLabel('The shift'),
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
DetailRow('Date', Fmt.dayShort(shift.start)),
const Divider(height: 1),
DetailRow(
'Scheduled',
Fmt.window(shift.start, shift.end),
),
const Divider(height: 1),
DetailRow('Location', shift.address),
const Divider(height: 1),
DetailRow('Break, unpaid', '${shift.breakMinutes} min'),
if (shift.manager != null) ...[
const Divider(height: 1),
DetailRow('Report to', shift.manager!),
],
if (shift.clockIn != null) ...[
const Divider(height: 1),
DetailRow(
'Clocked',
shift.clockOut == null
? '${Fmt.time(shift.clockIn!)} - running'
: Fmt.window(shift.clockIn!, shift.clockOut!),
valueColor: KrowPalette.blue,
),
],
],
),
),
const SectionLabel('Pay breakdown'),
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
DetailRow('Base rate', '${Fmt.money(shift.rate)}/hr'),
const Divider(height: 1),
DetailRow(
'Regular · '
'${Fmt.duration(shift.workedDuration ?? shift.scheduledDuration)}',
Fmt.money(shift.pay),
),
const Divider(height: 1),
DetailRow(
'Total, pre-tax',
Fmt.money(shift.pay),
emphasis: true,
),
],
),
),
const SectionLabel('Requirements'),
_Requirements(shift: shift),
const SizedBox(height: 18),
_Actions(shift: shift),
],
),
),
],
),
);
}
}
/// What this business asks for, checked against what is already on file.
class _Requirements extends ConsumerWidget {
const _Requirements({required this.shift});
final Shift shift;
@override
Widget build(BuildContext context, WidgetRef ref) {
final held = ref.watch(complianceProvider).all;
var met = 0;
final rows = <Widget>[];
for (final req in shift.requirements) {
final item = held.where((i) => i.title == req).firstOrNull;
final ok = item?.state == ComplianceState.verified;
if (ok) met++;
rows.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 11),
child: Row(
children: [
Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: ok ? KrowPalette.blueTint : const Color(0xFFF7E4AE),
borderRadius: BorderRadius.circular(7),
),
child: Icon(
ok ? LucideIcons.check : LucideIcons.clock,
size: 12,
color: ok ? KrowPalette.blue : KrowPalette.ink,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
req,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
Text(
ok ? 'On file' : (item?.badge ?? 'Missing'),
style: TextStyle(
fontSize: 12,
color: ok ? KrowPalette.mute : KrowPalette.ink,
fontWeight: ok ? FontWeight.w400 : FontWeight.w600,
),
),
],
),
),
);
}
final total = shift.requirements.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
KrowCard(
padding: const EdgeInsets.all(16),
child: Row(
children: [
SizedBox(
width: 54,
height: 54,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: total == 0 ? 1 : met / total,
strokeWidth: 6,
backgroundColor: const Color(0xFFDEEBE2),
valueColor: const AlwaysStoppedAnimation(
KrowPalette.blue,
),
),
Text(
'$met/$total',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: KrowPalette.blue,
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
met == total
? 'You are cleared to work'
: 'Some items still missing',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
),
),
const SizedBox(height: 4),
Text(
met == total
? 'Everything ${shift.business} asks for is on file.'
: 'Clear them from the compliance hub to apply.',
style: const TextStyle(
fontSize: 13,
height: 1.45,
color: KrowPalette.mute,
),
),
],
),
),
],
),
),
const SizedBox(height: 10),
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Column(children: rows),
),
],
);
}
}
class _Actions extends ConsumerWidget {
const _Actions({required this.shift});
final Shift shift;
Future<void> _confirmCancel(BuildContext context, WidgetRef ref) async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Give this shift back?'),
content: const Text(
'It goes back on the open market straight away, and the '
'cancellation counts against your trust index.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Keep it'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
child: const Text('Cancel shift'),
),
],
),
);
if (ok != true || !context.mounted) return;
ref.read(shiftsProvider.notifier).cancel(shift.id);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Shift cancelled')));
context.pop();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
switch (shift.status) {
case ShiftStatus.open:
return FilledButton.icon(
onPressed: () {
ref.read(shiftsProvider.notifier).apply(shift.id);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Shift booked')));
},
icon: const Icon(LucideIcons.check, size: 18),
label: const Text('Apply for this shift'),
);
case ShiftStatus.booked:
return Column(
children: [
FilledButton.icon(
onPressed: () => context.go('/clock'),
icon: const Icon(LucideIcons.clock, size: 18),
label: const Text('Go to clock in'),
),
const SizedBox(height: 10),
OutlinedButton(
onPressed: () => _confirmCancel(context, ref),
child: const Text('Cancel this shift'),
),
],
);
case ShiftStatus.active:
return FilledButton.icon(
onPressed: () => context.go('/clock'),
icon: const Icon(LucideIcons.squareCheck, size: 18),
label: const Text('You are on shift - clock out'),
);
default:
return OutlinedButton.icon(
onPressed: () => context.push('/support'),
icon: const Icon(LucideIcons.lifeBuoy, size: 18),
label: const Text('Something wrong? Get help'),
);
}
}
}

View File

@@ -0,0 +1,396 @@
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/shifts_controller.dart';
import '../widgets/common.dart';
import '../widgets/glass_segments.dart';
import '../widgets/shift_card.dart';
import '../widgets/week_strip.dart';
enum _Tab { booked, open, history }
class ShiftsScreen extends ConsumerStatefulWidget {
const ShiftsScreen({super.key});
@override
ConsumerState<ShiftsScreen> createState() => _ShiftsScreenState();
}
class _ShiftsScreenState extends ConsumerState<ShiftsScreen> {
_Tab _tab = _Tab.booked;
/// Where we came from, so the new list can slide in from the right side.
_Tab _previousTab = _Tab.booked;
DateTime _selectedDay = DateTime.now();
String _query = '';
String _filter = 'All';
void _selectTab(_Tab tab) {
if (tab == _tab) return;
setState(() {
_previousTab = _tab;
_tab = tab;
});
}
List<DateTime> get _week {
final now = DateTime.now();
return [for (var i = 0; i < 7; i++) now.add(Duration(days: i))];
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
BrandHeader(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Fmt.monthYear(DateTime.now()).toUpperCase(),
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.6,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5),
),
),
const SizedBox(height: 4),
const Text(
'Shifts',
style: TextStyle(
fontSize: 27,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
color: Colors.white,
),
),
const SizedBox(height: 18),
_HeaderSegments(value: _tab, onChanged: _selectTab),
],
),
),
Expanded(
// The list slides in from the side the new tab sits on, so the
// content moves the same way your eye just did.
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 280),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeIn,
layoutBuilder: (current, previous) => Stack(
alignment: Alignment.topCenter,
children: [...previous, ?current],
),
transitionBuilder: (child, animation) {
final incoming = child.key == ValueKey(_tab);
final direction = _tab.index >= _previousTab.index ? 1.0 : -1.0;
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween(
begin: Offset(incoming ? 0.08 * direction : 0, 0),
end: Offset.zero,
).animate(animation),
child: child,
),
);
},
child: KeyedSubtree(
key: ValueKey(_tab),
child: switch (_tab) {
_Tab.booked => _BookedTab(
week: _week,
selected: _selectedDay,
onSelect: (d) => setState(() => _selectedDay = d),
),
_Tab.open => _OpenTab(
query: _query,
filter: _filter,
onQuery: (q) => setState(() => _query = q),
onFilter: (f) => setState(() => _filter = f),
),
_Tab.history => const _HistoryTab(),
},
),
),
),
],
),
);
}
}
class _HeaderSegments extends StatelessWidget {
const _HeaderSegments({required this.value, required this.onChanged});
final _Tab value;
final ValueChanged<_Tab> onChanged;
@override
Widget build(BuildContext context) {
return GlassSegments<_Tab>(
segments: const [
(_Tab.booked, 'Booked'),
(_Tab.open, 'Open'),
(_Tab.history, 'History'),
],
value: value,
onChanged: onChanged,
);
}
}
class _BookedTab extends ConsumerWidget {
const _BookedTab({
required this.week,
required this.selected,
required this.onSelect,
});
final List<DateTime> week;
final DateTime selected;
final ValueChanged<DateTime> onSelect;
@override
Widget build(BuildContext context, WidgetRef ref) {
final booked = ref.watch(bookedShiftsProvider);
final forDay = booked.where((s) => Fmt.sameDay(s.start, selected)).toList();
final marked = <int>{
for (var i = 0; i < week.length; i++)
if (booked.any((s) => Fmt.sameDay(s.start, week[i]))) i,
};
return ListView(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
children: [
WeekStrip(
days: week,
selected: selected,
onSelect: onSelect,
marked: marked,
),
SectionLabel(
'${Fmt.dayFull(selected)} · ${forDay.length} '
'${forDay.length == 1 ? 'shift' : 'shifts'}',
),
if (forDay.isEmpty)
const EmptyState(
icon: LucideIcons.ticket,
title: 'Nothing booked this day',
message: 'Switch to Open above to claim a shift near you.',
)
else
for (final shift in forDay)
Padding(
padding: const EdgeInsets.only(bottom: 11),
child: ShiftCard(
shift: shift,
onTap: () => context.push('/shift/${shift.id}'),
),
),
const SizedBox(height: 6),
const InfoBanner(
'Auto-Match only offers you shifts inside the availability blocks you '
'have switched on.',
),
],
);
}
}
class _OpenTab extends ConsumerWidget {
const _OpenTab({
required this.query,
required this.filter,
required this.onQuery,
required this.onFilter,
});
final String query;
final String filter;
final ValueChanged<String> onQuery;
final ValueChanged<String> onFilter;
static const _filters = ['All', 'Today', 'This week', 'Top pay'];
@override
Widget build(BuildContext context, WidgetRef ref) {
var shifts = ref.watch(openShiftsProvider);
final now = DateTime.now();
if (query.isNotEmpty) {
final q = query.toLowerCase();
shifts = shifts
.where(
(s) =>
s.role.toLowerCase().contains(q) ||
s.business.toLowerCase().contains(q) ||
s.venue.toLowerCase().contains(q),
)
.toList();
}
shifts = switch (filter) {
'Today' => shifts.where((s) => Fmt.sameDay(s.start, now)).toList(),
'This week' =>
shifts.where((s) => s.start.difference(now).inDays < 7).toList(),
'Top pay' => shifts.where((s) => s.rate >= 22).toList(),
_ => shifts,
};
return ListView(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
children: [
TextField(
onChanged: onQuery,
decoration: const InputDecoration(
hintText: 'Role, business or area',
prefixIcon: Icon(LucideIcons.search, size: 18),
),
),
const SizedBox(height: 14),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final f in _filters)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(f),
selected: f == filter,
showCheckmark: false,
selectedColor: KrowPalette.blue,
labelStyle: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w500,
color: f == filter ? Colors.white : KrowPalette.mute,
),
onSelected: (_) => onFilter(f),
),
),
],
),
),
SectionLabel('${shifts.length} shifts · highest paying first'),
if (shifts.isEmpty)
const EmptyState(
icon: LucideIcons.search,
title: 'Nothing matches',
message: 'Try a different filter or clear your search.',
)
else
for (final shift in shifts)
Padding(
padding: const EdgeInsets.only(bottom: 11),
child: ShiftCard(
shift: shift,
showDate: true,
onTap: () => context.push('/shift/${shift.id}'),
trailingAction: _ApplyButton(shift: shift),
),
),
],
);
}
}
class _ApplyButton extends ConsumerWidget {
const _ApplyButton({required this.shift});
final Shift shift;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
Fmt.money(shift.scheduledPay),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
),
),
const SizedBox(width: 10),
FilledButton(
onPressed: () {
HapticFeedback.selectionClick();
ref.read(shiftsProvider.notifier).apply(shift.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${shift.role} at ${shift.business} booked for '
'${Fmt.dayShort(shift.start)}',
),
),
);
},
style: FilledButton.styleFrom(
minimumSize: const Size(0, 34),
padding: const EdgeInsets.symmetric(horizontal: 16),
textStyle: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
),
),
child: const Text('Apply'),
),
],
);
}
}
class _HistoryTab extends ConsumerWidget {
const _HistoryTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final history = ref.watch(historyShiftsProvider);
final completed = history
.where((s) => s.status == ShiftStatus.completed)
.length;
final cancelled = history
.where((s) => s.status == ShiftStatus.cancelled)
.length;
final logged = ref.watch(hoursLoggedProvider);
return ListView(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
children: [
StatRail(
stats: [
('Completed', '$completed'),
('Cancelled', '$cancelled'),
('Logged', Fmt.duration(logged)),
],
),
const SectionLabel('Everything you have worked'),
if (history.isEmpty)
const EmptyState(
icon: LucideIcons.history,
title: 'No history yet',
message: 'Shifts you finish or cancel will show up here.',
)
else
for (final shift in history)
Padding(
padding: const EdgeInsets.only(bottom: 11),
child: ShiftCard(
shift: shift,
showDate: true,
onTap: () => context.push('/shift/${shift.id}'),
),
),
],
);
}
}

View File

@@ -0,0 +1,388 @@
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: cards.isEmpty
? const Padding(
padding: EdgeInsets.all(20),
child: EmptyState(
icon: LucideIcons.fileText,
title: 'No timecards yet',
message: 'One is filed automatically every time you clock out.',
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
itemCount: cards.length,
separatorBuilder: (_, _) => const SizedBox(height: 11),
itemBuilder: (context, i) => _TimecardTile(card: cards[i]),
),
);
}
}
class _TimecardTile extends StatelessWidget {
const _TimecardTile({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<void> _dispute(BuildContext context, WidgetRef ref) async {
final controller = TextEditingController();
final note = await showDialog<String>(
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'),
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,421 @@
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/shift.dart';
import '../state/availability_controller.dart';
import '../state/compliance_controller.dart';
import '../state/profile_controller.dart';
import '../state/shifts_controller.dart';
import '../widgets/collapsing_header.dart';
import '../widgets/common.dart';
import '../widgets/shift_card.dart';
class TodayScreen extends ConsumerWidget {
const TodayScreen({super.key});
String _greeting() {
final h = DateTime.now().hour;
if (h < 12) return 'Good morning';
if (h < 18) return 'Good afternoon';
return 'Good evening';
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final worker = ref.watch(workerStatsProvider);
final next = ref.watch(nextShiftProvider);
final open = ref.watch(openShiftsProvider);
final scheduled = ref.watch(scheduledEarningsProvider);
final hours = ref.watch(hoursLoggedProvider);
final daysSet = ref.watch(daysSetProvider);
final compliant = ref.watch(isCompliantProvider);
final (done, total) = ref.watch(complianceProgressProvider);
return Scaffold(
body: CustomScrollView(
slivers: [
KrowSliverHeader(
expandedHeight: 152,
collapsedHeight: 60,
builder: (context, t) => HeaderCrossFade(
t: t,
expandedHeight: 152,
expanded: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_Avatar(worker.initials),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_greeting().toUpperCase(),
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.6,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5),
),
),
const SizedBox(height: 2),
Text(
worker.name,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: -0.7,
color: Colors.white,
),
),
],
),
),
HeaderIconButton(
icon: LucideIcons.messageCircle,
tooltip: 'Support',
onPressed: () => context.push('/support'),
),
],
),
const SizedBox(height: 18),
Row(
children: [
// Flexible, not free-sized: on a 320px screen the two
// captions plus the rule are wider than the header.
Flexible(
child: _HeaderStat(
Fmt.money(scheduled),
'This period',
),
),
Container(
width: 1,
height: 34,
margin: const EdgeInsets.symmetric(horizontal: 18),
color: Colors.white24,
),
Flexible(
child: _HeaderStat(
Fmt.duration(hours),
'Hours logged',
),
),
],
),
],
),
),
// Once collapsed, keep who you are and the figure that matters,
// plus the one control that was in the open state.
collapsed: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
child: Row(
children: [
_Avatar(worker.initials, size: 32),
const SizedBox(width: 10),
Expanded(
child: Text(
worker.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
color: Colors.white,
),
),
),
Text(
Fmt.money(scheduled),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: KrowPalette.yellow,
),
),
const SizedBox(width: 10),
HeaderIconButton(
icon: LucideIcons.messageCircle,
tooltip: 'Support',
onPressed: () => context.push('/support'),
),
],
),
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
sliver: SliverList.list(
children: [
if (!compliant) ...[
const SizedBox(height: 16),
KrowCard(
onTap: () => context.push('/compliance'),
padding: const EdgeInsets.all(14),
child: Row(
children: [
const Icon(
LucideIcons.triangleAlert,
size: 20,
color: KrowPalette.ink,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Finish your compliance',
style: TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'$done of $total items cleared',
style: const TextStyle(
fontSize: 12,
color: KrowPalette.mute,
),
),
],
),
),
const StatusChip('Action', tone: ChipTone.yellow),
],
),
),
],
const SectionLabel('Up next'),
if (next == null)
EmptyState(
icon: LucideIcons.ticket,
title: 'Nothing booked',
message:
'Open shifts near you start at '
'${open.isEmpty ? '-' : Fmt.money(open.last.rate)}/hr.',
action: FilledButton(
onPressed: () => context.go('/shifts'),
child: const Text('Browse open shifts'),
),
)
else
ShiftCard(
shift: next,
showDate: !Fmt.sameDay(next.start, DateTime.now()),
onTap: () => context.push('/shift/${next.id}'),
),
if (next != null && next.status == ShiftStatus.active) ...[
const SizedBox(height: 10),
FilledButton.icon(
onPressed: () => context.go('/clock'),
icon: const Icon(LucideIcons.squareCheck, size: 18),
label: const Text('You are on shift - go to clock'),
),
],
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: _QuickTile(
icon: LucideIcons.search,
label: 'Find shifts',
caption: '${open.length} OPEN',
onTap: () => context.go('/shifts'),
),
),
const SizedBox(width: 10),
Expanded(
child: _QuickTile(
icon: LucideIcons.calendar,
label: 'Availability',
caption: '$daysSet DAYS SET',
onTap: () => context.push('/availability'),
),
),
const SizedBox(width: 10),
Expanded(
child: _QuickTile(
icon: LucideIcons.banknote,
label: 'Earnings',
caption: 'PENDING',
onTap: () => context.go('/pay'),
),
),
],
),
SectionLabel(
'Matched to you',
trailing: TextButton(
onPressed: () => context.go('/shifts'),
child: const Text('See all'),
),
),
if (open.isEmpty)
const InfoBanner(
'No open shifts right now. Turn on more availability blocks '
'and we will match you to more work.',
)
else
for (final shift in open.take(3))
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ShiftCard(
shift: shift,
showDate: true,
onTap: () => context.push('/shift/${shift.id}'),
),
),
],
),
),
],
),
);
}
}
/// The worker's initials, used at two sizes as the header collapses.
class _Avatar extends StatelessWidget {
const _Avatar(this.initials, {this.size = 44});
final String initials;
final double size;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: KrowPalette.yellow,
borderRadius: BorderRadius.circular(size / 3.4),
),
child: Text(
initials,
style: TextStyle(
fontSize: size * 0.39,
fontWeight: FontWeight.w700,
color: KrowPalette.ink,
),
),
);
}
}
class _HeaderStat extends StatelessWidget {
const _HeaderStat(this.value, this.caption);
final String value;
final String caption;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Both lines hold to one line and shrink to fit: a wrapped caption
// would push the header past its pinned height.
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
value,
maxLines: 1,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
letterSpacing: -0.8,
color: Colors.white,
),
),
),
const SizedBox(height: 3),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
caption.toUpperCase(),
maxLines: 1,
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.4,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5),
),
),
),
],
);
}
}
class _QuickTile extends StatelessWidget {
const _QuickTile({
required this.icon,
required this.label,
required this.caption,
required this.onTap,
});
final IconData icon;
final String label;
final String caption;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return KrowCard(
onTap: onTap,
padding: const EdgeInsets.fromLTRB(12, 14, 12, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: KrowPalette.blueTint,
borderRadius: BorderRadius.circular(11),
),
child: Icon(icon, size: 17, color: KrowPalette.blue),
),
const SizedBox(height: 22),
Text(
label,
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
),
const SizedBox(height: 2),
Text(
caption,
style: const TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: Color(0xFF949FA5),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,128 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/availability.dart';
import 'session_controller.dart';
/// Availability is stored as `yyyy-mm-dd` -> the blocks switched on that day.
typedef AvailabilityMap = Map<String, Set<DayBlock>>;
String dayKey(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
class AvailabilityController extends Notifier<AvailabilityMap> {
static const _key = 'availability';
SharedPreferences get _prefs => ref.read(prefsProvider);
@override
AvailabilityMap build() {
final raw = _prefs.getStringList(_key) ?? const <String>[];
final map = <String, Set<DayBlock>>{};
for (final entry in raw) {
// Stored as "2026-08-14:morning,evening".
final parts = entry.split(':');
if (parts.length != 2) continue;
final blocks = parts[1]
.split(',')
.where((s) => s.isNotEmpty)
.map(
(name) => DayBlock.values.firstWhere(
(b) => b.name == name,
orElse: () => DayBlock.morning,
),
)
.toSet();
if (blocks.isNotEmpty) map[parts[0]] = blocks;
}
return map;
}
void _persist(AvailabilityMap map) {
_prefs.setStringList(_key, [
for (final e in map.entries)
if (e.value.isNotEmpty)
'${e.key}:${e.value.map((b) => b.name).join(',')}',
]);
state = map;
}
Set<DayBlock> blocksFor(DateTime day) => state[dayKey(day)] ?? <DayBlock>{};
bool isDayOff(DateTime day) => blocksFor(day).isEmpty;
void toggleBlock(DateTime day, DayBlock block) {
final map = {
for (final e in state.entries) e.key: {...e.value},
};
final key = dayKey(day);
final blocks = map[key] ?? <DayBlock>{};
if (!blocks.remove(block)) blocks.add(block);
if (blocks.isEmpty) {
map.remove(key);
} else {
map[key] = blocks;
}
_persist(map);
}
/// The day toggle: all three blocks on, or the day cleared.
void setDayAvailable(DateTime day, bool available) {
final map = {
for (final e in state.entries) e.key: {...e.value},
};
final key = dayKey(day);
if (available) {
map[key] = DayBlock.values.toSet();
} else {
map.remove(key);
}
_persist(map);
}
/// Presets across the visible week: all days, weekdays only, weekends only.
void applyPreset(
List<DateTime> week, {
required bool Function(DateTime) test,
}) {
final map = {
for (final e in state.entries) e.key: {...e.value},
};
for (final day in week) {
final key = dayKey(day);
if (test(day)) {
map[key] = DayBlock.values.toSet();
} else {
map.remove(key);
}
}
_persist(map);
}
void clearWeek(List<DateTime> week) {
final map = {
for (final e in state.entries) e.key: {...e.value},
};
for (final day in week) {
map.remove(dayKey(day));
}
_persist(map);
}
}
final availabilityProvider =
NotifierProvider<AvailabilityController, AvailabilityMap>(
AvailabilityController.new,
);
/// How many days in the coming week have at least one block switched on.
final daysSetProvider = Provider<int>((ref) {
final map = ref.watch(availabilityProvider);
final now = DateTime.now();
var count = 0;
for (var i = 0; i < 7; i++) {
final d = now.add(Duration(days: i));
if ((map[dayKey(d)] ?? const <DayBlock>{}).isNotEmpty) count++;
}
return count;
});

View File

@@ -0,0 +1,178 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/compliance.dart';
import 'seed.dart';
/// The four compliance sections, each a list the worker can act on.
enum ComplianceSection { documents, certificates, taxForms, wardrobe }
extension ComplianceSectionX on ComplianceSection {
String get title => switch (this) {
ComplianceSection.documents => 'Documents',
ComplianceSection.certificates => 'Certificates',
ComplianceSection.taxForms => 'Tax forms',
ComplianceSection.wardrobe => 'Attire',
};
String get subtitle => switch (this) {
ComplianceSection.documents => 'ID, SSN, work permit, address',
ComplianceSection.certificates => 'Food handler, RBS and more',
ComplianceSection.taxForms => 'Form I-9 and Form W-4',
ComplianceSection.wardrobe => "Photos of what you'll wear",
};
}
class ComplianceData {
const ComplianceData({
required this.documents,
required this.certificates,
required this.taxForms,
required this.wardrobe,
});
final List<ComplianceItem> documents;
final List<ComplianceItem> certificates;
final List<ComplianceItem> taxForms;
final List<ComplianceItem> wardrobe;
List<ComplianceItem> section(ComplianceSection s) => switch (s) {
ComplianceSection.documents => documents,
ComplianceSection.certificates => certificates,
ComplianceSection.taxForms => taxForms,
ComplianceSection.wardrobe => wardrobe,
};
List<ComplianceItem> get all => [
...documents,
...certificates,
...taxForms,
...wardrobe,
];
ComplianceData copyWith({
List<ComplianceItem>? documents,
List<ComplianceItem>? certificates,
List<ComplianceItem>? taxForms,
List<ComplianceItem>? wardrobe,
}) => ComplianceData(
documents: documents ?? this.documents,
certificates: certificates ?? this.certificates,
taxForms: taxForms ?? this.taxForms,
wardrobe: wardrobe ?? this.wardrobe,
);
}
class ComplianceController extends Notifier<ComplianceData> {
@override
ComplianceData build() => ComplianceData(
documents: Seed.documents(),
certificates: Seed.certificates(),
taxForms: Seed.taxForms(),
wardrobe: Seed.wardrobe(),
);
List<ComplianceItem> _apply(
List<ComplianceItem> list,
String id,
ComplianceItem Function(ComplianceItem) f,
) => [
for (final i in list)
if (i.id == id) f(i) else i,
];
void _update(
ComplianceSection section,
String id,
ComplianceItem Function(ComplianceItem) f,
) {
final s = state;
state = switch (section) {
ComplianceSection.documents => s.copyWith(
documents: _apply(s.documents, id, f),
),
ComplianceSection.certificates => s.copyWith(
certificates: _apply(s.certificates, id, f),
),
ComplianceSection.taxForms => s.copyWith(
taxForms: _apply(s.taxForms, id, f),
),
ComplianceSection.wardrobe => s.copyWith(
wardrobe: _apply(s.wardrobe, id, f),
),
};
}
/// Stand-in for the camera / file picker: attaching a file sends the item
/// back for review.
void uploadFile(ComplianceSection section, String id, String fileName) {
_update(
section,
id,
(i) => i.copyWith(
state: ComplianceState.pending,
statusLabel: 'In review',
fileName: fileName,
fileMeta: 'Uploaded just now',
),
);
}
void removeFile(ComplianceSection section, String id) {
_update(
section,
id,
(i) => ComplianceItem(
id: i.id,
title: i.title,
subtitle: i.subtitle,
state: ComplianceState.missing,
statusLabel: 'Not added',
required_: i.required_,
),
);
}
void setExpiry(ComplianceSection section, String id, DateTime date) {
_update(section, id, (i) => i.copyWith(expires: date));
}
ComplianceItem? find(ComplianceSection section, String id) {
for (final i in state.section(section)) {
if (i.id == id) return i;
}
return null;
}
}
final complianceProvider =
NotifierProvider<ComplianceController, ComplianceData>(
ComplianceController.new,
);
/// `(verified, total)` across every required item — what the hub reports.
final complianceProgressProvider = Provider<(int, int)>((ref) {
final items = ref.watch(complianceProvider).all.where((i) => i.required_);
final total = items.length;
final done = items.where((i) => i.state == ComplianceState.verified).length;
return (done, total);
});
/// Whether the worker is cleared to take shifts at all.
final isCompliantProvider = Provider<bool>((ref) {
final (done, total) = ref.watch(complianceProgressProvider);
return done == total;
});
final sectionProgressProvider = Provider.family<(int, int), ComplianceSection>((
ref,
section,
) {
final items = ref
.watch(complianceProvider)
.section(section)
.where((i) => i.required_);
return (
items.where((i) => i.state == ComplianceState.verified).length,
items.length,
);
});

View File

@@ -0,0 +1,168 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/compliance.dart';
import '../models/shift.dart';
import '../models/worker.dart';
import 'session_controller.dart';
import 'shifts_controller.dart';
class ProfileState {
const ProfileState({
required this.worker,
required this.settings,
required this.skills,
required this.contacts,
this.bank,
this.newsletter = false,
});
final Worker worker;
/// Settings the worker has worked in, and the trades they can do.
final Set<String> settings;
final Set<String> skills;
final List<EmergencyContact> contacts;
final BankAccount? bank;
final bool newsletter;
ProfileState copyWith({
Worker? worker,
Set<String>? settings,
Set<String>? skills,
List<EmergencyContact>? contacts,
BankAccount? bank,
bool? newsletter,
}) => ProfileState(
worker: worker ?? this.worker,
settings: settings ?? this.settings,
skills: skills ?? this.skills,
contacts: contacts ?? this.contacts,
bank: bank ?? this.bank,
newsletter: newsletter ?? this.newsletter,
);
}
class ProfileController extends Notifier<ProfileState> {
static const _kSettings = 'profile_settings';
static const _kSkills = 'profile_skills';
static const _kNewsletter = 'profile_newsletter';
static const _kName = 'profile_name';
SharedPreferences get _prefs => ref.read(prefsProvider);
@override
ProfileState build() {
final p = _prefs;
final phone = ref.watch(sessionProvider).phone;
return ProfileState(
worker: Worker(
name: p.getString(_kName) ?? 'Iliana Ortega',
role: 'Barista',
city: 'San Jose',
phone: phone ?? '+1 408 555 0148',
onTimePercent: 50,
noShows: 1,
cancellations: 1,
),
settings:
(p.getStringList(_kSettings) ??
const ['Hospitality', 'Events', 'Catering'])
.toSet(),
skills:
(p.getStringList(_kSkills) ??
const ['Barista', 'Customer service', 'Food service'])
.toSet(),
contacts: [
EmergencyContact(
name: 'Carlos Ortega',
phone: '+1 555 001 0006',
relationship: 'Spouse',
),
],
newsletter: p.getBool(_kNewsletter) ?? false,
);
}
void updateName(String name) {
_prefs.setString(_kName, name);
state = state.copyWith(worker: state.worker.copyWith(name: name));
}
void toggleSetting(String value) {
final next = {...state.settings};
if (!next.remove(value)) next.add(value);
_prefs.setStringList(_kSettings, next.toList());
state = state.copyWith(settings: next);
}
void toggleSkill(String value) {
final next = {...state.skills};
if (!next.remove(value)) next.add(value);
_prefs.setStringList(_kSkills, next.toList());
state = state.copyWith(skills: next);
}
void addSkill(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return;
final next = {...state.skills, trimmed};
_prefs.setStringList(_kSkills, next.toList());
state = state.copyWith(skills: next);
}
void saveContact(EmergencyContact contact, {int? index}) {
final next = [...state.contacts];
if (index != null && index < next.length) {
next[index] = contact;
} else {
next.add(contact);
}
state = state.copyWith(contacts: next);
}
void removeContact(int index) {
final next = [...state.contacts]..removeAt(index);
state = state.copyWith(contacts: next);
}
void saveBank(BankAccount account) {
state = state.copyWith(bank: account);
}
void setNewsletter(bool value) {
_prefs.setBool(_kNewsletter, value);
state = state.copyWith(newsletter: value);
}
}
final profileProvider = NotifierProvider<ProfileController, ProfileState>(
ProfileController.new,
);
/// Live stats derived from the shift list, so the profile reflects what the
/// worker has actually done in this session.
final workerStatsProvider = Provider<Worker>((ref) {
final base = ref.watch(profileProvider).worker;
final shifts = ref.watch(shiftsProvider);
final completed = shifts
.where((s) => s.status == ShiftStatus.completed)
.toList();
final noShows = shifts.where((s) => s.status == ShiftStatus.noShow).length;
final cancels = shifts.where((s) => s.status == ShiftStatus.cancelled).length;
final onTime = completed
.where(
(s) =>
s.clockIn != null &&
!s.clockIn!.isAfter(s.start.add(const Duration(minutes: 5))),
)
.length;
return base.copyWith(
shiftsWorked: completed.length,
onTimePercent: completed.isEmpty
? 100
: (onTime * 100 / completed.length).round(),
noShows: noShows,
cancellations: cancels,
);
});

392
lib/state/seed.dart Normal file
View File

@@ -0,0 +1,392 @@
import '../models/compliance.dart';
import '../models/shift.dart';
import '../models/timecard.dart';
/// Demo content. Everything is anchored to "now" so the app is live whenever
/// it is opened: the next shift always starts in 41 minutes.
abstract final class Seed {
static DateTime get _now => DateTime.now();
static DateTime _at(int dayOffset, int hour, int minute) {
final base = _now.add(Duration(days: dayOffset));
return DateTime(base.year, base.month, base.day, hour, minute);
}
static const _baristaReqs = [
'Government ID',
'Food handler certification',
'Black pants',
'Black shirt',
'Non-slip black shoes',
];
static List<Shift> shifts() {
final now = _now;
// Inside the 30-minute clock-in window, so the app opens on a shift you
// can actually start.
final soon = now.add(const Duration(minutes: 20));
return [
// Today: the shift you are about to clock into.
Shift(
id: 's1',
role: 'Barista',
business: 'Zoox',
venue: 'Zoox - San Jose Hub',
address: '848 E Gish Rd, San Jose CA',
start: soon,
end: soon.add(const Duration(hours: 6)),
rate: 21.00,
status: ShiftStatus.booked,
distanceMiles: 2.4,
manager: 'Kirk R. - Owner',
requirements: _baristaReqs,
),
Shift(
id: 's2',
role: 'Server',
business: 'Google LLC',
venue: 'Google LLC - Midpoint',
address: 'Midpoint, Sunnyvale CA',
start: _at(1, 13, 0),
end: _at(1, 18, 0),
rate: 24.00,
status: ShiftStatus.booked,
distanceMiles: 3.1,
manager: 'Dana W. - Floor lead',
requirements: _baristaReqs,
),
// Open market, highest paying first.
Shift(
id: 's3',
role: 'Bartender',
business: 'Google LLC',
venue: 'Google LLC - Midpoint',
address: 'Midpoint, Sunnyvale CA',
start: _at(2, 16, 0),
end: _at(2, 23, 0),
rate: 28.00,
status: ShiftStatus.open,
distanceMiles: 3.1,
requirements: [..._baristaReqs, 'Responsible beverage service'],
),
Shift(
id: 's4',
role: 'Barista',
business: 'Zoox',
venue: 'Zoox - San Jose Hub',
address: '848 E Gish Rd, San Jose CA',
start: _at(3, 18, 0),
end: _at(3, 23, 50),
rate: 21.00,
status: ShiftStatus.open,
distanceMiles: 2.4,
requirements: _baristaReqs,
),
Shift(
id: 's5',
role: 'Host',
business: 'Google LLC',
venue: 'Google LLC - Midpoint',
address: 'Midpoint, Sunnyvale CA',
start: _at(4, 11, 0),
end: _at(4, 17, 0),
rate: 19.50,
status: ShiftStatus.open,
distanceMiles: 3.1,
requirements: _baristaReqs,
),
Shift(
id: 's6',
role: 'Banquet server',
business: 'Legendary Events',
venue: 'Legendary Events - Convention Centre',
address: '150 W San Carlos St, San Jose CA',
start: _at(5, 9, 0),
end: _at(5, 17, 30),
rate: 22.50,
status: ShiftStatus.open,
distanceMiles: 4.8,
requirements: _baristaReqs,
),
// Worked history.
Shift(
id: 's7',
role: 'Barista',
business: 'Zoox',
venue: 'Zoox - San Jose Hub',
address: '848 E Gish Rd, San Jose CA',
start: _at(-4, 17, 50),
end: _at(-4, 23, 50),
rate: 21.00,
status: ShiftStatus.completed,
distanceMiles: 2.4,
clockIn: _at(-4, 18, 1),
clockOut: _at(-4, 23, 50),
requirements: _baristaReqs,
),
Shift(
id: 's8',
role: 'Barista',
business: 'Google LLC',
venue: 'Google LLC - Midpoint',
address: 'Midpoint, Sunnyvale CA',
start: _at(-6, 11, 0),
end: _at(-6, 18, 0),
rate: 21.00,
status: ShiftStatus.cancelled,
distanceMiles: 3.1,
requirements: _baristaReqs,
),
Shift(
id: 's9',
role: 'Barista',
business: 'Google LLC',
venue: 'Google LLC - Midpoint',
address: 'Midpoint, Sunnyvale CA',
start: _at(-13, 9, 0),
end: _at(-13, 15, 0),
rate: 21.00,
status: ShiftStatus.noShow,
distanceMiles: 3.1,
requirements: _baristaReqs,
),
];
}
static List<Timecard> timecards() {
final worked = shifts().firstWhere((s) => s.id == 's7');
return [
Timecard(
id: 'tc1',
shiftId: worked.id,
role: worked.role,
business: worked.business,
scheduledStart: worked.start,
scheduledEnd: worked.end,
clockIn: worked.clockIn!,
clockOut: worked.clockOut!,
breakMinutes: worked.breakMinutes,
rate: worked.rate,
status: TimecardStatus.needsReview,
submittedAt: worked.clockOut!.add(const Duration(minutes: 12)),
),
];
}
static List<ComplianceItem> documents() => [
ComplianceItem(
id: 'd1',
title: 'Government ID',
subtitle: 'State ID or passport',
state: ComplianceState.verified,
fileName: 'government-id-front.jpg',
fileMeta: '2.1 MB - uploaded 2 Aug',
expires: DateTime(2029, 3, 14),
),
ComplianceItem(
id: 'd2',
title: 'Social security',
subtitle: 'Card or SSA letter',
state: ComplianceState.verified,
fileName: 'ssn-card.jpg',
fileMeta: '1.2 MB - uploaded 2 Aug',
),
ComplianceItem(
id: 'd3',
title: 'Work permit',
subtitle: 'Right-to-work evidence',
state: ComplianceState.verified,
fileName: 'work-permit.pdf',
fileMeta: '640 KB - uploaded 2 Aug',
),
ComplianceItem(
id: 'd4',
title: 'Proof of address',
subtitle: 'Bill or statement, last 90 days',
state: ComplianceState.pending,
statusLabel: 'In review',
fileName: 'utility-bill.pdf',
fileMeta: '380 KB - uploaded yesterday',
),
];
static List<ComplianceItem> certificates() => [
ComplianceItem(
id: 'c1',
title: 'Food handler certification',
subtitle: 'Accepted by all California venues',
state: ComplianceState.verified,
statusLabel: 'Validated',
fileName: 'food-safety-certificate.pdf',
fileMeta: '840 KB - uploaded 2 Aug',
expires: DateTime(2027, 3, 14),
),
ComplianceItem(
id: 'c2',
title: 'Responsible beverage service',
subtitle: 'Needed for bartender shifts',
state: ComplianceState.verified,
statusLabel: 'Validated',
fileName: 'rbs-certificate.pdf',
fileMeta: '510 KB - uploaded 2 Aug',
expires: DateTime(2028, 1, 9),
),
ComplianceItem(
id: 'c3',
title: 'CPR / AED',
subtitle: 'Adds around 4 shifts a week near you',
state: ComplianceState.missing,
statusLabel: 'Not added',
required_: false,
),
ComplianceItem(
id: 'c4',
title: 'Forklift licence',
subtitle: 'Warehouse and logistics roles',
state: ComplianceState.missing,
statusLabel: 'Not added',
required_: false,
),
];
static List<ComplianceItem> taxForms() => [
ComplianceItem(
id: 't1',
title: 'Form I-9',
subtitle: 'Employment eligibility verification',
state: ComplianceState.verified,
statusLabel: 'Approved',
fileName: 'i9-form.pdf',
fileMeta: 'Approved 3 Aug',
),
ComplianceItem(
id: 't2',
title: 'Form W-4',
subtitle: 'Sets how much tax is withheld',
state: ComplianceState.verified,
statusLabel: 'Approved',
fileName: 'w4-form.pdf',
fileMeta: 'Approved 3 Aug',
),
];
static List<ComplianceItem> wardrobe() => [
ComplianceItem(
id: 'w1',
title: 'Black pants',
subtitle: 'Solid black dress pants or slacks',
state: ComplianceState.verified,
statusLabel: 'Approved',
fileName: 'black-pants.jpg',
fileMeta: '1.4 MB - uploaded 2 Aug',
),
ComplianceItem(
id: 'w2',
title: 'Black shirt',
subtitle: 'Plain, no logos',
state: ComplianceState.verified,
statusLabel: 'Approved',
fileName: 'black-shirt.jpg',
fileMeta: '1.1 MB - uploaded 2 Aug',
),
ComplianceItem(
id: 'w3',
title: 'Non-slip black shoes',
subtitle: 'Closed-toe, required on every shift',
state: ComplianceState.verified,
statusLabel: 'Approved',
fileName: 'black-shoes.jpg',
fileMeta: '0.9 MB - uploaded 2 Aug',
),
ComplianceItem(
id: 'w4',
title: 'Black belt',
subtitle: 'Plain, simple buckle',
state: ComplianceState.missing,
statusLabel: 'Not added',
required_: false,
),
];
static const workSettings = <String>[
'Hospitality',
'Restaurant',
'Warehouse',
'Logistics',
'Events',
'Stadiums',
'Education',
'Healthcare & cafeteria',
'Convention centre',
'Catering',
];
static const skills = <String>[
'Food prep',
'Cooking',
'Grilling',
'Dishwashing',
'Food service',
'Banquet',
'Bartender',
'Barista',
'Customer service',
'Safe food handling',
'Event setup',
];
static const supportTopics = <String>[
'Shift issue',
'Pay or billing',
'Schedule clash',
'Account',
'Bug report',
'Feature idea',
];
static const faqs = <String, List<(String, String)>>{
'Getting started': [
(
'How do I finish my worker profile?',
'Open Profile, then Compliance. Work down the sections until every one '
'shows a green tick. You can accept shifts as soon as documents, '
'tax forms and attire are complete.',
),
(
"Why can't I apply to shifts yet?",
'Businesses only see workers who are fully compliant. Anything still '
'marked yellow on the compliance hub is blocking you.',
),
],
'Shifts and attendance': [
(
'How does clock-in work?',
'The clock-in button unlocks 30 minutes before your shift starts, once '
'you are within 150 m of the venue. Tap out when you leave and the '
'timecard submits itself.',
),
(
'What happens if I request a swap?',
'Your shift is offered to other qualified workers. You stay responsible '
'for it until someone accepts, so keep an eye on notifications.',
),
(
'What counts as a no-show?',
'Not clocking in within 30 minutes of the scheduled start, with no '
'cancellation. No-shows cost 15 points of trust index each.',
),
],
'Pay and compliance': [
(
'When do I actually get paid?',
'Semi-monthly. Each period closes on the 15th and the last day of the '
'month, and pays 11 days later.',
),
(
'How are documents verified?',
'A reviewer checks each upload by hand, usually within one business '
'day. You get a notification either way.',
),
],
};
}

View File

@@ -0,0 +1,115 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Injected in `main()` once SharedPreferences has loaded.
final prefsProvider = Provider<SharedPreferences>(
(ref) => throw UnimplementedError('prefsProvider must be overridden'),
);
class SessionState {
const SessionState({
this.onboardingSeen = false,
this.phone,
this.signedIn = false,
this.locationGranted = false,
this.notificationsGranted = false,
this.cameraGranted = false,
});
final bool onboardingSeen;
final String? phone;
final bool signedIn;
final bool locationGranted;
final bool notificationsGranted;
final bool cameraGranted;
/// The three-step permission run shown right after verification.
bool get permissionsDone =>
locationGranted && notificationsGranted && cameraGranted;
SessionState copyWith({
bool? onboardingSeen,
String? phone,
bool? signedIn,
bool? locationGranted,
bool? notificationsGranted,
bool? cameraGranted,
}) => SessionState(
onboardingSeen: onboardingSeen ?? this.onboardingSeen,
phone: phone ?? this.phone,
signedIn: signedIn ?? this.signedIn,
locationGranted: locationGranted ?? this.locationGranted,
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
cameraGranted: cameraGranted ?? this.cameraGranted,
);
}
class SessionController extends Notifier<SessionState> {
static const _kOnboarding = 'onboarding_seen';
static const _kPhone = 'phone';
static const _kSignedIn = 'signed_in';
static const _kLocation = 'perm_location';
static const _kNotifications = 'perm_notifications';
static const _kCamera = 'perm_camera';
SharedPreferences get _prefs => ref.read(prefsProvider);
@override
SessionState build() {
final p = _prefs;
return SessionState(
onboardingSeen: p.getBool(_kOnboarding) ?? false,
phone: p.getString(_kPhone),
signedIn: p.getBool(_kSignedIn) ?? false,
locationGranted: p.getBool(_kLocation) ?? false,
notificationsGranted: p.getBool(_kNotifications) ?? false,
cameraGranted: p.getBool(_kCamera) ?? false,
);
}
void completeOnboarding() {
_prefs.setBool(_kOnboarding, true);
state = state.copyWith(onboardingSeen: true);
}
/// The demo backend accepts any 10-digit number and always sends `123456`.
static const demoCode = '123456';
String sendCode(String phone) {
_prefs.setString(_kPhone, phone);
state = state.copyWith(phone: phone);
return demoCode;
}
bool verifyCode(String code) {
if (code != demoCode) return false;
_prefs.setBool(_kSignedIn, true);
state = state.copyWith(signedIn: true);
return true;
}
void grantLocation() {
_prefs.setBool(_kLocation, true);
state = state.copyWith(locationGranted: true);
}
void grantNotifications() {
_prefs.setBool(_kNotifications, true);
state = state.copyWith(notificationsGranted: true);
}
void grantCamera() {
_prefs.setBool(_kCamera, true);
state = state.copyWith(cameraGranted: true);
}
Future<void> signOut() async {
await _prefs.remove(_kSignedIn);
await _prefs.remove(_kPhone);
state = state.copyWith(signedIn: false, phone: '');
}
}
final sessionProvider = NotifierProvider<SessionController, SessionState>(
SessionController.new,
);

View File

@@ -0,0 +1,172 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/formatters.dart';
import '../models/shift.dart';
import '../models/timecard.dart';
import 'seed.dart';
import 'timecards_controller.dart';
class ShiftsController extends Notifier<List<Shift>> {
@override
List<Shift> build() => Seed.shifts();
Shift? byId(String id) {
for (final s in state) {
if (s.id == id) return s;
}
return null;
}
void _replace(Shift updated) {
state = [
for (final s in state)
if (s.id == updated.id) updated else s,
];
}
/// Accept an open shift. It moves straight into Booked.
void apply(String id) {
final shift = byId(id);
if (shift == null || shift.status != ShiftStatus.open) return;
_replace(shift.copyWith(status: ShiftStatus.booked));
}
/// Give a booked shift back. Counts against the cancellation tally.
void cancel(String id) {
final shift = byId(id);
if (shift == null) return;
_replace(shift.copyWith(status: ShiftStatus.cancelled));
}
void clockIn(String id) {
final shift = byId(id);
if (shift == null || shift.status != ShiftStatus.booked) return;
_replace(
shift.copyWith(status: ShiftStatus.active, clockIn: DateTime.now()),
);
}
/// Clocking out completes the shift and files the timecard automatically.
Timecard? clockOut(String id) {
final shift = byId(id);
if (shift == null || shift.status != ShiftStatus.active) return null;
final now = DateTime.now();
final done = shift.copyWith(status: ShiftStatus.completed, clockOut: now);
_replace(done);
final card = Timecard(
id: 'tc-${shift.id}-${now.millisecondsSinceEpoch}',
shiftId: shift.id,
role: shift.role,
business: shift.business,
scheduledStart: shift.start,
scheduledEnd: shift.end,
clockIn: done.clockIn!,
clockOut: now,
breakMinutes: shift.breakMinutes,
rate: shift.rate,
status: TimecardStatus.needsReview,
submittedAt: now,
);
ref.read(timecardsProvider.notifier).add(card);
return card;
}
}
final shiftsProvider = NotifierProvider<ShiftsController, List<Shift>>(
ShiftsController.new,
);
final bookedShiftsProvider = Provider<List<Shift>>((ref) {
final list =
ref
.watch(shiftsProvider)
.where(
(s) =>
s.status == ShiftStatus.booked ||
s.status == ShiftStatus.active,
)
.toList()
..sort((a, b) => a.start.compareTo(b.start));
return list;
});
final openShiftsProvider = Provider<List<Shift>>((ref) {
final list =
ref
.watch(shiftsProvider)
.where((s) => s.status == ShiftStatus.open)
.toList()
..sort((a, b) => b.rate.compareTo(a.rate));
return list;
});
final historyShiftsProvider = Provider<List<Shift>>((ref) {
final list =
ref
.watch(shiftsProvider)
.where(
(s) => const {
ShiftStatus.completed,
ShiftStatus.cancelled,
ShiftStatus.noShow,
}.contains(s.status),
)
.toList()
..sort((a, b) => b.start.compareTo(a.start));
return list;
});
/// The shift the Today screen leads with: whatever is running now, else the
/// next one on the calendar.
final nextShiftProvider = Provider<Shift?>((ref) {
final booked = ref.watch(bookedShiftsProvider);
for (final s in booked) {
if (s.status == ShiftStatus.active) return s;
}
final now = DateTime.now();
for (final s in booked) {
if (s.end.isAfter(now)) return s;
}
return booked.isEmpty ? null : booked.first;
});
final activeShiftProvider = Provider<Shift?>((ref) {
for (final s in ref.watch(shiftsProvider)) {
if (s.status == ShiftStatus.active) return s;
}
return null;
});
/// Gross pay, pre-tax, already banked from completed shifts.
final completedEarningsProvider = Provider<double>((ref) {
return ref
.watch(shiftsProvider)
.where((s) => s.status == ShiftStatus.completed)
.fold<double>(0, (sum, s) => sum + s.pay);
});
final hoursLoggedProvider = Provider<Duration>((ref) {
return ref
.watch(shiftsProvider)
.where((s) => s.status == ShiftStatus.completed)
.fold<Duration>(
Duration.zero,
(sum, s) => sum + (s.workedDuration ?? Duration.zero),
);
});
/// Booked but not yet worked: the money still coming.
final scheduledEarningsProvider = Provider<double>((ref) {
return ref
.watch(bookedShiftsProvider)
.fold<double>(0, (sum, s) => sum + s.scheduledPay);
});
/// Shifts falling on a given calendar day, used by the week strip.
final shiftsOnDayProvider = Provider.family<List<Shift>, DateTime>((ref, day) {
return ref
.watch(bookedShiftsProvider)
.where((s) => Fmt.sameDay(s.start, day))
.toList();
});

View File

@@ -0,0 +1,49 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/timecard.dart';
import 'seed.dart';
class TimecardsController extends Notifier<List<Timecard>> {
@override
List<Timecard> build() => Seed.timecards();
void add(Timecard card) {
state = [card, ...state];
}
void approve(String id) {
state = [
for (final t in state)
if (t.id == id) t.copyWith(status: TimecardStatus.approved) else t,
];
}
void dispute(String id, String note) {
state = [
for (final t in state)
if (t.id == id)
t.copyWith(status: TimecardStatus.disputed, disputeNote: note)
else
t,
];
}
Timecard? byId(String id) {
for (final t in state) {
if (t.id == id) return t;
}
return null;
}
}
final timecardsProvider = NotifierProvider<TimecardsController, List<Timecard>>(
TimecardsController.new,
);
/// Timecards still waiting on the worker to confirm or dispute.
final timecardsNeedingReviewProvider = Provider<List<Timecard>>((ref) {
return ref
.watch(timecardsProvider)
.where((t) => t.status == TimecardStatus.needsReview)
.toList();
});

View File

@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'common.dart';
/// Builds header content for a given collapse progress: 0 fully open, 1 fully
/// collapsed.
typedef CollapsingHeaderBuilder =
Widget Function(BuildContext context, double t);
/// The pinned brand header used by the main tabs.
///
/// It stays on screen while the content scrolls beneath it, and shrinks from
/// [expandedHeight] to [collapsedHeight] on the way. Pinning a tall header at
/// full size would cost a quarter of a phone screen for the whole session;
/// collapsing keeps the context and gives the space back.
class KrowSliverHeader extends StatelessWidget {
const KrowSliverHeader({
super.key,
required this.expandedHeight,
required this.collapsedHeight,
required this.builder,
});
/// Heights exclude the status bar, which is added for you.
final double expandedHeight;
final double collapsedHeight;
final CollapsingHeaderBuilder builder;
@override
Widget build(BuildContext context) {
final top = MediaQuery.paddingOf(context).top;
return SliverPersistentHeader(
pinned: true,
delegate: _HeaderDelegate(
minHeight: collapsedHeight + top,
maxHeight: expandedHeight + top,
topInset: top,
builder: builder,
),
);
}
}
class _HeaderDelegate extends SliverPersistentHeaderDelegate {
_HeaderDelegate({
required this.minHeight,
required this.maxHeight,
required this.topInset,
required this.builder,
});
final double minHeight;
final double maxHeight;
final double topInset;
final CollapsingHeaderBuilder builder;
@override
double get minExtent => minHeight;
@override
double get maxExtent => maxHeight;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlaps) {
final range = maxHeight - minHeight;
final t = range <= 0 ? 1.0 : (shrinkOffset / range).clamp(0.0, 1.0);
return BrandSurface(
child: Padding(
padding: EdgeInsets.only(top: topInset),
child: builder(context, t),
),
);
}
@override
bool shouldRebuild(_HeaderDelegate old) =>
old.minHeight != minHeight ||
old.maxHeight != maxHeight ||
old.topInset != topInset ||
old.builder != builder;
}
/// Cross-fades a header between its open and collapsed states.
///
/// The open content keeps its natural size and is clipped as the header
/// shrinks — letting it compress instead would squash the type on the way
/// down, and would overflow before it finished fading.
class HeaderCrossFade extends StatelessWidget {
const HeaderCrossFade({
super.key,
required this.t,
required this.expandedHeight,
required this.expanded,
required this.collapsed,
});
final double t;
final double expandedHeight;
final Widget expanded;
final Widget collapsed;
@override
Widget build(BuildContext context) {
// The open state clears out over the first two thirds; the collapsed bar
// arrives over the last half. They overlap briefly, which reads as one
// state becoming the other rather than a swap.
final openOpacity = (1 - t * 1.5).clamp(0.0, 1.0);
final shutOpacity = ((t - 0.5) * 2).clamp(0.0, 1.0);
return Stack(
fit: StackFit.expand,
children: [
if (openOpacity > 0)
ClipRect(
child: OverflowBox(
alignment: Alignment.topCenter,
minHeight: 0,
// Unbounded, so content taller than the header lays out at its
// natural size and is clipped. Capping it here instead would
// throw a layout overflow — and one only visible in a debug
// build, so it reaches a device before it reaches you.
maxHeight: double.infinity,
child: Opacity(
opacity: openOpacity,
child: IgnorePointer(ignoring: t > 0.5, child: expanded),
),
),
),
if (shutOpacity > 0)
Opacity(
opacity: shutOpacity,
child: IgnorePointer(ignoring: t < 0.5, child: collapsed),
),
],
);
}
}

835
lib/widgets/common.dart Normal file
View File

@@ -0,0 +1,835 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/theme.dart';
/// Small uppercase section heading used above every group of cards.
class SectionLabel extends StatelessWidget {
const SectionLabel(this.text, {super.key, this.trailing});
final String text;
final Widget? trailing;
@override
Widget build(BuildContext context) {
// No horizontal inset: the label has to share a left edge with the cards
// and body copy it sits above.
return Padding(
padding: const EdgeInsets.fromLTRB(0, 22, 0, 10),
child: Row(
children: [
Expanded(
child: Text(
text.toUpperCase(),
style: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w600,
letterSpacing: 1.4,
color: Color(0xFF949FA5),
),
),
),
?trailing,
],
),
);
}
}
enum ChipTone { blue, yellow, neutral, muted, danger }
/// Status pill. Yellow marks anything waiting on the worker.
class StatusChip extends StatelessWidget {
const StatusChip(
this.label, {
super.key,
this.tone = ChipTone.neutral,
this.icon,
});
final String label;
final ChipTone tone;
final IconData? icon;
@override
Widget build(BuildContext context) {
final (bg, fg) = switch (tone) {
ChipTone.blue => (KrowPalette.blueTint, KrowPalette.blue),
ChipTone.yellow => (KrowPalette.yellow, KrowPalette.ink),
ChipTone.neutral => (const Color(0xFFDEEBE2), KrowPalette.ink),
ChipTone.muted => (const Color(0xFFDEEBE2), const Color(0xFF65727A)),
ChipTone.danger => (const Color(0xFFFBE3E3), const Color(0xFFA6403F)),
};
return Container(
padding: EdgeInsets.fromLTRB(icon == null ? 10 : 8, 6, 10, 6),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(9),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 13, color: fg),
const SizedBox(width: 5),
],
// Flexible so the chip gives way when the row it sits in runs out of
// width — at a large text scale it otherwise overflows its parent.
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: fg,
),
),
),
],
),
);
}
}
/// Soft informational panel. `warning: true` switches it to the yellow tone.
class InfoBanner extends StatelessWidget {
const InfoBanner(this.text, {super.key, this.warning = false, this.icon});
final String text;
final bool warning;
final IconData? icon;
@override
Widget build(BuildContext context) {
final bg = warning ? const Color(0xFFF7E4AE) : KrowPalette.blueTint;
final fg = warning ? KrowPalette.ink : KrowPalette.blueDeep;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon ?? (warning ? LucideIcons.triangleAlert : LucideIcons.info),
size: 16,
color: fg,
),
const SizedBox(width: 11),
Expanded(
child: Text(
text,
style: TextStyle(fontSize: 12.5, height: 1.5, color: fg),
),
),
],
),
);
}
}
/// White rounded container used for every grouped block.
class KrowCard extends StatelessWidget {
const KrowCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(16),
this.onTap,
this.dashed = false,
});
final Widget child;
final EdgeInsetsGeometry padding;
final VoidCallback? onTap;
final bool dashed;
@override
Widget build(BuildContext context) {
return Material(
color: dashed ? Colors.transparent : Colors.white,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: padding,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: KrowPalette.line),
),
child: child,
),
),
);
}
}
/// A navigation row: icon tile, title, subtitle, optional badge, chevron.
class NavTile extends StatelessWidget {
const NavTile({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.badge,
this.onTap,
});
final IconData icon;
final String title;
final String? subtitle;
final Widget? badge;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: KrowPalette.blueTint,
borderRadius: BorderRadius.circular(11),
),
child: Icon(icon, size: 18, color: KrowPalette.blue),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
),
),
if (subtitle != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
subtitle!,
style: const TextStyle(
fontSize: 12,
height: 1.35,
color: KrowPalette.mute,
),
),
),
],
),
),
if (badge != null) ...[const SizedBox(width: 8), badge!],
const SizedBox(width: 8),
const Icon(
LucideIcons.chevronRight,
size: 17,
color: Color(0xFF949FA5),
),
],
),
),
);
}
}
/// Groups NavTiles into a single card with hairline dividers between rows.
class TileGroup extends StatelessWidget {
const TileGroup({super.key, required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
return KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) const Divider(height: 1),
children[i],
],
],
),
);
}
}
/// Dotted-leader row: label on the left, value on the right.
class DetailRow extends StatelessWidget {
const DetailRow(
this.label,
this.value, {
super.key,
this.emphasis = false,
this.valueColor,
});
final String label;
final String value;
final bool emphasis;
final Color? valueColor;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 11),
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 13.5,
fontWeight: emphasis ? FontWeight.w600 : FontWeight.w400,
),
),
),
const SizedBox(width: 12),
Text(
value,
style: TextStyle(
fontSize: emphasis ? 16 : 13.5,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: valueColor ?? KrowPalette.ink,
),
),
],
),
);
}
}
/// Three-up figure block: caption over a big number.
///
/// Both lines are pinned to a fixed height and scale down instead of wrapping.
/// A wrapping caption or figure — "CLOCK OUT", "11:50 PM" — would make one
/// card taller than the two beside it and leave the row ragged.
class StatSlab extends StatelessWidget {
const StatSlab(this.caption, this.value, {super.key, this.valueColor});
final String caption;
final String value;
final Color? valueColor;
static const _captionHeight = 13.0;
static const _valueHeight = 25.0;
@override
Widget build(BuildContext context) {
return KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: _captionHeight,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
caption.toUpperCase(),
maxLines: 1,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
letterSpacing: 1.2,
color: Color(0xFF949FA5),
),
),
),
),
const SizedBox(height: 6),
SizedBox(
height: _valueHeight,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
value,
maxLines: 1,
style: TextStyle(
fontSize: 17.5,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
color: valueColor ?? KrowPalette.ink,
),
),
),
),
],
),
);
}
}
/// Lays out slabs as an evenly spaced row of equal-height cards.
class StatSlabRow extends StatelessWidget {
const StatSlabRow({super.key, required this.slabs});
final List<StatSlab> slabs;
@override
Widget build(BuildContext context) {
// These rows live inside scrollables, where the height is unbounded, so
// `stretch` alone would try to fill infinity. IntrinsicHeight gives the
// row the height of its tallest card first, and stretch then matches the
// others to it.
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < slabs.length; i++) ...[
if (i > 0) const SizedBox(width: 9),
Expanded(child: slabs[i]),
],
],
),
);
}
}
/// Horizontal rail of small stats, divided by hairlines.
class StatRail extends StatelessWidget {
const StatRail({super.key, required this.stats});
final List<(String, String)> stats;
@override
Widget build(BuildContext context) {
return KrowCard(
padding: EdgeInsets.zero,
child: IntrinsicHeight(
child: Row(
children: [
for (var i = 0; i < stats.length; i++) ...[
if (i > 0) const VerticalDivider(width: 1),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Text(
stats[i].$2,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
),
),
const SizedBox(height: 4),
Text(
stats[i].$1.toUpperCase(),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 8.5,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
color: Color(0xFF949FA5),
),
),
],
),
),
),
],
],
),
),
);
}
}
/// Progress that grows into place rather than snapping, so a change — an
/// uploaded document, a day worked — is something you see happen.
class KrowProgressBar extends StatelessWidget {
const KrowProgressBar(this.value, {super.key, this.color});
final double value;
final Color? color;
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
tween: Tween(end: value.clamp(0, 1)),
duration: const Duration(milliseconds: 600),
curve: Curves.easeOutCubic,
builder: (context, animated, _) => ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: animated,
minHeight: 6,
backgroundColor: KrowPalette.line,
valueColor: AlwaysStoppedAnimation(color ?? KrowPalette.blue),
),
),
);
}
}
class EmptyState extends StatelessWidget {
const EmptyState({
super.key,
required this.icon,
required this.title,
required this.message,
this.action,
});
final IconData icon;
final String title;
final String message;
final Widget? action;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 34),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: KrowPalette.line, style: BorderStyle.solid),
),
child: Column(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: KrowPalette.line),
),
child: Icon(icon, size: 24, color: const Color(0xFF949FA5)),
),
const SizedBox(height: 14),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 5),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
height: 1.5,
color: KrowPalette.mute,
),
),
if (action != null) ...[const SizedBox(height: 16), action!],
],
),
);
}
}
/// Business identity tile used on every shift card.
class BusinessBadge extends StatelessWidget {
const BusinessBadge(this.letter, {super.key, this.size = 46});
final String letter;
final double size;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: KrowPalette.blue,
borderRadius: BorderRadius.circular(size / 3.2),
),
child: Text(
letter,
style: TextStyle(
fontSize: size * 0.42,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
);
}
}
/// Small icon + text pair used for shift metadata.
class MetaPill extends StatelessWidget {
const MetaPill(this.icon, this.text, {super.key});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0x0F26353E),
borderRadius: BorderRadius.circular(9),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: const Color(0xFF949FA5)),
const SizedBox(width: 6),
Flexible(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
/// The blue ground every header sits on: brand colour, rounded bottom, and two
/// soft glows. The glows are quiet on their own, but they give the frosted
/// controls sitting on top something to refract.
class BrandSurface extends StatelessWidget {
const BrandSurface({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
child: Container(
width: double.infinity,
color: KrowPalette.blue,
child: Stack(
children: [
Positioned(
top: -70,
right: -40,
child: _Glow(220, Colors.white.withValues(alpha: 0.16)),
),
Positioned(
bottom: -90,
left: -50,
child: _Glow(240, KrowPalette.yellow.withValues(alpha: 0.14)),
),
child,
],
),
),
);
}
}
/// The blue header block that tops a pushed detail screen. Main tabs use
/// KrowSliverHeader instead, so their header stays put while content scrolls.
class BrandHeader extends StatelessWidget {
const BrandHeader({
super.key,
required this.child,
this.padding = const EdgeInsets.fromLTRB(20, 8, 20, 24),
});
final Widget child;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
return BrandSurface(
child: SafeArea(
bottom: false,
child: Padding(padding: padding, child: child),
),
);
}
}
/// A soft circular bloom. Drawn as a radial gradient rather than a blurred
/// shape, which costs nothing per frame.
class _Glow extends StatelessWidget {
const _Glow(this.size, this.color);
final double size;
final Color color;
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(colors: [color, color.withValues(alpha: 0)]),
),
),
);
}
}
/// Round icon button used in headers, on light or dark ground.
class HeaderIconButton extends StatelessWidget {
const HeaderIconButton({
super.key,
required this.icon,
required this.onPressed,
this.onDark = true,
this.tooltip,
});
final IconData icon;
final VoidCallback onPressed;
final bool onDark;
final String? tooltip;
@override
Widget build(BuildContext context) {
return Material(
color: onDark ? Colors.white24 : Colors.white,
borderRadius: BorderRadius.circular(11),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(11),
child: Tooltip(
message: tooltip ?? '',
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(11),
border: onDark ? null : Border.all(color: KrowPalette.line),
),
child: Icon(
icon,
size: 19,
color: onDark ? Colors.white : KrowPalette.ink,
),
),
),
),
);
}
}
/// Simple bar chart used on the earnings screens.
class MiniBarChart extends StatelessWidget {
const MiniBarChart({super.key, required this.bars});
/// `(label, value, isPaid)` — paid bars read blue, pending read yellow.
final List<(String, double, bool)> bars;
@override
Widget build(BuildContext context) {
final max = bars.fold<double>(0, (m, b) => b.$2 > m ? b.$2 : m);
return Column(
children: [
SizedBox(
height: 118,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final bar in bars)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
// Bars grow to their new height when the range changes,
// so you can see which column moved.
child: TweenAnimationBuilder<double>(
tween: Tween(
end: max == 0
? 0.0
: (bar.$2 / max).clamp(0.0, 1.0).toDouble(),
),
duration: const Duration(milliseconds: 520),
curve: Curves.easeOutCubic,
builder: (context, height, _) => FractionallySizedBox(
heightFactor: height,
child: TweenAnimationBuilder<Color?>(
tween: ColorTween(
end: bar.$3 ? KrowPalette.blue : KrowPalette.yellow,
),
duration: const Duration(milliseconds: 320),
builder: (context, color, _) => Container(
decoration: BoxDecoration(
color: color,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(6),
bottom: Radius.circular(3),
),
),
),
),
),
),
),
),
],
),
),
const SizedBox(height: 9),
Row(
children: [
for (final bar in bars)
Expanded(
child: Text(
bar.$1,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.w500,
color: Color(0xFF949FA5),
),
),
),
],
),
],
);
}
}
/// Legend dot + label, paired with the chart.
class LegendDot extends StatelessWidget {
const LegendDot(this.color, this.label, {super.key});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(fontSize: 12, color: KrowPalette.mute),
),
],
);
}
}

View File

@@ -0,0 +1,191 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../core/theme.dart';
/// A frosted-glass segmented control for use on the brand header.
///
/// The track is translucent and blurs whatever sits behind it, with a bright
/// top edge and a darker bottom edge so it reads as a pane of glass rather
/// than a flat tint. The selected segment is a brighter frosted pill that
/// slides between positions.
class GlassSegments<T> extends StatelessWidget {
const GlassSegments({
super.key,
required this.segments,
required this.value,
required this.onChanged,
this.height = 44,
});
/// Each segment as `(value, label)`.
final List<(T, String)> segments;
final T value;
final ValueChanged<T> onChanged;
final double height;
static const _radius = 15.0;
static const _pad = 4.0;
@override
Widget build(BuildContext context) {
final index = segments.indexWhere((s) => s.$1 == value);
return SizedBox(
height: height,
child: ClipRRect(
borderRadius: BorderRadius.circular(_radius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_radius),
// Light falling across the pane: brighter at the top edge.
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white.withValues(alpha: 0.26),
Colors.white.withValues(alpha: 0.12),
],
),
border: Border.all(
color: Colors.white.withValues(alpha: 0.28),
width: 1,
),
),
padding: const EdgeInsets.all(_pad),
child: LayoutBuilder(
builder: (context, constraints) {
final segmentWidth = constraints.maxWidth / segments.length;
// One animation drives everything: the pill's position, its
// stretch, and each label's colour. Animating the selected
// index as a double — rather than moving the pill and flipping
// the labels separately — is what makes the labels light up
// exactly as the pill arrives over them. Retargeting mid-flight
// continues from wherever the pill currently is.
return TweenAnimationBuilder<double>(
tween: Tween(end: index.toDouble()),
duration: const Duration(milliseconds: 340),
curve: Curves.easeOutQuint,
builder: (context, position, _) {
// Squash and stretch: the pill widens at speed and settles
// back to size as it lands.
final travel = (position - position.roundToDouble()).abs();
final stretch = segmentWidth * 0.12 * (travel * 2);
return Stack(
children: [
if (index >= 0)
Positioned(
left: position * segmentWidth - stretch / 2,
width: segmentWidth + stretch,
top: 0,
bottom: 0,
child: const _SelectedPill(),
),
Row(
children: [
for (var i = 0; i < segments.length; i++)
Expanded(
child: _Segment(
label: segments[i].$2,
// 1 when the pill is centred on this label,
// 0 once it has moved a full segment away.
selectedAmount: (1 - (position - i).abs())
.clamp(0.0, 1.0),
onTap: () {
if (segments[i].$1 == value) return;
HapticFeedback.selectionClick();
onChanged(segments[i].$1);
},
),
),
],
),
],
);
},
);
},
),
),
),
),
);
}
}
class _SelectedPill extends StatelessWidget {
const _SelectedPill();
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(11),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white.withValues(alpha: 0.96),
Colors.white.withValues(alpha: 0.86),
],
),
border: Border.all(color: Colors.white.withValues(alpha: 0.9)),
boxShadow: [
BoxShadow(
color: KrowPalette.blueDeep.withValues(alpha: 0.28),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
);
}
}
class _Segment extends StatelessWidget {
const _Segment({
required this.label,
required this.selectedAmount,
required this.onTap,
});
final String label;
/// How far the travelling pill has arrived over this label, 0 to 1.
final double selectedAmount;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Center(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
// Weight is snapped to the nearest real weight; the eye reads the
// colour ramp as the smooth part.
fontWeight: selectedAmount > 0.5
? FontWeight.w700
: FontWeight.w600,
color: Color.lerp(
Colors.white.withValues(alpha: 0.82),
KrowPalette.blue,
Curves.easeIn.transform(selectedAmount),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../core/theme.dart';
class KrowNavItem {
const KrowNavItem({
required this.icon,
required this.label,
this.showDot = false,
});
final IconData icon;
final String label;
/// Marks the tab as needing attention, without saying how much.
final bool showDot;
}
/// Bottom navigation whose indicator slides between tabs.
///
/// Material's own [NavigationBar] fades its indicator in at the destination
/// and out at the origin, so the selection appears to teleport. Here the pill
/// travels, and the icons hand it over as it passes.
class KrowNavBar extends StatelessWidget {
const KrowNavBar({
super.key,
required this.items,
required this.currentIndex,
required this.onSelect,
});
final List<KrowNavItem> items;
final int currentIndex;
final ValueChanged<int> onSelect;
static const _pillWidth = 58.0;
static const _pillHeight = 32.0;
/// Breathing room between the bar's top edge and the icons. The pill and the
/// icons both start here, which is also what keeps them aligned — centring
/// the icons while pinning the pill to a fixed offset left the two a couple
/// of pixels apart.
static const _topPadding = 12.0;
/// Lets tests find the travelling indicator unambiguously.
static const indicatorKey = ValueKey('krow-nav-indicator');
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: KrowPalette.line)),
),
child: SafeArea(
top: false,
child: SizedBox(
height: 76,
child: LayoutBuilder(
builder: (context, constraints) {
final slot = constraints.maxWidth / items.length;
// The selected index animated as a double, so the pill's
// position and every icon's colour come from one value.
return TweenAnimationBuilder<double>(
tween: Tween(end: currentIndex.toDouble()),
duration: const Duration(milliseconds: 360),
curve: Curves.easeOutQuint,
builder: (context, position, _) {
final travel = (position - position.roundToDouble()).abs();
final stretch = 20 * (travel * 2);
return Stack(
children: [
Positioned(
left:
position * slot +
(slot - _pillWidth) / 2 -
stretch / 2,
top: _topPadding,
width: _pillWidth + stretch,
height: _pillHeight,
child: DecoratedBox(
key: indicatorKey,
decoration: BoxDecoration(
color: KrowPalette.blue,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: KrowPalette.blue.withValues(alpha: 0.28),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
),
),
Row(
children: [
for (var i = 0; i < items.length; i++)
Expanded(
child: _NavItem(
item: items[i],
// 1 when the pill is centred on this tab.
proximity: (1 - (position - i).abs()).clamp(
0.0,
1.0,
),
onTap: () {
if (i != currentIndex) {
HapticFeedback.selectionClick();
}
onSelect(i);
},
),
),
],
),
],
);
},
);
},
),
),
),
);
}
}
class _NavItem extends StatelessWidget {
const _NavItem({
required this.item,
required this.proximity,
required this.onTap,
});
final KrowNavItem item;
final double proximity;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
// The icon only turns white once the pill is actually under it — a linear
// ramp would leave it half-white over bare white while the pill is still
// in transit. easeInCubic holds it grey, then flips on arrival.
final onPill = Curves.easeInCubic.transform(proximity);
final iconColor = Color.lerp(KrowPalette.mute, Colors.white, onPill)!;
// The label is never covered, so it can ramp gently the whole way.
final labelColor = Color.lerp(
KrowPalette.mute,
KrowPalette.blue,
Curves.easeIn.transform(proximity),
)!;
// Material's NavigationBar announces each tab and its selected state;
// rolling our own means saying so explicitly. container + excludeSemantics
// collapse the icon, dot and label into a single node — otherwise the
// annotation is split across the children and never reaches the reader.
return Semantics(
container: true,
excludeSemantics: true,
button: true,
selected: proximity > 0.5,
label: item.showDot ? '${item.label}, needs attention' : item.label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
// Started from the top rather than centred, so the icon sits exactly
// where the pill is drawn.
mainAxisAlignment: MainAxisAlignment.start,
children: [
const SizedBox(height: KrowNavBar._topPadding),
SizedBox(
height: KrowNavBar._pillHeight,
child: Center(child: Icon(item.icon, size: 21, color: iconColor)),
),
const SizedBox(height: 3),
// The dot's slot is always reserved, so tabs with and without one
// keep their labels on the same line.
SizedBox(
height: 6,
child: Center(
child: AnimatedScale(
scale: item.showDot ? 1 : 0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutBack,
child: Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
shape: BoxShape.circle,
),
),
),
),
),
const SizedBox(height: 2),
Text(
item.label,
style: TextStyle(
fontSize: 11,
fontWeight: proximity > 0.5 ? FontWeight.w600 : FontWeight.w500,
color: labelColor,
),
),
],
),
),
);
}
}

170
lib/widgets/shift_card.dart Normal file
View File

@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/formatters.dart';
import '../core/theme.dart';
import '../models/shift.dart';
import 'common.dart';
/// The one card shape in the app. Identity on top, a hairline split, then the
/// money band — with a status bar welded to the bottom edge.
class ShiftCard extends StatelessWidget {
const ShiftCard({
super.key,
required this.shift,
this.onTap,
this.trailingAction,
this.showDate = false,
});
final Shift shift;
final VoidCallback? onTap;
/// Optional action shown in the money band, e.g. an Apply button.
final Widget? trailingAction;
final bool showDate;
(String, ChipTone) get _status => switch (shift.status) {
ShiftStatus.open => ('Open', ChipTone.blue),
ShiftStatus.booked => (Fmt.untilStart(shift.start), ChipTone.yellow),
ShiftStatus.active => ('On shift', ChipTone.yellow),
ShiftStatus.completed => ('Completed', ChipTone.neutral),
ShiftStatus.cancelled => ('Cancelled', ChipTone.muted),
ShiftStatus.noShow => ('No show', ChipTone.danger),
};
Color get _railColor => switch (shift.status) {
ShiftStatus.active => KrowPalette.yellow,
ShiftStatus.cancelled || ShiftStatus.noShow => const Color(0xFFCFDDD3),
_ => KrowPalette.blue,
};
@override
Widget build(BuildContext context) {
final (statusLabel, statusTone) = _status;
final unpaid =
shift.status == ShiftStatus.cancelled ||
shift.status == ShiftStatus.noShow;
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(color: KrowPalette.line),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BusinessBadge(shift.logoLetter),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
shift.role,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.15,
),
),
const SizedBox(height: 3),
Text(
shift.venue,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12.5,
color: KrowPalette.mute,
),
),
],
),
),
const SizedBox(width: 8),
StatusChip(statusLabel, tone: statusTone),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 7,
runSpacing: 7,
children: [
if (showDate)
MetaPill(
LucideIcons.calendar,
Fmt.dayShort(shift.start),
),
MetaPill(
LucideIcons.clock,
Fmt.window(shift.start, shift.end),
),
MetaPill(
LucideIcons.mapPin,
'${shift.distanceMiles} mi',
),
],
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
Expanded(
child: Text(
unpaid
? 'No pay generated'
: '${Fmt.money(shift.rate)}/hr · '
'${Fmt.duration(shift.scheduledDuration)}',
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
color: Color(0xFF949FA5),
),
),
),
if (trailingAction != null)
trailingAction!
else
Text(
Fmt.money(shift.pay),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.7,
color: unpaid
? const Color(0xFF949FA5)
: KrowPalette.ink,
),
),
],
),
),
Container(height: 4, color: _railColor),
],
),
),
),
);
}
}

143
lib/widgets/week_strip.dart Normal file
View File

@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../core/theme.dart';
/// Seven-day selector. A dot under a day marks something booked or set.
class WeekStrip extends StatelessWidget {
const WeekStrip({
super.key,
required this.days,
required this.selected,
required this.onSelect,
this.marked = const {},
});
final List<DateTime> days;
final DateTime selected;
final ValueChanged<DateTime> onSelect;
/// Day indices (0..6) that carry a marker dot.
final Set<int> marked;
@override
Widget build(BuildContext context) {
final dow = DateFormat('EEE');
return Row(
children: [
for (var i = 0; i < days.length; i++) ...[
if (i > 0) const SizedBox(width: 7),
Expanded(
child: _DayCell(
day: days[i],
label: dow.format(days[i]),
selected:
days[i].day == selected.day &&
days[i].month == selected.month,
marked: marked.contains(i),
onTap: () => onSelect(days[i]),
),
),
],
],
);
}
}
class _DayCell extends StatelessWidget {
const _DayCell({
required this.day,
required this.label,
required this.selected,
required this.marked,
required this.onTap,
});
final DateTime day;
final String label;
final bool selected;
final bool marked;
final VoidCallback onTap;
static const _duration = Duration(milliseconds: 260);
static const _curve = Curves.easeOutCubic;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
// Colour is animated rather than swapped so moving along the week reads
// as one continuous selection rather than a series of flashes.
child: AnimatedContainer(
duration: _duration,
curve: _curve,
height: 64,
decoration: BoxDecoration(
color: selected ? KrowPalette.blue : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected ? KrowPalette.blue : KrowPalette.line,
),
boxShadow: selected
? [
BoxShadow(
color: KrowPalette.blue.withValues(alpha: 0.26),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: null,
),
child: Stack(
alignment: Alignment.center,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedDefaultTextStyle(
duration: _duration,
curve: _curve,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
color: selected ? Colors.white70 : const Color(0xFF949FA5),
),
child: Text(label.toUpperCase()),
),
const SizedBox(height: 2),
AnimatedDefaultTextStyle(
duration: _duration,
curve: _curve,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: selected ? Colors.white : KrowPalette.ink,
),
child: Text('${day.day}'),
),
],
),
Positioned(
bottom: 8,
child: AnimatedScale(
scale: marked ? 1 : 0,
duration: _duration,
curve: Curves.easeOutBack,
child: AnimatedContainer(
duration: _duration,
width: 5,
height: 5,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? KrowPalette.yellow : KrowPalette.blue,
),
),
),
),
],
),
),
);
}
}