diff --git a/README.md b/README.md index d6d5ae2..3e8e7cc 100644 --- a/README.md +++ b/README.md @@ -145,15 +145,47 @@ segmented control: that the indicator actually travels rather than jumping, that taps report the right index, and that each tab reaches the semantics tree with its selected state (the dot alone is not readable). +## Device permissions + +Location, notifications and camera are real requests through +`permission_handler`, wrapped in `lib/core/permissions.dart` so nothing else +imports the plugin. Two rules that layer enforces: + +- **The OS is the source of truth.** The flags on `SessionState` are a cache for + the UI. A permission can be revoked in system settings while the app is + closed, so any screen that shows their state calls `refresh()` to re-read. +- **Platforms the plugin does not implement degrade to granted.** On web and + desktop it returns `true` rather than throwing — the browser prompts at the + point of use, so asking up front is neither possible nor useful. + +There is no API to revoke a permission, and re-requesting a granted one is a +no-op, so switching one off in Settings opens the system settings page instead. +Same when a permission is permanently denied and the OS will no longer show its +dialog. + +Native declarations live in `AndroidManifest.xml` and `ios/Runner/Info.plist`; +iOS rejects builds that request a permission without a usage string. + +> `permission_handler_android` compiles against Android API 37, and its AAR +> metadata requires dependents to match, so `android/app/build.gradle.kts` pins +> `compileSdk = 37` rather than following `flutter.compileSdkVersion`. Building +> needs the `platforms;android-37` SDK package installed. + ## Wiring it to a backend The seams are deliberate. `lib/state/seed.dart` is the only source of demo data; swap each controller's `build()` for an API call and the screens are unchanged. -Two places currently stand in for device APIs: + +What still stands in for something real: - **Geofence** — `ClockScreen` has an "at the venue" switch in place of GPS. - Replace with `geolocator` and a 150 m radius check against the venue. -- **File upload** — the compliance sheet fakes a filename. Replace with - `image_picker` / `file_picker` and a real upload. - -Notifications and SMS verification are also stubbed: the OTP is always `123456`. + Location permission is real, but nothing measures distance yet. Add + `geolocator` and a 150 m radius check against the venue. +- **PDF upload** — compliance items take a photo or a gallery image through + `image_picker`. Picking a document needs `file_picker`, so that option is not + offered rather than offered and faked. +- **Upload** — a picked file updates local state only; nothing is sent + anywhere yet. +- **Notifications** — permission is requested, but nothing is scheduled or + received. Needs a messaging service and a local-notifications plugin. +- **SMS verification** — the OTP is always `123456`. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 01af1bb..1270e0a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,10 @@ plugins { android { namespace = "in.nearle.krow.krow_worker_app" - compileSdk = flutter.compileSdkVersion + // Pinned above flutter.compileSdkVersion because permission_handler_android + // compiles against 37 and AAR metadata requires anything depending on it to + // do the same. Drop this back once Flutter's default reaches 37. + compileSdk = 37 ndkVersion = flutter.ndkVersion compileOptions { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 924d2ba..2572890 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,24 @@ + + + + + + + + + + + + + + + - + - - - - + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 304732f..7e20987 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,12 +1,7 @@ - + - - - - + diff --git a/android/app/src/main/res/drawable/splash_icon_none.xml b/android/app/src/main/res/drawable/splash_icon_none.xml new file mode 100644 index 0000000..612d41b --- /dev/null +++ b/android/app/src/main/res/drawable/splash_icon_none.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2abc601 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..b2c949a 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9dc757c Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..956aefe 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5d118e7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..470c231 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f97464a Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..d09605f 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..c38ebcb Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..0ce2e6e 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..812cb0f Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..47e8d8c --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..42feb96 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..56929d6 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + + + #3752A8 + + + #FFFFFF + diff --git a/assets/art/onboarding_find_work.svg b/assets/art/onboarding_find_work.svg new file mode 100644 index 0000000..8f9a065 --- /dev/null +++ b/assets/art/onboarding_find_work.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/art/onboarding_get_paid.svg b/assets/art/onboarding_get_paid.svg new file mode 100644 index 0000000..5814cbb --- /dev/null +++ b/assets/art/onboarding_get_paid.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/art/onboarding_know_your_pay.svg b/assets/art/onboarding_know_your_pay.svg new file mode 100644 index 0000000..1c9e971 --- /dev/null +++ b/assets/art/onboarding_know_your_pay.svg @@ -0,0 +1,30 @@ + + + + + + + $ + + + + + + + + + + + + + + + + + + + $121.80 + + diff --git a/assets/images/krow_logo.png b/assets/images/krow_logo.png new file mode 100644 index 0000000..9d2ea85 Binary files /dev/null and b/assets/images/krow_logo.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..b96ebbb 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..2c49eaa 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..ca21b6e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..a532e76 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..a16b973 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..64f6f76 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..d34d393 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..ca21b6e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..7c675f5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..bf02ee7 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..bf02ee7 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..28dde0c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..75cafa6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..95059f5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..f3e47a1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard index f2e259c..ddbd45c 100644 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -13,17 +13,12 @@ + - - - - - - - - - + @@ -31,7 +26,4 @@ - - - diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 937f7d6..2d5f600 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -47,6 +47,14 @@ + + NSLocationWhenInUseUsageDescription + KROW unlocks clock-in once you are at the venue, and sorts shifts by how far away they are. Location is only read while a shift is active. + NSCameraUsageDescription + KROW uses the camera to photograph your uniform items, ID and certificates. Nothing is uploaded until you tap send. + NSPhotoLibraryUsageDescription + KROW lets you pick an existing photo of your uniform items, ID or certificates instead of taking a new one. UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName diff --git a/lib/core/permissions.dart b/lib/core/permissions.dart new file mode 100644 index 0000000..b214ec7 --- /dev/null +++ b/lib/core/permissions.dart @@ -0,0 +1,62 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:permission_handler/permission_handler.dart'; + +/// The three things the app asks for, in the order it asks. +enum AppPermission { location, notifications, camera } + +extension AppPermissionX on AppPermission { + Permission get _platform => switch (this) { + // While-in-use only: clock-in needs to know you are at the venue, and + // nothing here justifies background tracking. + AppPermission.location => Permission.locationWhenInUse, + AppPermission.notifications => Permission.notification, + AppPermission.camera => Permission.camera, + }; +} + +/// Thin wrapper over `permission_handler`. +/// +/// Exists so the rest of the app never imports the plugin directly, and so the +/// platforms it has no implementation for — web, desktop — degrade to "granted" +/// instead of throwing. On the web the browser prompts at the point of use +/// anyway, so asking up front is neither possible nor needed. +class PermissionService { + const PermissionService(); + + bool get _supported => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS); + + /// Shows the platform dialog. Returns whether the permission ended up + /// granted — which is not the same as whether the user tapped Allow, since + /// it may already have been permanently denied. + Future request(AppPermission permission) async { + if (!_supported) return true; + final status = await permission._platform.request(); + return status.isGranted || status.isLimited; + } + + Future isGranted(AppPermission permission) async { + if (!_supported) return true; + final status = await permission._platform.status; + return status.isGranted || status.isLimited; + } + + /// True once the OS will no longer show a dialog, so the only way back is + /// the system settings page. + Future isPermanentlyDenied(AppPermission permission) async { + if (!_supported) return false; + return permission._platform.status.then((s) => s.isPermanentlyDenied); + } + + Future openSettings() async { + if (!_supported) return; + await openAppSettings(); + } +} + +final permissionServiceProvider = Provider( + (ref) => const PermissionService(), +); diff --git a/lib/core/router.dart b/lib/core/router.dart index dbbe6ea..d9e9b9c 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -10,6 +10,7 @@ import '../screens/onboarding_screens.dart'; import '../screens/pay_screens.dart'; import '../screens/profile_detail_screens.dart'; import '../screens/profile_screen.dart'; +import '../screens/requirements_screen.dart'; import '../screens/settings_screens.dart'; import '../screens/shift_detail_screen.dart'; import '../screens/shifts_screen.dart'; @@ -60,27 +61,30 @@ final router = GoRouter( _detailRoute('/phone', (_) => const PhoneScreen()), _detailRoute('/otp', (_) => const OtpScreen()), + // Branch order is the tab order, and it matches the prototype: Today sits + // in the middle, with the two browsing tabs to its left and the two + // personal ones to its right. KrowNavBar's items must stay in step. StatefulShellRoute.indexedStack( builder: (_, _, shell) => HomeShell(navigationShell: shell), branches: [ + StatefulShellBranch( + routes: [ + GoRoute(path: '/shifts', builder: (_, _) => const ShiftsScreen()), + ], + ), + StatefulShellBranch( + routes: [GoRoute(path: '/pay', builder: (_, _) => const PayScreen())], + ), 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()), @@ -92,6 +96,12 @@ final router = GoRouter( _detailRoute( '/shift/:id', (state) => ShiftDetailScreen(shiftId: state.pathParameters['id']!), + routes: [ + _detailRoute( + 'requirements', + (state) => RequirementsScreen(shiftId: state.pathParameters['id']!), + ), + ], ), _detailRoute('/availability', (_) => const AvailabilityScreen()), _detailRoute('/timecards', (_) => const TimecardsScreen()), diff --git a/lib/screens/auth_screens.dart b/lib/screens/auth_screens.dart index c1afa04..431e6ec 100644 --- a/lib/screens/auth_screens.dart +++ b/lib/screens/auth_screens.dart @@ -7,8 +7,11 @@ import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../core/theme.dart'; +import '../core/permissions.dart'; import '../state/session_controller.dart'; import '../widgets/common.dart'; +import 'today_screen.dart'; +import '../widgets/krow_nav_bar.dart'; /// Step 1 of 2: the mobile number a shift manager would text. class PhoneScreen extends ConsumerStatefulWidget { @@ -456,18 +459,92 @@ class _TodoRow extends StatelessWidget { } } +/// One permission ask, as the prototype words it. class _Permission { - const _Permission(this.icon, this.step, this.title, this.body, this.reasons); + const _Permission({ + required this.icon, + required this.step, + required this.title, + required this.body, + required this.reasons, + required this.allow, + required this.defer, + required this.permission, + }); final IconData icon; final String step; final String title; final String body; - final List reasons; + + /// Each reason as `(before, emphasis, after)` — the prototype bolds the part + /// that says what you actually get. + final List<(String, String, String)> reasons; + final String allow; + + /// The way out. Worded differently each time, per the prototype. + final String defer; + + /// What the OS is actually asked for when Allow is tapped. + final AppPermission permission; } -/// The three-step permission run. Each grant is recorded in the session so the -/// app knows what it is allowed to do later. +const _permissions = [ + _Permission( + icon: LucideIcons.mapPin, + step: 'Step 1 of 3', + title: 'Let KROW see where you are', + body: + 'Clock-in is locked until you are within 150 m of the venue. Without ' + 'location you cannot start a shift.', + reasons: [ + ('Unlocks ', 'clock in', ' when you arrive'), + ('Sorts shifts by ', 'distance from you', ''), + ('Only read ', 'while a shift is active', ''), + ], + allow: 'Allow location', + defer: 'Not now', + permission: AppPermission.location, + ), + _Permission( + icon: LucideIcons.bell, + step: 'Step 2 of 3', + title: 'Turn on notifications', + body: + 'Shift offers are first-come. If your phone is silent someone else ' + 'takes the shift.', + reasons: [ + ('', 'New shift offers', ' matched to you'), + ('', 'Clock-in reminders', ' 30 min before'), + ('', 'Pay alerts', ' when money lands'), + ], + allow: 'Turn on notifications', + defer: 'Maybe later', + permission: AppPermission.notifications, + ), + _Permission( + icon: LucideIcons.camera, + step: 'Step 3 of 3', + title: 'Camera and photos', + body: + 'Attire and document checks need a photo of the real item. Nothing is ' + 'uploaded until you tap send.', + reasons: [ + ('Photograph ', 'your uniform items', ''), + ('Capture ', 'ID and certificates', ''), + ('Nothing is stored ', 'on our servers unsent', ''), + ], + allow: 'Allow camera', + defer: 'Choose from library instead', + permission: AppPermission.camera, + ), +]; + +/// The three permission asks, each a sheet over the home screen. +/// +/// Shown this way rather than as full pages because that is what the worker +/// is actually looking at: the app is already there behind the scrim, so the +/// ask reads as a step before using it rather than another sign-up screen. class PermissionsScreen extends ConsumerStatefulWidget { const PermissionsScreen({super.key}); @@ -477,58 +554,9 @@ class PermissionsScreen extends ConsumerStatefulWidget { class _PermissionsScreenState extends ConsumerState { int _step = 0; + bool _asking = false; - 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(); - } - } + void _next() { if (_step == _permissions.length - 1) { context.go('/today'); } else { @@ -536,115 +564,267 @@ class _PermissionsScreenState extends ConsumerState { } } + /// Hands off to the operating system's own dialog. Whatever it answers is + /// recorded, and the run continues either way — a refused permission is a + /// smaller problem than a worker stuck on a sign-up step. + Future _allow(_Permission permission) async { + if (_asking) return; + setState(() => _asking = true); + final granted = await ref + .read(sessionProvider.notifier) + .request(permission.permission); + if (!mounted) return; + setState(() => _asking = false); + + if (!granted) { + final blocked = await ref + .read(permissionServiceProvider) + .isPermanentlyDenied(permission.permission); + // Once the OS stops showing the dialog, tapping Allow again does + // nothing — the only way back is the system settings page. + if (blocked && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Turn this on in system settings'), + action: SnackBarAction( + label: 'Open', + textColor: KrowPalette.yellow, + onPressed: ref.read(permissionServiceProvider).openSettings, + ), + ), + ); + } + } + if (mounted) _next(); + } + @override Widget build(BuildContext context) { - final p = _permissions[_step]; + final permission = _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), + backgroundColor: KrowPalette.sage, + body: Stack( + children: [ + // The app itself, already built, sitting behind the ask. + const IgnorePointer( + child: Scaffold( + body: TodayScreen(), + bottomNavigationBar: KrowNavBar( + items: [ + KrowNavItem(icon: LucideIcons.briefcase, label: 'Shifts'), + KrowNavItem(icon: LucideIcons.wallet, label: 'Pay'), + KrowNavItem(icon: LucideIcons.house, label: 'Today'), + KrowNavItem(icon: LucideIcons.clock, label: 'Clock'), + KrowNavItem(icon: LucideIcons.user, label: 'Profile'), + ], + currentIndex: 2, + onSelect: _ignoreTap, + ), + ), + ), + const ModalBarrier(color: Color(0x8F101A14), dismissible: false), + Align( + alignment: Alignment.bottomCenter, + child: _PermissionSheet( + permission: permission, + busy: _asking, + onAllow: () => _allow(permission), + onDefer: _next, + ), + ), + ], + ), + ); + } + + static void _ignoreTap(int _) {} +} + +class _PermissionSheet extends StatelessWidget { + const _PermissionSheet({ + required this.permission, + required this.busy, + required this.onAllow, + required this.onDefer, + }); + + final _Permission permission; + final bool busy; + final VoidCallback onAllow; + final VoidCallback onDefer; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SafeArea( + top: false, + child: ConstrainedBox( + // Scrolls rather than overflows if the copy outgrows the screen. + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.86, + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(22, 12, 22, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFCFDDD4), + borderRadius: BorderRadius.circular(2), ), - 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: 20), + // Aligned rather than stretched: the surrounding column + // stretches its children so the buttons fill the sheet, and + // that would pull this 60px tile across the whole width. + Align( + alignment: Alignment.centerLeft, + child: Container( + width: 60, + height: 60, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF4A69C6), KrowPalette.blue], + ), + boxShadow: [ + BoxShadow( + color: KrowPalette.blue.withValues(alpha: 0.4), + blurRadius: 24, + offset: const Offset(0, 12), + ), + ], + ), + child: Icon( + permission.icon, + size: 27, + color: Colors.white, + ), + ), + ), + SectionLabel(permission.step), + Text( + permission.title, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.15, + letterSpacing: -0.7, + ), + ), + const SizedBox(height: 10), + Text( + permission.body, + style: const TextStyle( + fontSize: 14, + height: 1.55, + fontWeight: FontWeight.w500, + color: KrowPalette.mute, + ), + ), + const SizedBox(height: 14), + for (final (before, emphasis, after) in permission.reasons) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _Reason( + before: before, + emphasis: emphasis, + after: after, + ), + ), + const SizedBox(height: 10), + FilledButton( + onPressed: busy ? null : onAllow, + child: Text(permission.allow), + ), + TextButton( + onPressed: onDefer, + style: TextButton.styleFrom( + minimumSize: const Size.fromHeight(44), + foregroundColor: KrowPalette.mute, + ), + child: Text(permission.defer), + ), + ], ), - 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'), - ), - ], + ), ), ), ), ); } } + +class _Reason extends StatelessWidget { + const _Reason({ + required this.before, + required this.emphasis, + required this.after, + }); + + final String before; + final String emphasis; + final String after; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 20, + height: 20, + margin: const EdgeInsets.only(top: 1), + decoration: BoxDecoration( + color: KrowPalette.blueTint, + borderRadius: BorderRadius.circular(7), + ), + child: const Icon( + LucideIcons.check, + size: 11, + color: KrowPalette.blue, + ), + ), + const SizedBox(width: 11), + Expanded( + child: Text.rich( + TextSpan( + children: [ + TextSpan(text: before), + TextSpan( + text: emphasis, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: KrowPalette.ink, + ), + ), + TextSpan(text: after), + ], + ), + style: const TextStyle( + fontSize: 13, + height: 1.45, + fontWeight: FontWeight.w500, + color: KrowPalette.mute, + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/clock_screen.dart b/lib/screens/clock_screen.dart index 6b82c8a..2de1285 100644 --- a/lib/screens/clock_screen.dart +++ b/lib/screens/clock_screen.dart @@ -11,9 +11,13 @@ import '../core/theme.dart'; import '../models/shift.dart'; import '../state/session_controller.dart'; import '../state/shifts_controller.dart'; +import '../state/timecards_controller.dart'; import '../widgets/collapsing_header.dart'; import '../widgets/common.dart'; +import '../widgets/glass_segments.dart'; +import '../widgets/krow_wordmark.dart'; import '../widgets/shift_card.dart'; +import 'timecard_screens.dart'; /// Clock in and out. The button unlocks 30 minutes before the shift starts, /// and only once the worker is at the venue. @@ -24,7 +28,11 @@ class ClockScreen extends ConsumerStatefulWidget { ConsumerState createState() => _ClockScreenState(); } +/// The two halves of the Clock tab, as the prototype splits them. +enum _ClockTab { today, timecards } + class _ClockScreenState extends ConsumerState { + _ClockTab _tab = _ClockTab.today; Timer? _ticker; /// Stands in for the geofence check while there is no GPS plugin wired up. @@ -66,56 +74,58 @@ class _ClockScreenState extends ConsumerState { 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. + // Pins at one height: the two segments have to stay reachable, so + // there is nothing worth collapsing away. Sized with headroom for a + // large system text scale, which the eyebrow and title both grow with. KrowSliverHeader( - expandedHeight: 96, - collapsedHeight: 96, + expandedHeight: 170, + collapsedHeight: 170, builder: (context, t) => Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, 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, - ), - ), - ], + HeaderBrandLine( + // The caption reports whichever half you are looking at. + trailing: HeaderEyebrow( + _tab == _ClockTab.today + ? Fmt.dayFull(DateTime.now()) + : '${Fmt.monthYear(DateTime.now())} · ' + '${Fmt.duration(ref.watch(hoursLoggedProvider))} logged', ), ), - HeaderIconButton( - icon: LucideIcons.fileText, - tooltip: 'Timecards', - onPressed: () => context.push('/timecards'), + const SizedBox(height: 8), + const Text( + 'Clock in', + style: TextStyle( + fontSize: 27, + fontWeight: FontWeight.w700, + letterSpacing: -0.9, + color: Colors.white, + ), + ), + const SizedBox(height: 14), + GlassSegments<_ClockTab>( + segments: const [ + (_ClockTab.today, 'Today'), + (_ClockTab.timecards, 'Timecards'), + ], + value: _tab, + onChanged: (v) => setState(() => _tab = v), ), ], ), ), ), - if (shift == null) + if (_tab == _ClockTab.timecards) + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 28), + sliver: SliverList.list( + children: timecardItems(ref.watch(timecardsProvider)), + ), + ) + else if (shift == null) const SliverPadding( padding: EdgeInsets.fromLTRB(20, 40, 20, 20), sliver: SliverToBoxAdapter( @@ -168,12 +178,6 @@ class _ClockScreenState extends ConsumerState { 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'), - ), ], ), ), diff --git a/lib/screens/compliance_screens.dart b/lib/screens/compliance_screens.dart index 075b6a8..ac480ad 100644 --- a/lib/screens/compliance_screens.dart +++ b/lib/screens/compliance_screens.dart @@ -1,12 +1,16 @@ 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:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../core/theme.dart'; +import '../core/permissions.dart'; import '../models/compliance.dart'; import '../state/compliance_controller.dart'; +import '../state/session_controller.dart'; import '../widgets/common.dart'; IconData _sectionIcon(ComplianceSection s) => switch (s) { @@ -299,8 +303,12 @@ class ComplianceDetailScreen extends ConsumerWidget { final ComplianceSection section; final String itemId; + /// Picks a real file: the camera for a new photo, the gallery for an + /// existing one. Camera access is requested first, because image_picker + /// surfaces a refusal as a plain null and the worker would be left tapping a + /// button that silently does nothing. Future _upload(BuildContext context, WidgetRef ref) async { - final choice = await showModalBottomSheet( + final source = await showModalBottomSheet( context: context, showDragHandle: true, builder: (context) => SafeArea( @@ -311,29 +319,67 @@ class ComplianceDetailScreen extends ConsumerWidget { 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'), + onTap: () => Navigator.pop(context, ImageSource.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'), + onTap: () => Navigator.pop(context, ImageSource.gallery), ), ], ), ), ); - if (choice == null || !context.mounted) return; + if (source == null || !context.mounted) return; - final ext = choice == 'file' ? 'pdf' : 'jpg'; - final slug = itemId.toLowerCase(); + if (source == ImageSource.camera) { + final granted = await ref + .read(sessionProvider.notifier) + .request(AppPermission.camera); + if (!context.mounted) return; + if (!granted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Camera access is off'), + action: SnackBarAction( + label: 'Settings', + onPressed: ref.read(permissionServiceProvider).openSettings, + ), + ), + ); + return; + } + } + + final XFile? picked; + try { + picked = await ImagePicker().pickImage( + source: source, + // Documents only need to be legible, and a full-resolution phone + // photo is a slow upload on a venue's wifi. + maxWidth: 2000, + imageQuality: 85, + ); + } on PlatformException catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Could not open: ${error.code}'))); + return; + } + // Null means the picker was dismissed, which is not an error. + if (picked == null || !context.mounted) return; + + final size = await picked.length(); + if (!context.mounted) return; ref .read(complianceProvider.notifier) - .uploadFile(section, itemId, 'krow-$slug.$ext'); + .uploadFile( + section, + itemId, + picked.name, + meta: '${(size / 1024 / 1024).toStringAsFixed(1)} MB · just now', + ); ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Uploaded - sent for review'))); diff --git a/lib/screens/home_shell.dart b/lib/screens/home_shell.dart index c7207a7..984b98d 100644 --- a/lib/screens/home_shell.dart +++ b/lib/screens/home_shell.dart @@ -27,15 +27,16 @@ class HomeShell extends ConsumerWidget { i, initialLocation: i == navigationShell.currentIndex, ), + // Must stay in the same order as the shell branches in the router. items: [ - const KrowNavItem(icon: LucideIcons.house, label: 'Today'), const KrowNavItem(icon: LucideIcons.briefcase, label: 'Shifts'), + const KrowNavItem(icon: LucideIcons.wallet, label: 'Pay'), + const KrowNavItem(icon: LucideIcons.house, label: 'Today'), KrowNavItem( icon: LucideIcons.clock, label: 'Clock', showDot: needsReview, ), - const KrowNavItem(icon: LucideIcons.wallet, label: 'Pay'), const KrowNavItem(icon: LucideIcons.user, label: 'Profile'), ], ), diff --git a/lib/screens/onboarding_screens.dart b/lib/screens/onboarding_screens.dart index c9a9b9f..85c1741 100644 --- a/lib/screens/onboarding_screens.dart +++ b/lib/screens/onboarding_screens.dart @@ -2,33 +2,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.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, - ), - ); - } -} +import '../widgets/common.dart'; +import '../widgets/krow_wordmark.dart'; /// Splash. Routes on to onboarding, sign-in or the app depending on how far /// the worker got last time. @@ -57,10 +37,11 @@ class _SplashScreenState extends ConsumerState { void _go() { if (!mounted) return; final s = ref.read(sessionProvider); - if (!s.onboardingSeen) { + // Onboarding sits between the splash and the welcome screen for anyone + // who is not signed in, rather than only on a first install — the flow is + // splash, onboarding, welcome, mobile number. + if (!s.signedIn) { context.go('/onboarding'); - } else if (!s.signedIn) { - context.go('/welcome'); } else if (!s.permissionsDone) { context.go('/permissions'); } else { @@ -76,7 +57,7 @@ class _SplashScreenState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const KrowWordmark(size: 46), + const KrowWordmark(width: 196), const SizedBox(height: 22), Text( 'WORK · HIRE · ELEVATE', @@ -107,38 +88,177 @@ class _SplashScreenState extends ConsumerState { } class _Slide { - const _Slide(this.eyebrow, this.title, this.body, this.icon); + const _Slide({ + required this.eyebrow, + required this.title, + required this.body, + required this.art, + required this.preview, + }); final String eyebrow; final String title; final String body; - final IconData icon; + + /// The scene behind the copy, taken from the prototype's own artwork. + final String art; + + /// A sample of the thing the slide is describing, floated over the artwork. + /// Each one is a real piece of the app rather than an illustration. + final Widget preview; } 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, + eyebrow: 'Find work', + title: 'Shifts that fit around your week.', + body: + 'Browse open hospitality shifts near you, filtered to the hours you ' + 'said you can work.', + art: 'assets/art/onboarding_find_work.svg', + preview: _PreviewCard( + accent: KrowPalette.yellow, + title: r'Barista · $21/hr', + subtitle: 'Zoox · tonight, 5:50 PM', + ), ), _Slide( - 'Know your pay', - 'See the money before you say yes.', - 'Every shift shows the hourly rate and the total. No surprises on the ' + eyebrow: 'Know your pay', + title: 'See the money before you say yes.', + body: + 'Every shift shows the hourly rate and the total. No surprises on the ' 'payslip.', - LucideIcons.banknote, + art: 'assets/art/onboarding_know_your_pay.svg', + preview: _PreviewRow( + label: 'Regular · 5h 48m', + trailing: Text( + r'$121.80', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: KrowPalette.ink, + ), + ), + ), ), _Slide( - 'Get paid', - 'Clock in on site. Paid on schedule.', - 'Tap in when you arrive, tap out when you leave. Timecards submit ' + eyebrow: 'Get paid', + title: 'Clock in on site. Paid on schedule.', + body: + 'Tap in when you arrive, tap out when you leave. Timecards submit ' 'themselves.', - LucideIcons.clock, + art: 'assets/art/onboarding_get_paid.svg', + preview: _PreviewRow(label: 'Paid 26 Aug', trailing: StatusChip('On time')), ), ]; +/// A miniature shift card, matching the one used throughout the app: identity +/// rail down the side, role and venue stacked. +class _PreviewCard extends StatelessWidget { + const _PreviewCard({ + required this.accent, + required this.title, + required this.subtitle, + }); + + final Color accent; + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) { + return Container( + width: 218, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: KrowPalette.blueDeep.withValues(alpha: 0.35), + blurRadius: 24, + offset: const Offset(0, 10), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Row( + children: [ + Container(width: 6, height: 62, color: accent), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 11, 12, 11), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11.5, + color: KrowPalette.mute, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +/// A single floated row: a label and a figure or status. +class _PreviewRow extends StatelessWidget { + const _PreviewRow({required this.label, required this.trailing}); + + final String label; + final Widget trailing; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: KrowPalette.blueDeep.withValues(alpha: 0.35), + blurRadius: 24, + offset: const Offset(0, 10), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w600), + ), + const SizedBox(width: 14), + trailing, + ], + ), + ); + } +} + class OnboardingScreen extends ConsumerStatefulWidget { const OnboardingScreen({super.key}); @@ -156,10 +276,7 @@ class _OnboardingScreenState extends ConsumerState { super.dispose(); } - void _finish() { - ref.read(sessionProvider.notifier).completeOnboarding(); - context.go('/welcome'); - } + void _finish() => context.go('/welcome'); @override Widget build(BuildContext context) { @@ -175,7 +292,7 @@ class _OnboardingScreenState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const KrowWordmark(size: 20), + const KrowWordmark(width: 66), TextButton( onPressed: _finish, style: TextButton.styleFrom( @@ -194,20 +311,37 @@ class _OnboardingScreenState extends ConsumerState { 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, + // Artwork behind, with a real piece of the app floated over + // the bottom-right of it — the prototype's hero layout. + return Stack( + fit: StackFit.expand, + children: [ + // Cropped rather than letterboxed, so the scene fills the + // hero on any aspect ratio the way the prototype does. + SvgPicture.asset( + slide.art, + fit: BoxFit.cover, + alignment: Alignment.topCenter, ), - child: Icon( - slide.icon, - size: 76, - color: KrowPalette.yellow, + // Veil: darkens the foot of the artwork so the floated + // card and the sheet below it keep their edges. + const Positioned( + left: 0, + right: 0, + bottom: 0, + height: 140, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x0022366F), Color(0xF022366F)], + ), + ), + ), ), - ), + Positioned(right: 20, bottom: 24, child: slide.preview), + ], ); }, ), @@ -322,7 +456,7 @@ class WelcomeScreen extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const KrowWordmark(size: 40), + const KrowWordmark(width: 150), const SizedBox(height: 34), const Text( 'Work the shifts\nyou actually want.', diff --git a/lib/screens/pay_screens.dart b/lib/screens/pay_screens.dart index ca54c00..29f576e 100644 --- a/lib/screens/pay_screens.dart +++ b/lib/screens/pay_screens.dart @@ -12,22 +12,25 @@ import '../state/shifts_controller.dart'; import '../widgets/collapsing_header.dart'; import '../widgets/common.dart'; import '../widgets/glass_segments.dart'; +import '../widgets/krow_wordmark.dart'; -enum PayRange { week, month, year, all } +/// Declared in the order the segments are shown, which is the prototype's: +/// All first, then narrowing. +enum PayRange { all, week, month, year } extension on PayRange { String get label => switch (this) { + PayRange.all => 'All', PayRange.week => 'Week', PayRange.month => 'Month', PayRange.year => 'Year', - PayRange.all => 'All', }; Duration get window => switch (this) { + PayRange.all => const Duration(days: 3650), PayRange.week => const Duration(days: 7), PayRange.month => const Duration(days: 30), PayRange.year => const Duration(days: 365), - PayRange.all => const Duration(days: 3650), }; } @@ -86,16 +89,10 @@ class _PayScreenState extends ConsumerState { 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 HeaderBrandLine( + trailing: HeaderEyebrow('Gross pay · pre-tax'), ), - const SizedBox(height: 5), + const SizedBox(height: 6), Text( Fmt.money(paid + pending), style: const TextStyle( diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 793de96..11f0854 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -8,6 +8,7 @@ import '../state/compliance_controller.dart'; import '../state/profile_controller.dart'; import '../widgets/collapsing_header.dart'; import '../widgets/common.dart'; +import '../widgets/krow_wordmark.dart'; class ProfileScreen extends ConsumerWidget { const ProfileScreen({super.key}); @@ -69,11 +70,14 @@ class ProfileScreen extends ConsumerWidget { // row it added 46px the header could not afford. Positioned( top: 0, + left: 0, right: 0, - child: HeaderIconButton( - icon: LucideIcons.settings, - tooltip: 'Settings', - onPressed: () => context.push('/settings'), + child: HeaderBrandLine( + trailing: HeaderIconButton( + icon: LucideIcons.settings, + tooltip: 'Settings', + onPressed: () => context.push('/settings'), + ), ), ), Column( diff --git a/lib/screens/requirements_screen.dart b/lib/screens/requirements_screen.dart new file mode 100644 index 0000000..845c637 --- /dev/null +++ b/lib/screens/requirements_screen.dart @@ -0,0 +1,173 @@ +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 '../models/compliance.dart'; +import '../state/compliance_controller.dart'; +import '../state/shifts_controller.dart'; +import '../widgets/common.dart'; +import 'shift_detail_screen.dart'; + +/// Everything one business asks for, on its own screen. +/// +/// The shift page shows the same check inline; this is the full read, grouped +/// the way the compliance hub groups things, so it is obvious *which* kind of +/// item is missing and where to go and fix it. +class RequirementsScreen extends ConsumerWidget { + const RequirementsScreen({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.')), + ); + } + + final compliance = ref.watch(complianceProvider); + + /// The items this shift needs, split by which compliance section they + /// belong to, so each group can be labelled and linked separately. + List group(ComplianceSection section) => compliance + .section(section) + .where((i) => shift.requirements.contains(i.title)) + .toList(); + + final groups = <(ComplianceSection, String, List)>[ + ( + ComplianceSection.documents, + 'Documents', + group(ComplianceSection.documents), + ), + (ComplianceSection.wardrobe, 'Attire', group(ComplianceSection.wardrobe)), + ( + ComplianceSection.certificates, + 'Certificates', + group(ComplianceSection.certificates), + ), + ]; + + return Scaffold( + appBar: AppBar( + title: const Text('Requirements'), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 16), + child: Center( + child: IconButton( + onPressed: () => context.push('/support'), + icon: const Icon(LucideIcons.messageCircle, size: 20), + tooltip: 'Message the manager', + ), + ), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 28), + children: [ + Text( + '${shift.role} at ${shift.business}', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.7, + ), + ), + const SizedBox(height: 16), + // The same summary the shift page shows, so the two never disagree. + ShiftRequirements(shift: shift), + for (final (section, label, items) in groups) + if (items.isNotEmpty) ...[ + SectionLabel( + label, + trailing: TextButton( + onPressed: () => context.push('/compliance/${section.name}'), + child: const Text('Manage'), + ), + ), + KrowCard( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + for (var i = 0; i < items.length; i++) ...[ + if (i > 0) const Divider(height: 1), + _RequirementRow(item: items[i]), + ], + ], + ), + ), + ], + const SizedBox(height: 16), + const InfoBanner( + 'These are shared with the business only while you are booked on ' + 'one of their shifts.', + ), + ], + ), + ); + } +} + +class _RequirementRow extends StatelessWidget { + const _RequirementRow({required this.item}); + + final ComplianceItem item; + + @override + Widget build(BuildContext context) { + final ok = item.state == ComplianceState.verified; + return 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 1), + Text( + item.subtitle, + style: const TextStyle( + fontSize: 12, + height: 1.4, + color: KrowPalette.mute, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + StatusChip(item.badge, tone: ok ? ChipTone.neutral : ChipTone.yellow), + ], + ), + ); + } +} diff --git a/lib/screens/settings_screens.dart b/lib/screens/settings_screens.dart index c7b48c7..b8608ae 100644 --- a/lib/screens/settings_screens.dart +++ b/lib/screens/settings_screens.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../core/permissions.dart'; import '../core/theme.dart'; import '../state/profile_controller.dart'; import '../state/seed.dart'; @@ -11,13 +12,41 @@ import '../state/shifts_controller.dart'; import '../state/timecards_controller.dart'; import '../widgets/common.dart'; -class SettingsScreen extends ConsumerWidget { +class SettingsScreen extends ConsumerStatefulWidget { const SettingsScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final session = ref.watch(sessionProvider); + ConsumerState createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + // A permission can be revoked in system settings while the app is closed, + // so the switches are re-read from the OS rather than trusted from cache. + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(sessionProvider.notifier).refresh(); + }); + } + + /// The OS has no API to revoke a permission, and re-requesting a granted one + /// does nothing — so the switch hands off to the system settings page for + /// anything it cannot do itself. + Future _toggle(AppPermission permission, bool value) async { final notifier = ref.read(sessionProvider.notifier); + if (!value) { + await ref.read(permissionServiceProvider).openSettings(); + return; + } + final granted = await notifier.request(permission); + if (granted || !mounted) return; + await ref.read(permissionServiceProvider).openSettings(); + } + + @override + Widget build(BuildContext context) { + final session = ref.watch(sessionProvider); final profile = ref.watch(profileProvider); final reviewCount = ref.watch(timecardsNeedingReviewProvider).length; @@ -44,7 +73,7 @@ class SettingsScreen extends ConsumerWidget { title: 'Location', subtitle: 'Needed to clock in at the venue', value: session.locationGranted, - onChanged: (v) => v ? notifier.grantLocation() : null, + onChanged: (v) => _toggle(AppPermission.location, v), ), const Divider(height: 1), _SwitchRow( @@ -52,7 +81,7 @@ class SettingsScreen extends ConsumerWidget { title: 'Notifications', subtitle: 'Shift offers, reminders and pay alerts', value: session.notificationsGranted, - onChanged: (v) => v ? notifier.grantNotifications() : null, + onChanged: (v) => _toggle(AppPermission.notifications, v), ), const Divider(height: 1), _SwitchRow( @@ -60,7 +89,7 @@ class SettingsScreen extends ConsumerWidget { title: 'Camera', subtitle: 'Photograph documents and attire', value: session.cameraGranted, - onChanged: (v) => v ? notifier.grantCamera() : null, + onChanged: (v) => _toggle(AppPermission.camera, v), ), ], ), diff --git a/lib/screens/shift_detail_screen.dart b/lib/screens/shift_detail_screen.dart index 3e925a4..36169f7 100644 --- a/lib/screens/shift_detail_screen.dart +++ b/lib/screens/shift_detail_screen.dart @@ -152,8 +152,15 @@ class ShiftDetailScreen extends ConsumerWidget { ], ), ), - const SectionLabel('Requirements'), - _Requirements(shift: shift), + SectionLabel( + 'Requirements', + trailing: TextButton( + onPressed: () => + context.push('/shift/${shift.id}/requirements'), + child: const Text('See all'), + ), + ), + ShiftRequirements(shift: shift), const SizedBox(height: 18), _Actions(shift: shift), ], @@ -166,8 +173,9 @@ class ShiftDetailScreen extends ConsumerWidget { } /// What this business asks for, checked against what is already on file. -class _Requirements extends ConsumerWidget { - const _Requirements({required this.shift}); +/// Shown inline on the shift, and on its own screen from the link above it. +class ShiftRequirements extends ConsumerWidget { + const ShiftRequirements({super.key, required this.shift}); final Shift shift; diff --git a/lib/screens/shifts_screen.dart b/lib/screens/shifts_screen.dart index eb16c28..a5911fd 100644 --- a/lib/screens/shifts_screen.dart +++ b/lib/screens/shifts_screen.dart @@ -10,6 +10,7 @@ import '../models/shift.dart'; import '../state/shifts_controller.dart'; import '../widgets/common.dart'; import '../widgets/glass_segments.dart'; +import '../widgets/krow_wordmark.dart'; import '../widgets/shift_card.dart'; import '../widgets/week_strip.dart'; @@ -54,16 +55,10 @@ class _ShiftsScreenState extends ConsumerState { 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), - ), + HeaderBrandLine( + trailing: HeaderEyebrow(Fmt.monthYear(DateTime.now())), ), - const SizedBox(height: 4), + const SizedBox(height: 8), const Text( 'Shifts', style: TextStyle( diff --git a/lib/screens/timecard_screens.dart b/lib/screens/timecard_screens.dart index c5da005..9ddc22a 100644 --- a/lib/screens/timecard_screens.dart +++ b/lib/screens/timecard_screens.dart @@ -33,27 +33,36 @@ class TimecardsScreen extends ConsumerWidget { ), ], ), - 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]), - ), + body: ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 28), + children: timecardItems(cards), + ), ); } } -class _TimecardTile extends StatelessWidget { - const _TimecardTile({required this.card}); +/// The timecard list as plain children, so the Clock tab can drop them into +/// its sliver list and the pushed screen into a ListView, with one layout. +List timecardItems(List cards) { + if (cards.isEmpty) { + return const [ + EmptyState( + icon: LucideIcons.fileText, + title: 'No timecards yet', + message: 'One is filed automatically every time you clock out.', + ), + ]; + } + return [ + for (var i = 0; i < cards.length; i++) ...[ + if (i > 0) const SizedBox(height: 11), + TimecardTile(card: cards[i]), + ], + ]; +} + +class TimecardTile extends StatelessWidget { + const TimecardTile({super.key, required this.card}); final Timecard card; diff --git a/lib/screens/today_screen.dart b/lib/screens/today_screen.dart index bf35937..f80db5c 100644 --- a/lib/screens/today_screen.dart +++ b/lib/screens/today_screen.dart @@ -13,10 +13,15 @@ import '../state/shifts_controller.dart'; import '../widgets/collapsing_header.dart'; import '../widgets/common.dart'; import '../widgets/shift_card.dart'; +import '../widgets/krow_wordmark.dart'; class TodayScreen extends ConsumerWidget { const TodayScreen({super.key}); + /// Brand line (40) + gap + identity row (44) + gap + stats, plus 6/18 + /// padding. + static const _expandedHeader = 188.0; + String _greeting() { final h = DateTime.now().hour; if (h < 12) return 'Good morning'; @@ -39,16 +44,24 @@ class TodayScreen extends ConsumerWidget { body: CustomScrollView( slivers: [ KrowSliverHeader( - expandedHeight: 152, + expandedHeight: _expandedHeader, collapsedHeight: 60, builder: (context, t) => HeaderCrossFade( t: t, - expandedHeight: 152, + expandedHeight: _expandedHeader, expanded: Padding( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + padding: const EdgeInsets.fromLTRB(20, 6, 20, 18), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + HeaderBrandLine( + trailing: HeaderIconButton( + icon: LucideIcons.messageCircle, + tooltip: 'Support', + onPressed: () => context.push('/support'), + ), + ), + const SizedBox(height: 14), Row( children: [ _Avatar(worker.initials), @@ -69,6 +82,8 @@ class TodayScreen extends ConsumerWidget { const SizedBox(height: 2), Text( worker.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w700, @@ -79,14 +94,9 @@ class TodayScreen extends ConsumerWidget { ], ), ), - HeaderIconButton( - icon: LucideIcons.messageCircle, - tooltip: 'Support', - onPressed: () => context.push('/support'), - ), ], ), - const SizedBox(height: 18), + const SizedBox(height: 16), Row( children: [ // Flexible, not free-sized: on a 320px screen the two diff --git a/lib/state/compliance_controller.dart b/lib/state/compliance_controller.dart index 1363657..af6ac9e 100644 --- a/lib/state/compliance_controller.dart +++ b/lib/state/compliance_controller.dart @@ -104,7 +104,12 @@ class ComplianceController extends Notifier { /// Stand-in for the camera / file picker: attaching a file sends the item /// back for review. - void uploadFile(ComplianceSection section, String id, String fileName) { + void uploadFile( + ComplianceSection section, + String id, + String fileName, { + String? meta, + }) { _update( section, id, @@ -112,7 +117,7 @@ class ComplianceController extends Notifier { state: ComplianceState.pending, statusLabel: 'In review', fileName: fileName, - fileMeta: 'Uploaded just now', + fileMeta: meta ?? 'Uploaded just now', ), ); } diff --git a/lib/state/session_controller.dart b/lib/state/session_controller.dart index 088b11a..d539cd6 100644 --- a/lib/state/session_controller.dart +++ b/lib/state/session_controller.dart @@ -1,6 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../core/permissions.dart'; + /// Injected in `main()` once SharedPreferences has loaded. final prefsProvider = Provider( (ref) => throw UnimplementedError('prefsProvider must be overridden'), @@ -8,7 +10,6 @@ final prefsProvider = Provider( class SessionState { const SessionState({ - this.onboardingSeen = false, this.phone, this.signedIn = false, this.locationGranted = false, @@ -16,7 +17,6 @@ class SessionState { this.cameraGranted = false, }); - final bool onboardingSeen; final String? phone; final bool signedIn; final bool locationGranted; @@ -28,14 +28,12 @@ class SessionState { 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, @@ -45,7 +43,6 @@ class SessionState { } class SessionController extends Notifier { - static const _kOnboarding = 'onboarding_seen'; static const _kPhone = 'phone'; static const _kSignedIn = 'signed_in'; static const _kLocation = 'perm_location'; @@ -58,7 +55,6 @@ class SessionController extends Notifier { 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, @@ -67,11 +63,6 @@ class SessionController extends Notifier { ); } - 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'; @@ -88,19 +79,40 @@ class SessionController extends Notifier { return true; } - void grantLocation() { - _prefs.setBool(_kLocation, true); - state = state.copyWith(locationGranted: true); + /// Asks the OS, then records what it actually said. + /// + /// The stored flag is a cache for the UI, never the source of truth — the + /// user can revoke a permission in system settings without the app running, + /// so [refresh] re-reads the real status whenever a screen needs it. + Future request(AppPermission permission) async { + final granted = await ref + .read(permissionServiceProvider) + .request(permission); + _record(permission, granted); + return granted; } - void grantNotifications() { - _prefs.setBool(_kNotifications, true); - state = state.copyWith(notificationsGranted: true); + /// Re-reads all three from the OS. Called when a screen that shows their + /// state appears, so a change made in system settings is picked up. + Future refresh() async { + final service = ref.read(permissionServiceProvider); + for (final permission in AppPermission.values) { + _record(permission, await service.isGranted(permission)); + } } - void grantCamera() { - _prefs.setBool(_kCamera, true); - state = state.copyWith(cameraGranted: true); + void _record(AppPermission permission, bool granted) { + switch (permission) { + case AppPermission.location: + _prefs.setBool(_kLocation, granted); + state = state.copyWith(locationGranted: granted); + case AppPermission.notifications: + _prefs.setBool(_kNotifications, granted); + state = state.copyWith(notificationsGranted: granted); + case AppPermission.camera: + _prefs.setBool(_kCamera, granted); + state = state.copyWith(cameraGranted: granted); + } } Future signOut() async { diff --git a/lib/widgets/krow_nav_bar.dart b/lib/widgets/krow_nav_bar.dart index e9d7af9..7440c8b 100644 --- a/lib/widgets/krow_nav_bar.dart +++ b/lib/widgets/krow_nav_bar.dart @@ -43,6 +43,9 @@ class KrowNavBar extends StatelessWidget { /// of pixels apart. static const _topPadding = 12.0; + static const _labelSize = 11.0; + static const _dotSlot = 6.0; + /// Lets tests find the travelling indicator unambiguously. static const indicatorKey = ValueKey('krow-nav-indicator'); @@ -55,74 +58,75 @@ class KrowNavBar extends StatelessWidget { ), child: SafeArea( top: false, - child: SizedBox( - height: 76, - child: LayoutBuilder( - builder: (context, constraints) { - final slot = constraints.maxWidth / items.length; + // No fixed height: the Stack below takes the height of the item row, + // and the pill is positioned within it. A hard number here has to be + // retuned every time the label grows — a bigger text scale, a longer + // word, a different font — and overflows the moment it is not. + 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( - 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); + // The selected index animated as a double, so the pill's + // position and every icon's colour come from one value. + return TweenAnimationBuilder( + 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), - ), - ], - ), + 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); - }, + ), + 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); + }, ), - ], - ), - ], - ); - }, - ); - }, - ), + ), + ], + ), + ], + ); + }, + ); + }, ), ), ); @@ -171,6 +175,7 @@ class _NavItem extends StatelessWidget { // Started from the top rather than centred, so the icon sits exactly // where the pill is drawn. mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: KrowNavBar._topPadding), SizedBox( @@ -181,7 +186,7 @@ class _NavItem extends StatelessWidget { // The dot's slot is always reserved, so tabs with and without one // keep their labels on the same line. SizedBox( - height: 6, + height: KrowNavBar._dotSlot, child: Center( child: AnimatedScale( scale: item.showDot ? 1 : 0, @@ -202,11 +207,12 @@ class _NavItem extends StatelessWidget { Text( item.label, style: TextStyle( - fontSize: 11, + fontSize: KrowNavBar._labelSize, fontWeight: proximity > 0.5 ? FontWeight.w600 : FontWeight.w500, color: labelColor, ), ), + const SizedBox(height: 4), ], ), ), diff --git a/lib/widgets/krow_wordmark.dart b/lib/widgets/krow_wordmark.dart new file mode 100644 index 0000000..c74fbba --- /dev/null +++ b/lib/widgets/krow_wordmark.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import '../core/theme.dart'; + +/// The brand wordmark. +/// +/// The asset is an alpha mask — the shape lives in the alpha channel — so one +/// file tints to any colour. `Image.asset` blends a `color` with `srcIn` by +/// default, which is the same thing the brand sheet did with a CSS mask. +/// +/// Sized by width, because the mark is a fixed 3.06:1 and the width is what +/// has to sit within a layout. +class KrowWordmark extends StatelessWidget { + const KrowWordmark({super.key, this.width = 132, this.color}); + + final double width; + final Color? color; + + @override + Widget build(BuildContext context) { + return Image.asset( + 'assets/images/krow_logo.png', + width: width, + color: color ?? KrowPalette.yellow, + filterQuality: FilterQuality.medium, + semanticLabel: 'KROW', + ); + } +} + +/// The line that tops each main tab: the wordmark on the left, and whatever +/// that tab already had up there on the right — an action button, or its +/// caption. Putting the mark on the existing line rather than a new one keeps +/// the brand consistent across tabs without spending header height on it. +class HeaderBrandLine extends StatelessWidget { + const HeaderBrandLine({super.key, this.trailing}); + + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + // White rather than yellow: the accent is already carrying the avatar + // and the money figures, and a third yellow element leads nowhere. + const KrowWordmark(width: 62, color: Colors.white), + const SizedBox(width: 12), + if (trailing != null) + Expanded( + child: Align(alignment: Alignment.centerRight, child: trailing), + ), + ], + ); + } +} + +/// The small uppercase caption that sits beside the wordmark on a tab header. +class HeaderEyebrow extends StatelessWidget { + const HeaderEyebrow(this.text, {super.key}); + + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 9.5, + letterSpacing: 1.5, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.5), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 5f09bb8..0074ee5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" crypto: dependency: transitive description: @@ -97,6 +105,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + url: "https://pub.dev" + source: hosted + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" fixnum: dependency: transitive description: @@ -118,6 +158,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_riverpod: dependency: "direct main" description: @@ -126,6 +174,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.3" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -176,6 +232,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "0a55d645a670d6ae11efa948f955ae904afa472f1344fc15a7ddacc20c1e7219" + url: "https://pub.dev" + source: hosted + version: "0.8.13+23" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae + url: "https://pub.dev" + source: hosted + version: "0.8.13+7" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: "direct main" description: @@ -288,6 +408,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" objective_c: dependency: transitive description: @@ -312,6 +440,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: transitive description: @@ -360,6 +496,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: e6bcecadfe40df6b888141f23cbaf6ba6c3da49fb33f6ff887d060dd48544fbd + url: "https://pub.dev" + source: hosted + version: "13.0.2" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: b1ce660f4d0dcaffdf2605a19544c129fb5adda4e6fd039992ae356c4e437039 + url: "https://pub.dev" + source: hosted + version: "14.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8 + url: "https://pub.dev" + source: hosted + version: "9.6.1" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" + url: "https://pub.dev" + source: hosted + version: "0.1.4+1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: ed86a61c190258fdd65de395ea0632822e3415c1faec38eae0c31b479c28a531 + url: "https://pub.dev" + source: hosted + version: "4.4.1" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd + url: "https://pub.dev" + source: hosted + version: "0.2.2" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -533,6 +725,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" + url: "https://pub.dev" + source: hosted + version: "1.3.0" vector_math: dependency: transitive description: @@ -565,6 +781,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 532e17f..743eaf3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,9 @@ dependencies: lucide_icons_flutter: ^3.1.20 intl: ^0.20.3 shared_preferences: ^2.5.5 + flutter_svg: ^2.3.0 + permission_handler: ^13.0.2 + image_picker: ^1.2.3 dev_dependencies: flutter_test: @@ -63,10 +66,13 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + # The brand wordmark. Shipped as an alpha mask so a single file can be + # tinted white, yellow or blue wherever it appears. + - assets/images/krow_logo.png + # Onboarding artwork, lifted from the prototype's SVG with its theme + # variables resolved to the sage palette. + - assets/art/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/screen_layout_test.dart b/test/screen_layout_test.dart index 19c4fa5..061937d 100644 --- a/test/screen_layout_test.dart +++ b/test/screen_layout_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:krow_worker_app/core/theme.dart'; +import 'package:krow_worker_app/screens/auth_screens.dart'; import 'package:krow_worker_app/screens/clock_screen.dart'; import 'package:krow_worker_app/screens/pay_screens.dart'; import 'package:krow_worker_app/screens/profile_screen.dart'; @@ -72,6 +73,9 @@ void main() { 'Clock': const ClockScreen(), 'Pay': const PayScreen(), 'Profile': const ProfileScreen(), + // Not a tab, but it renders Today behind a sheet full of copy, so it + // is the most crowded layout in the app. + 'Permissions': const PermissionsScreen(), }; // A small phone, a tall one, and a short-but-wide one. diff --git a/web/favicon.png b/web/favicon.png index 8aaa46a..0c0b17a 100644 Binary files a/web/favicon.png and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index b749bfe..0626b0e 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index 88cfd48..5e1cb53 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png index eb9b4d7..d13d626 100644 Binary files a/web/icons/Icon-maskable-192.png and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png index d69c566..948bbbf 100644 Binary files a/web/icons/Icon-maskable-512.png and b/web/icons/Icon-maskable-512.png differ