Initial commit: Krow Worker App
This commit is contained in:
51
lib/core/formatters.dart
Normal file
51
lib/core/formatters.dart
Normal 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
137
lib/core/router.dart
Normal 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
144
lib/core/theme.dart
Normal 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
84
lib/core/transitions.dart
Normal 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
73
lib/main.dart
Normal 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;
|
||||
}
|
||||
21
lib/models/availability.dart
Normal file
21
lib/models/availability.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
82
lib/models/compliance.dart
Normal file
82
lib/models/compliance.dart
Normal 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
95
lib/models/shift.dart
Normal 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
70
lib/models/timecard.dart
Normal 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
57
lib/models/worker.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
650
lib/screens/auth_screens.dart
Normal file
650
lib/screens/auth_screens.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
337
lib/screens/availability_screen.dart
Normal file
337
lib/screens/availability_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
397
lib/screens/clock_screen.dart
Normal file
397
lib/screens/clock_screen.dart
Normal file
@@ -0,0 +1,397 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../core/formatters.dart';
|
||||
import '../core/theme.dart';
|
||||
import '../models/shift.dart';
|
||||
import '../state/session_controller.dart';
|
||||
import '../state/shifts_controller.dart';
|
||||
import '../widgets/collapsing_header.dart';
|
||||
import '../widgets/common.dart';
|
||||
import '../widgets/shift_card.dart';
|
||||
|
||||
/// Clock in and out. The button unlocks 30 minutes before the shift starts,
|
||||
/// and only once the worker is at the venue.
|
||||
class ClockScreen extends ConsumerStatefulWidget {
|
||||
const ClockScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ClockScreen> createState() => _ClockScreenState();
|
||||
}
|
||||
|
||||
class _ClockScreenState extends ConsumerState<ClockScreen> {
|
||||
Timer? _ticker;
|
||||
|
||||
/// Stands in for the geofence check while there is no GPS plugin wired up.
|
||||
bool _atVenue = false;
|
||||
|
||||
/// Last minute we repainted for. Off shift, only the countdown moves, so
|
||||
/// rebuilding every second would just churn the tree — and a rebuild landing
|
||||
/// between a tap's down and up swallows it.
|
||||
int _lastMinute = -1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Drives the countdown and the running stopwatch.
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
final running = ref.read(activeShiftProvider) != null;
|
||||
final minute = DateTime.now().minute;
|
||||
if (running || minute != _lastMinute) {
|
||||
_lastMinute = minute;
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = ref.watch(activeShiftProvider);
|
||||
final next = ref.watch(nextShiftProvider);
|
||||
final shift = active ?? next;
|
||||
final locationGranted = ref.watch(sessionProvider).locationGranted;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Already compact, so it pins at one height rather than collapsing.
|
||||
// Sized with headroom over the 58px of content, so a larger system
|
||||
// text scale does not push it over.
|
||||
KrowSliverHeader(
|
||||
expandedHeight: 96,
|
||||
collapsedHeight: 96,
|
||||
builder: (context, t) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
Fmt.dayFull(DateTime.now()).toUpperCase(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Clock in',
|
||||
style: TextStyle(
|
||||
fontSize: 27,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.9,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
HeaderIconButton(
|
||||
icon: LucideIcons.fileText,
|
||||
tooltip: 'Timecards',
|
||||
onPressed: () => context.push('/timecards'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (shift == null)
|
||||
const SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(20, 40, 20, 20),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: EmptyState(
|
||||
icon: LucideIcons.clock,
|
||||
title: 'No shift to clock into',
|
||||
message:
|
||||
'Book a shift and the clock unlocks 30 minutes before '
|
||||
'it starts.',
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 24),
|
||||
sliver: SliverList.list(
|
||||
children: [
|
||||
_ClockDial(shift: shift, atVenue: _atVenue),
|
||||
const SizedBox(height: 22),
|
||||
if (shift.status == ShiftStatus.active)
|
||||
FilledButton.icon(
|
||||
onPressed: () => _clockOut(shift),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: KrowPalette.ink,
|
||||
),
|
||||
icon: const Icon(LucideIcons.logOut, size: 18),
|
||||
label: const Text('Clock out'),
|
||||
)
|
||||
else
|
||||
FilledButton.icon(
|
||||
onPressed: _canClockIn(shift, locationGranted)
|
||||
? () => _clockIn(shift)
|
||||
: null,
|
||||
icon: const Icon(LucideIcons.logIn, size: 18),
|
||||
label: Text(
|
||||
_canClockIn(shift, locationGranted)
|
||||
? 'Clock in'
|
||||
: 'Clock in · ${Fmt.untilStart(shift.start)}',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_GeofenceCard(
|
||||
shift: shift,
|
||||
atVenue: _atVenue,
|
||||
locationGranted: locationGranted,
|
||||
onChanged: (v) => setState(() => _atVenue = v),
|
||||
),
|
||||
const SectionLabel("Today's shift"),
|
||||
ShiftCard(
|
||||
shift: shift,
|
||||
onTap: () => context.push('/shift/${shift.id}'),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.push('/timecards'),
|
||||
icon: const Icon(LucideIcons.fileText, size: 18),
|
||||
label: const Text('View timecards'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _canClockIn(Shift shift, bool locationGranted) {
|
||||
if (shift.status != ShiftStatus.booked) return false;
|
||||
if (!_atVenue) return false;
|
||||
final minutesToStart = shift.start.difference(DateTime.now()).inMinutes;
|
||||
return minutesToStart <= 30;
|
||||
}
|
||||
|
||||
void _clockIn(Shift shift) {
|
||||
// Starting and ending a shift are the two moments that matter most in the
|
||||
// app; both get a firm confirmation you can feel without looking.
|
||||
HapticFeedback.mediumImpact();
|
||||
ref.read(shiftsProvider.notifier).clockIn(shift.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Clocked in at ${Fmt.time(DateTime.now())}')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _clockOut(Shift shift) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clock out?'),
|
||||
content: Text(
|
||||
'Your timecard is filed automatically and sent to '
|
||||
'${shift.business} for approval.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Not yet'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
|
||||
child: const Text('Clock out'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm != true || !mounted) return;
|
||||
|
||||
HapticFeedback.mediumImpact();
|
||||
final card = ref.read(shiftsProvider.notifier).clockOut(shift.id);
|
||||
if (card == null || !mounted) return;
|
||||
setState(() => _atVenue = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Timecard filed · ${Fmt.money(card.amount)}'),
|
||||
action: SnackBarAction(
|
||||
label: 'Review',
|
||||
textColor: KrowPalette.yellow,
|
||||
onPressed: () => context.push('/timecard/${card.id}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The big circular status dial: a countdown before the shift, a running
|
||||
/// stopwatch during it.
|
||||
class _ClockDial extends StatelessWidget {
|
||||
const _ClockDial({required this.shift, required this.atVenue});
|
||||
|
||||
final Shift shift;
|
||||
final bool atVenue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final running = shift.status == ShiftStatus.active;
|
||||
final elapsed = running
|
||||
? DateTime.now().difference(shift.clockIn!)
|
||||
: Duration.zero;
|
||||
final progress = running
|
||||
? (elapsed.inSeconds / shift.end.difference(shift.start).inSeconds)
|
||||
.clamp(0.0, 1.0)
|
||||
: 0.0;
|
||||
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 208,
|
||||
height: 208,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// The ring fills from zero when the shift starts, and the track
|
||||
// warms from sage to yellow as it does.
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: running ? progress : 0),
|
||||
duration: const Duration(milliseconds: 700),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, value, _) => SizedBox(
|
||||
width: 208,
|
||||
height: 208,
|
||||
child: CircularProgressIndicator(
|
||||
value: running ? value : 1,
|
||||
strokeWidth: 12,
|
||||
strokeCap: StrokeCap.round,
|
||||
backgroundColor: const Color(0xFFDEEBE2),
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
running ? KrowPalette.yellow : const Color(0xFFDEEBE2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedScale(
|
||||
// A small swell on clock-in: the dial acknowledges the tap.
|
||||
scale: running ? 1 : 0.96,
|
||||
duration: const Duration(milliseconds: 420),
|
||||
curve: Curves.easeOutBack,
|
||||
child: Container(
|
||||
width: 176,
|
||||
height: 176,
|
||||
decoration: const BoxDecoration(
|
||||
color: KrowPalette.blue,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
// Only the label swaps; the ticking digits must not
|
||||
// re-animate every second.
|
||||
child: Text(
|
||||
running ? Fmt.stopwatch(elapsed) : 'Clock in',
|
||||
key: ValueKey(running),
|
||||
style: TextStyle(
|
||||
fontSize: running ? 27 : 23,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.6,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
running
|
||||
? 'ON SHIFT SINCE ${Fmt.time(shift.clockIn!)}'
|
||||
: Fmt.untilStart(shift.start).toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
letterSpacing: 1.3,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: KrowPalette.yellow,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows why the clock is locked, and lets the demo simulate arriving on site.
|
||||
class _GeofenceCard extends StatelessWidget {
|
||||
const _GeofenceCard({
|
||||
required this.shift,
|
||||
required this.atVenue,
|
||||
required this.locationGranted,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final Shift shift;
|
||||
final bool atVenue;
|
||||
final bool locationGranted;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (shift.status == ShiftStatus.active) {
|
||||
return InfoBanner(
|
||||
'Break of ${shift.breakMinutes} minutes is deducted automatically when '
|
||||
'you clock out.',
|
||||
);
|
||||
}
|
||||
return KrowCard(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.mapPin, size: 18, color: KrowPalette.blue),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
atVenue
|
||||
? 'At the venue'
|
||||
: 'You are ${shift.distanceMiles} mi away',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
locationGranted
|
||||
? 'Get within 150 m to unlock the button'
|
||||
: 'Location is off - turn it on in Settings',
|
||||
style: const TextStyle(fontSize: 12, color: KrowPalette.mute),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(value: atVenue, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
565
lib/screens/compliance_screens.dart
Normal file
565
lib/screens/compliance_screens.dart
Normal 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
107
lib/screens/home_shell.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
448
lib/screens/onboarding_screens.dart
Normal file
448
lib/screens/onboarding_screens.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
620
lib/screens/pay_screens.dart
Normal file
620
lib/screens/pay_screens.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
736
lib/screens/profile_detail_screens.dart
Normal file
736
lib/screens/profile_detail_screens.dart
Normal 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.',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
362
lib/screens/profile_screen.dart
Normal file
362
lib/screens/profile_screen.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
589
lib/screens/settings_screens.dart
Normal file
589
lib/screens/settings_screens.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
379
lib/screens/shift_detail_screen.dart
Normal file
379
lib/screens/shift_detail_screen.dart
Normal 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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
396
lib/screens/shifts_screen.dart
Normal file
396
lib/screens/shifts_screen.dart
Normal 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}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
388
lib/screens/timecard_screens.dart
Normal file
388
lib/screens/timecard_screens.dart
Normal 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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
421
lib/screens/today_screen.dart
Normal file
421
lib/screens/today_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
128
lib/state/availability_controller.dart
Normal file
128
lib/state/availability_controller.dart
Normal 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;
|
||||
});
|
||||
178
lib/state/compliance_controller.dart
Normal file
178
lib/state/compliance_controller.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
168
lib/state/profile_controller.dart
Normal file
168
lib/state/profile_controller.dart
Normal 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
392
lib/state/seed.dart
Normal 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.',
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
115
lib/state/session_controller.dart
Normal file
115
lib/state/session_controller.dart
Normal 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,
|
||||
);
|
||||
172
lib/state/shifts_controller.dart
Normal file
172
lib/state/shifts_controller.dart
Normal 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();
|
||||
});
|
||||
49
lib/state/timecards_controller.dart
Normal file
49
lib/state/timecards_controller.dart
Normal 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();
|
||||
});
|
||||
137
lib/widgets/collapsing_header.dart
Normal file
137
lib/widgets/collapsing_header.dart
Normal 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
835
lib/widgets/common.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
191
lib/widgets/glass_segments.dart
Normal file
191
lib/widgets/glass_segments.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
215
lib/widgets/krow_nav_bar.dart
Normal file
215
lib/widgets/krow_nav_bar.dart
Normal 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
170
lib/widgets/shift_card.dart
Normal 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
143
lib/widgets/week_strip.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user