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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user