feat: enhance UI, branding assets, onboarding flow, and screen layouts

This commit is contained in:
R-Bharathraj
2026-09-18 15:23:30 +05:30
parent cafffd27e0
commit 5122eebc11
68 changed files with 1661 additions and 470 deletions

View File

@@ -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 that taps report the right index, and that each tab reaches the semantics tree
with its selected state (the dot alone is not readable). 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 ## Wiring it to a backend
The seams are deliberate. `lib/state/seed.dart` is the only source of demo data; 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. 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. - **Geofence** — `ClockScreen` has an "at the venue" switch in place of GPS.
Replace with `geolocator` and a 150 m radius check against the venue. Location permission is real, but nothing measures distance yet. Add
- **File upload** — the compliance sheet fakes a filename. Replace with `geolocator` and a 150 m radius check against the venue.
`image_picker` / `file_picker` and a real upload. - **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
Notifications and SMS verification are also stubbed: the OTP is always `123456`. 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`.

View File

@@ -6,7 +6,10 @@ plugins {
android { android {
namespace = "in.nearle.krow.krow_worker_app" 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 ndkVersion = flutter.ndkVersion
compileOptions { compileOptions {

View File

@@ -1,4 +1,24 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Clock-in is gated on being at the venue, so the app asks for location
only while it is in use. It never tracks in the background. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Shift offers, clock-in reminders and pay alerts. Runtime-requested
from Android 13; older versions grant it at install. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Photographing attire and documents. -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Choosing an existing photo instead. READ_MEDIA_IMAGES is the Android
13+ replacement for READ_EXTERNAL_STORAGE, which is capped below it. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<application <application
android:label="krow_worker_app" android:label="krow_worker_app"
android:name="${applicationName}" android:name="${applicationName}"

View File

@@ -1,12 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen --> <!-- See drawable/launch_background.xml. Brand blue in dark mode too, since the
app's splash is always the blue immersive screen. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" /> <item android:drawable="@color/krow_blue" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list> </layer-list>

View File

@@ -1,12 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen --> <!-- Launch screen for Android 11 and below: brand blue, no image. The app's
own splash draws the KROW wordmark on the same colour, so there is no
white flash and nothing jumps between the two. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" /> <item android:drawable="@color/krow_blue" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list> </layer-list>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A deliberately empty splash icon. Android 12 and above always draws an
icon on the system splash screen, defaulting to the launcher icon; pointing
it at nothing is how you get a plain screen instead. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@android:color/transparent" />
</shape>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Adaptive icon for Android 8 and above: the launcher masks the foreground
to whatever shape the device uses (circle, squircle, teardrop), so the
wordmark is drawn inside the guaranteed-visible middle of the canvas and
the brand blue fills the rest. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/icon_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<!-- Themed icons on Android 13+ recolour this silhouette. -->
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Same as values-v31, for devices in dark mode. The splash is brand blue
either way, because the app's own splash always is. -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowSplashScreenBackground">@color/krow_blue</item>
<item name="android:windowSplashScreenAnimatedIcon">
@drawable/splash_icon_none
</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Android 12 and above ignore windowBackground for the launch screen and
use the system splash screen API instead, which shows the launcher icon
by default. These two items replace that with plain brand blue. -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowSplashScreenBackground">@color/krow_blue</item>
<item name="android:windowSplashScreenAnimatedIcon">
@drawable/splash_icon_none
</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 286CP, the brand blue. Matches KrowPalette.blue so the native launch
screen and the app's own splash are the same colour and the handover
is invisible. -->
<color name="krow_blue">#3752A8</color>
<!-- The launcher icon's ground. Separate from the splash colour on
purpose: the icon is a blue mark on white, the splash is brand blue. -->
<color name="icon_background">#FFFFFF</color>
</resources>

View File

@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 390 520" preserveAspectRatio="xMidYMid slice">
<rect width="390" height="520" style="fill:#3752A8"/>
<circle cx="252" cy="178" r="168" style="fill:#6486D8"/>
<!-- light beams -->
<path d="M96 118 L166 118 L206 300 L56 300 Z" style="fill:#F5C842" opacity=".13"/>
<path d="M244 96 L314 96 L354 300 L204 300 Z" style="fill:#F5C842" opacity=".13"/>
<!-- pendants -->
<rect x="129" y="0" width="4" height="62" style="fill:#F5C842" opacity=".55"/>
<rect x="277" y="0" width="4" height="40" style="fill:#F5C842" opacity=".55"/>
<path d="M100 118 L162 118 L150 62 L112 62 Z" style="fill:#F5C842"/>
<ellipse cx="131" cy="118" rx="31" ry="7" style="fill:#FDF3D8"/>
<path d="M248 96 L310 96 L298 40 L260 40 Z" style="fill:#F5C842"/>
<ellipse cx="279" cy="96" rx="31" ry="7" style="fill:#FDF3D8"/>
<!-- barista silhouette -->
<circle cx="205" cy="228" r="38" style="fill:#22366F"/>
<path d="M205 274c-46 0-78 30-86 78h172c-8-48-40-78-86-78Z" style="fill:#22366F"/>
<path d="M186 274h38v78h-38Z" style="fill:#E1E6F4"/>
<!-- counter -->
<rect x="0" y="352" width="390" height="14" style="fill:#CFDDD3"/>
<rect x="0" y="366" width="390" height="154" style="fill:#CFDDD3"/>
<!-- espresso machine -->
<rect x="20" y="256" width="118" height="96" rx="12" style="fill:#26353E"/>
<rect x="34" y="272" width="90" height="30" rx="7" style="fill:#65727A"/>
<circle cx="52" cy="326" r="9" style="fill:#F5C842"/>
<circle cx="80" cy="326" r="9" style="fill:#949FA5"/>
<rect x="98" y="340" width="30" height="8" rx="4" style="fill:#949FA5"/>
<!-- cup + steam -->
<path d="M262 318h52v22a14 14 0 0 1-14 14h-24a14 14 0 0 1-14-14Z" fill="#FFFFFF"/>
<path d="M314 322h10a10 10 0 0 1 0 20h-10" fill="none" stroke="#FFFFFF" stroke-width="6"/>
<ellipse cx="288" cy="352" rx="34" ry="6" style="fill:#CFDDD3"/>
<path d="M278 306c-8-10 8-16 0-26M292 306c-8-10 8-16 0-26" stroke="#FFFFFF" stroke-width="4.5"
stroke-linecap="round" fill="none" opacity=".65"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 390 520" preserveAspectRatio="xMidYMid slice">
<rect width="390" height="520" style="fill:#3752A8"/>
<circle cx="196" cy="212" r="180" style="fill:#6486D8"/>
<!-- clock -->
<circle cx="196" cy="206" r="118" style="fill:#F5C842"/>
<circle cx="196" cy="206" r="100" fill="none" style="stroke:#22366F" stroke-width="4" opacity=".25"/>
<g style="fill:#22366F" opacity=".55">
<rect x="193" y="98" width="6" height="16" rx="3"/>
<rect x="193" y="298" width="6" height="16" rx="3"/>
<rect x="88" y="203" width="16" height="6" rx="3"/>
<rect x="288" y="203" width="16" height="6" rx="3"/>
</g>
<path d="M196 206V136" style="stroke:#22366F" stroke-width="11" stroke-linecap="round"/>
<path d="M196 206l50 30" style="stroke:#22366F" stroke-width="11" stroke-linecap="round"/>
<circle cx="196" cy="206" r="9" style="fill:#22366F"/>
<!-- location pin -->
<path d="M92 350c0 34-38 62-38 62s-38-28-38-62a38 38 0 0 1 76 0Z" fill="#FFFFFF"
transform="translate(24 12)"/>
<circle cx="78" cy="360" r="14" style="fill:#3752A8"/>
<!-- phone -->
<rect x="238" y="330" width="104" height="170" rx="20" style="fill:#CFDDD3"/>
<rect x="250" y="346" width="80" height="138" rx="12" fill="#FFFFFF"/>
<rect x="262" y="366" width="56" height="9" rx="4.5" style="fill:#C4D4C9"/>
<rect x="262" y="386" width="40" height="9" rx="4.5" style="fill:#C4D4C9"/>
<rect x="262" y="416" width="56" height="26" rx="9" style="fill:#3752A8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 390 520" preserveAspectRatio="xMidYMid slice">
<rect width="390" height="520" style="fill:#3752A8"/>
<circle cx="128" cy="150" r="150" style="fill:#6486D8"/>
<!-- coin -->
<circle cx="272" cy="150" r="86" style="fill:#F5C842"/>
<circle cx="272" cy="150" r="72" fill="none" style="stroke:#22366F" stroke-width="5" opacity=".35"/>
<text x="272" y="150" text-anchor="middle" dominant-baseline="central"
font-family="Poppins" font-weight="800" font-size="84" style="fill:#22366F">$</text>
<circle cx="118" cy="92" r="15" style="fill:#CFDDD3" opacity=".5"/>
<circle cx="336" cy="272" r="10" style="fill:#CFDDD3" opacity=".5"/>
<!-- the docket, tilted -->
<g transform="rotate(-8 190 356)">
<rect x="44" y="258" width="292" height="196" rx="22" fill="#FFFFFF"/>
<rect x="44" y="258" width="11" height="196" rx="5" style="fill:#F5C842"/>
<rect x="78" y="290" width="128" height="15" rx="7" style="fill:#26353E"/>
<rect x="78" y="316" width="92" height="11" rx="5" style="fill:#CFDDD3"/>
<g style="fill:#C4D4C9">
<circle cx="86" cy="360" r="3"/><circle cx="102" cy="360" r="3"/><circle cx="118" cy="360" r="3"/>
<circle cx="134" cy="360" r="3"/><circle cx="150" cy="360" r="3"/><circle cx="166" cy="360" r="3"/>
<circle cx="182" cy="360" r="3"/><circle cx="198" cy="360" r="3"/><circle cx="214" cy="360" r="3"/>
<circle cx="230" cy="360" r="3"/><circle cx="246" cy="360" r="3"/><circle cx="262" cy="360" r="3"/>
<circle cx="278" cy="360" r="3"/><circle cx="294" cy="360" r="3"/><circle cx="310" cy="360" r="3"/>
</g>
<circle cx="44" cy="360" r="15" style="fill:#22366F"/>
<circle cx="336" cy="360" r="15" style="fill:#22366F"/>
<rect x="78" y="392" width="76" height="11" rx="5" style="fill:#CFDDD3"/>
<text x="302" y="404" text-anchor="end" font-family="Poppins" font-weight="800"
font-size="30" style="fill:#3752A8">$121.80</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
assets/images/krow_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 370 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 996 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 649 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 996 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@@ -13,17 +13,12 @@
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/> <viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/> <viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides> </layoutGuides>
<!-- No image view: the launch screen is plain 286CP brand
blue, matching the app's own splash so the handover from
native launch to Flutter is invisible. -->
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3"> <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <color key="backgroundColor" red="0.21568627450980393" green="0.32156862745098036" blue="0.6588235294117647" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view> </view>
</viewController> </viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/> <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
@@ -31,7 +26,4 @@
<point key="canvasLocation" x="53" y="375"/> <point key="canvasLocation" x="53" y="375"/>
</scene> </scene>
</scenes> </scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document> </document>

View File

@@ -47,6 +47,14 @@
</array> </array>
</dict> </dict>
</dict> </dict>
<!-- Shown verbatim in the iOS permission dialogs. Apple rejects builds
that request a permission without one. -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>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.</string>
<key>NSCameraUsageDescription</key>
<string>KROW uses the camera to photograph your uniform items, ID and certificates. Nothing is uploaded until you tap send.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>KROW lets you pick an existing photo of your uniform items, ID or certificates instead of taking a new one.</string>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>

62
lib/core/permissions.dart Normal file
View File

@@ -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<bool> request(AppPermission permission) async {
if (!_supported) return true;
final status = await permission._platform.request();
return status.isGranted || status.isLimited;
}
Future<bool> 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<bool> isPermanentlyDenied(AppPermission permission) async {
if (!_supported) return false;
return permission._platform.status.then((s) => s.isPermanentlyDenied);
}
Future<void> openSettings() async {
if (!_supported) return;
await openAppSettings();
}
}
final permissionServiceProvider = Provider<PermissionService>(
(ref) => const PermissionService(),
);

View File

@@ -10,6 +10,7 @@ import '../screens/onboarding_screens.dart';
import '../screens/pay_screens.dart'; import '../screens/pay_screens.dart';
import '../screens/profile_detail_screens.dart'; import '../screens/profile_detail_screens.dart';
import '../screens/profile_screen.dart'; import '../screens/profile_screen.dart';
import '../screens/requirements_screen.dart';
import '../screens/settings_screens.dart'; import '../screens/settings_screens.dart';
import '../screens/shift_detail_screen.dart'; import '../screens/shift_detail_screen.dart';
import '../screens/shifts_screen.dart'; import '../screens/shifts_screen.dart';
@@ -60,27 +61,30 @@ final router = GoRouter(
_detailRoute('/phone', (_) => const PhoneScreen()), _detailRoute('/phone', (_) => const PhoneScreen()),
_detailRoute('/otp', (_) => const OtpScreen()), _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( StatefulShellRoute.indexedStack(
builder: (_, _, shell) => HomeShell(navigationShell: shell), builder: (_, _, shell) => HomeShell(navigationShell: shell),
branches: [ branches: [
StatefulShellBranch(
routes: [
GoRoute(path: '/shifts', builder: (_, _) => const ShiftsScreen()),
],
),
StatefulShellBranch(
routes: [GoRoute(path: '/pay', builder: (_, _) => const PayScreen())],
),
StatefulShellBranch( StatefulShellBranch(
routes: [ routes: [
GoRoute(path: '/today', builder: (_, _) => const TodayScreen()), GoRoute(path: '/today', builder: (_, _) => const TodayScreen()),
], ],
), ),
StatefulShellBranch(
routes: [
GoRoute(path: '/shifts', builder: (_, _) => const ShiftsScreen()),
],
),
StatefulShellBranch( StatefulShellBranch(
routes: [ routes: [
GoRoute(path: '/clock', builder: (_, _) => const ClockScreen()), GoRoute(path: '/clock', builder: (_, _) => const ClockScreen()),
], ],
), ),
StatefulShellBranch(
routes: [GoRoute(path: '/pay', builder: (_, _) => const PayScreen())],
),
StatefulShellBranch( StatefulShellBranch(
routes: [ routes: [
GoRoute(path: '/profile', builder: (_, _) => const ProfileScreen()), GoRoute(path: '/profile', builder: (_, _) => const ProfileScreen()),
@@ -92,6 +96,12 @@ final router = GoRouter(
_detailRoute( _detailRoute(
'/shift/:id', '/shift/:id',
(state) => ShiftDetailScreen(shiftId: state.pathParameters['id']!), (state) => ShiftDetailScreen(shiftId: state.pathParameters['id']!),
routes: [
_detailRoute(
'requirements',
(state) => RequirementsScreen(shiftId: state.pathParameters['id']!),
),
],
), ),
_detailRoute('/availability', (_) => const AvailabilityScreen()), _detailRoute('/availability', (_) => const AvailabilityScreen()),
_detailRoute('/timecards', (_) => const TimecardsScreen()), _detailRoute('/timecards', (_) => const TimecardsScreen()),

View File

@@ -7,8 +7,11 @@ import 'package:go_router/go_router.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/theme.dart'; import '../core/theme.dart';
import '../core/permissions.dart';
import '../state/session_controller.dart'; import '../state/session_controller.dart';
import '../widgets/common.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. /// Step 1 of 2: the mobile number a shift manager would text.
class PhoneScreen extends ConsumerStatefulWidget { class PhoneScreen extends ConsumerStatefulWidget {
@@ -456,18 +459,92 @@ class _TodoRow extends StatelessWidget {
} }
} }
/// One permission ask, as the prototype words it.
class _Permission { 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 IconData icon;
final String step; final String step;
final String title; final String title;
final String body; final String body;
final List<String> 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 const _permissions = [
/// app knows what it is allowed to do later. _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 { class PermissionsScreen extends ConsumerStatefulWidget {
const PermissionsScreen({super.key}); const PermissionsScreen({super.key});
@@ -477,58 +554,9 @@ class PermissionsScreen extends ConsumerStatefulWidget {
class _PermissionsScreenState extends ConsumerState<PermissionsScreen> { class _PermissionsScreenState extends ConsumerState<PermissionsScreen> {
int _step = 0; int _step = 0;
bool _asking = false;
static const _permissions = [ void _next() {
_Permission(
LucideIcons.mapPin,
'Step 1 of 3',
'Let KROW see where you are',
'Clock-in is locked until you are within 150 m of the venue. Without '
'location you cannot start a shift.',
[
'Unlocks clock in when you arrive',
'Sorts shifts by distance from you',
'Only read while a shift is active',
],
),
_Permission(
LucideIcons.bell,
'Step 2 of 3',
'Turn on notifications',
'Shift offers are first-come. If your phone is silent someone else takes '
'the shift.',
[
'New shift offers matched to you',
'Clock-in reminders 30 min before',
'Pay alerts when money lands',
],
),
_Permission(
LucideIcons.camera,
'Step 3 of 3',
'Camera and photos',
'Attire and document checks need a photo of the real item. Nothing is '
'uploaded until you tap send.',
[
'Photograph your uniform items',
'Capture ID and certificates',
'Nothing is stored on our servers unsent',
],
),
];
void _advance({required bool granted}) {
final notifier = ref.read(sessionProvider.notifier);
if (granted) {
switch (_step) {
case 0:
notifier.grantLocation();
case 1:
notifier.grantNotifications();
case 2:
notifier.grantCamera();
}
}
if (_step == _permissions.length - 1) { if (_step == _permissions.length - 1) {
context.go('/today'); context.go('/today');
} else { } else {
@@ -536,115 +564,267 @@ class _PermissionsScreenState extends ConsumerState<PermissionsScreen> {
} }
} }
/// 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<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final p = _permissions[_step]; final permission = _permissions[_step];
return Scaffold( return Scaffold(
body: SafeArea( backgroundColor: KrowPalette.sage,
child: Padding( body: Stack(
padding: const EdgeInsets.fromLTRB(22, 24, 22, 16), children: [
child: Column( // The app itself, already built, sitting behind the ask.
crossAxisAlignment: CrossAxisAlignment.stretch, const IgnorePointer(
children: [ child: Scaffold(
// The copy scrolls if it has to; the actions stay pinned, so a body: TodayScreen(),
// short screen or a large text scale can never push them off. bottomNavigationBar: KrowNavBar(
Expanded( items: [
child: SingleChildScrollView( KrowNavItem(icon: LucideIcons.briefcase, label: 'Shifts'),
child: Column( KrowNavItem(icon: LucideIcons.wallet, label: 'Pay'),
crossAxisAlignment: CrossAxisAlignment.start, KrowNavItem(icon: LucideIcons.house, label: 'Today'),
children: [ KrowNavItem(icon: LucideIcons.clock, label: 'Clock'),
Container( KrowNavItem(icon: LucideIcons.user, label: 'Profile'),
width: 60, ],
height: 60, currentIndex: 2,
decoration: BoxDecoration( onSelect: _ignoreTap,
color: KrowPalette.blue, ),
borderRadius: BorderRadius.circular(18), ),
), ),
child: Icon(p.icon, size: 27, color: Colors.white), 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,
),
),
),
],
);
}
}

View File

@@ -11,9 +11,13 @@ import '../core/theme.dart';
import '../models/shift.dart'; import '../models/shift.dart';
import '../state/session_controller.dart'; import '../state/session_controller.dart';
import '../state/shifts_controller.dart'; import '../state/shifts_controller.dart';
import '../state/timecards_controller.dart';
import '../widgets/collapsing_header.dart'; import '../widgets/collapsing_header.dart';
import '../widgets/common.dart'; import '../widgets/common.dart';
import '../widgets/glass_segments.dart';
import '../widgets/krow_wordmark.dart';
import '../widgets/shift_card.dart'; import '../widgets/shift_card.dart';
import 'timecard_screens.dart';
/// Clock in and out. The button unlocks 30 minutes before the shift starts, /// Clock in and out. The button unlocks 30 minutes before the shift starts,
/// and only once the worker is at the venue. /// and only once the worker is at the venue.
@@ -24,7 +28,11 @@ class ClockScreen extends ConsumerStatefulWidget {
ConsumerState<ClockScreen> createState() => _ClockScreenState(); ConsumerState<ClockScreen> createState() => _ClockScreenState();
} }
/// The two halves of the Clock tab, as the prototype splits them.
enum _ClockTab { today, timecards }
class _ClockScreenState extends ConsumerState<ClockScreen> { class _ClockScreenState extends ConsumerState<ClockScreen> {
_ClockTab _tab = _ClockTab.today;
Timer? _ticker; Timer? _ticker;
/// Stands in for the geofence check while there is no GPS plugin wired up. /// Stands in for the geofence check while there is no GPS plugin wired up.
@@ -66,56 +74,58 @@ class _ClockScreenState extends ConsumerState<ClockScreen> {
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
// Already compact, so it pins at one height rather than collapsing. // Pins at one height: the two segments have to stay reachable, so
// Sized with headroom over the 58px of content, so a larger system // there is nothing worth collapsing away. Sized with headroom for a
// text scale does not push it over. // large system text scale, which the eyebrow and title both grow with.
KrowSliverHeader( KrowSliverHeader(
expandedHeight: 96, expandedHeight: 170,
collapsedHeight: 96, collapsedHeight: 170,
builder: (context, t) => Padding( builder: (context, t) => Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: Row( child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( HeaderBrandLine(
child: Column( // The caption reports whichever half you are looking at.
mainAxisAlignment: MainAxisAlignment.center, trailing: HeaderEyebrow(
crossAxisAlignment: CrossAxisAlignment.start, _tab == _ClockTab.today
children: [ ? Fmt.dayFull(DateTime.now())
Text( : '${Fmt.monthYear(DateTime.now())} · '
Fmt.dayFull(DateTime.now()).toUpperCase(), '${Fmt.duration(ref.watch(hoursLoggedProvider))} logged',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.5,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5),
),
),
const SizedBox(height: 4),
const Text(
'Clock in',
style: TextStyle(
fontSize: 27,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
color: Colors.white,
),
),
],
), ),
), ),
HeaderIconButton( const SizedBox(height: 8),
icon: LucideIcons.fileText, const Text(
tooltip: 'Timecards', 'Clock in',
onPressed: () => context.push('/timecards'), 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( const SliverPadding(
padding: EdgeInsets.fromLTRB(20, 40, 20, 20), padding: EdgeInsets.fromLTRB(20, 40, 20, 20),
sliver: SliverToBoxAdapter( sliver: SliverToBoxAdapter(
@@ -168,12 +178,6 @@ class _ClockScreenState extends ConsumerState<ClockScreen> {
shift: shift, shift: shift,
onTap: () => context.push('/shift/${shift.id}'), 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'),
),
], ],
), ),
), ),

View File

@@ -1,12 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/theme.dart'; import '../core/theme.dart';
import '../core/permissions.dart';
import '../models/compliance.dart'; import '../models/compliance.dart';
import '../state/compliance_controller.dart'; import '../state/compliance_controller.dart';
import '../state/session_controller.dart';
import '../widgets/common.dart'; import '../widgets/common.dart';
IconData _sectionIcon(ComplianceSection s) => switch (s) { IconData _sectionIcon(ComplianceSection s) => switch (s) {
@@ -299,8 +303,12 @@ class ComplianceDetailScreen extends ConsumerWidget {
final ComplianceSection section; final ComplianceSection section;
final String itemId; 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<void> _upload(BuildContext context, WidgetRef ref) async { Future<void> _upload(BuildContext context, WidgetRef ref) async {
final choice = await showModalBottomSheet<String>( final source = await showModalBottomSheet<ImageSource>(
context: context, context: context,
showDragHandle: true, showDragHandle: true,
builder: (context) => SafeArea( builder: (context) => SafeArea(
@@ -311,29 +319,67 @@ class ComplianceDetailScreen extends ConsumerWidget {
leading: const Icon(LucideIcons.camera), leading: const Icon(LucideIcons.camera),
title: const Text('Take a photo'), title: const Text('Take a photo'),
subtitle: const Text('It has to be the real item, worn by you'), 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( ListTile(
leading: const Icon(LucideIcons.image), leading: const Icon(LucideIcons.image),
title: const Text('Choose from library'), title: const Text('Choose from library'),
onTap: () => Navigator.pop(context, 'library'), onTap: () => Navigator.pop(context, ImageSource.gallery),
),
ListTile(
leading: const Icon(LucideIcons.file),
title: const Text('Pick a PDF'),
onTap: () => Navigator.pop(context, 'file'),
), ),
], ],
), ),
), ),
); );
if (choice == null || !context.mounted) return; if (source == null || !context.mounted) return;
final ext = choice == 'file' ? 'pdf' : 'jpg'; if (source == ImageSource.camera) {
final slug = itemId.toLowerCase(); 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 ref
.read(complianceProvider.notifier) .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( ScaffoldMessenger.of(
context, context,
).showSnackBar(const SnackBar(content: Text('Uploaded - sent for review'))); ).showSnackBar(const SnackBar(content: Text('Uploaded - sent for review')));

View File

@@ -27,15 +27,16 @@ class HomeShell extends ConsumerWidget {
i, i,
initialLocation: i == navigationShell.currentIndex, initialLocation: i == navigationShell.currentIndex,
), ),
// Must stay in the same order as the shell branches in the router.
items: [ items: [
const KrowNavItem(icon: LucideIcons.house, label: 'Today'),
const KrowNavItem(icon: LucideIcons.briefcase, label: 'Shifts'), const KrowNavItem(icon: LucideIcons.briefcase, label: 'Shifts'),
const KrowNavItem(icon: LucideIcons.wallet, label: 'Pay'),
const KrowNavItem(icon: LucideIcons.house, label: 'Today'),
KrowNavItem( KrowNavItem(
icon: LucideIcons.clock, icon: LucideIcons.clock,
label: 'Clock', label: 'Clock',
showDot: needsReview, showDot: needsReview,
), ),
const KrowNavItem(icon: LucideIcons.wallet, label: 'Pay'),
const KrowNavItem(icon: LucideIcons.user, label: 'Profile'), const KrowNavItem(icon: LucideIcons.user, label: 'Profile'),
], ],
), ),

View File

@@ -2,33 +2,13 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/theme.dart'; import '../core/theme.dart';
import '../state/session_controller.dart'; import '../state/session_controller.dart';
import '../widgets/common.dart';
/// Brand wordmark. Drawn rather than shipped as an asset so it tints cleanly import '../widgets/krow_wordmark.dart';
/// on either ground.
class KrowWordmark extends StatelessWidget {
const KrowWordmark({super.key, this.size = 34, this.color});
final double size;
final Color? color;
@override
Widget build(BuildContext context) {
return Text(
'KROW',
style: TextStyle(
fontSize: size,
fontWeight: FontWeight.w800,
letterSpacing: size * 0.06,
color: color ?? KrowPalette.yellow,
),
);
}
}
/// Splash. Routes on to onboarding, sign-in or the app depending on how far /// Splash. Routes on to onboarding, sign-in or the app depending on how far
/// the worker got last time. /// the worker got last time.
@@ -57,10 +37,11 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
void _go() { void _go() {
if (!mounted) return; if (!mounted) return;
final s = ref.read(sessionProvider); 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'); context.go('/onboarding');
} else if (!s.signedIn) {
context.go('/welcome');
} else if (!s.permissionsDone) { } else if (!s.permissionsDone) {
context.go('/permissions'); context.go('/permissions');
} else { } else {
@@ -76,7 +57,7 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const KrowWordmark(size: 46), const KrowWordmark(width: 196),
const SizedBox(height: 22), const SizedBox(height: 22),
Text( Text(
'WORK · HIRE · ELEVATE', 'WORK · HIRE · ELEVATE',
@@ -107,38 +88,177 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
} }
class _Slide { 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 eyebrow;
final String title; final String title;
final String body; 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 = [ const _slides = [
_Slide( _Slide(
'Find work', eyebrow: 'Find work',
'Shifts that fit around your week.', title: 'Shifts that fit around your week.',
'Browse open hospitality shifts near you, filtered to the hours you said ' body:
'you can work.', 'Browse open hospitality shifts near you, filtered to the hours you '
LucideIcons.search, '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( _Slide(
'Know your pay', eyebrow: 'Know your pay',
'See the money before you say yes.', title: 'See the money before you say yes.',
'Every shift shows the hourly rate and the total. No surprises on the ' body:
'Every shift shows the hourly rate and the total. No surprises on the '
'payslip.', '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( _Slide(
'Get paid', eyebrow: 'Get paid',
'Clock in on site. Paid on schedule.', title: 'Clock in on site. Paid on schedule.',
'Tap in when you arrive, tap out when you leave. Timecards submit ' body:
'Tap in when you arrive, tap out when you leave. Timecards submit '
'themselves.', '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 { class OnboardingScreen extends ConsumerStatefulWidget {
const OnboardingScreen({super.key}); const OnboardingScreen({super.key});
@@ -156,10 +276,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
super.dispose(); super.dispose();
} }
void _finish() { void _finish() => context.go('/welcome');
ref.read(sessionProvider.notifier).completeOnboarding();
context.go('/welcome');
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -175,7 +292,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const KrowWordmark(size: 20), const KrowWordmark(width: 66),
TextButton( TextButton(
onPressed: _finish, onPressed: _finish,
style: TextButton.styleFrom( style: TextButton.styleFrom(
@@ -194,20 +311,37 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
onPageChanged: (i) => setState(() => _page = i), onPageChanged: (i) => setState(() => _page = i),
itemBuilder: (context, i) { itemBuilder: (context, i) {
final slide = _slides[i]; final slide = _slides[i];
return Center( // Artwork behind, with a real piece of the app floated over
child: Container( // the bottom-right of it — the prototype's hero layout.
width: 190, return Stack(
height: 190, fit: StackFit.expand,
decoration: BoxDecoration( children: [
color: Colors.white.withValues(alpha: 0.10), // Cropped rather than letterboxed, so the scene fills the
shape: BoxShape.circle, // hero on any aspect ratio the way the prototype does.
SvgPicture.asset(
slide.art,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
), ),
child: Icon( // Veil: darkens the foot of the artwork so the floated
slide.icon, // card and the sheet below it keep their edges.
size: 76, const Positioned(
color: KrowPalette.yellow, 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( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const KrowWordmark(size: 40), const KrowWordmark(width: 150),
const SizedBox(height: 34), const SizedBox(height: 34),
const Text( const Text(
'Work the shifts\nyou actually want.', 'Work the shifts\nyou actually want.',

View File

@@ -12,22 +12,25 @@ import '../state/shifts_controller.dart';
import '../widgets/collapsing_header.dart'; import '../widgets/collapsing_header.dart';
import '../widgets/common.dart'; import '../widgets/common.dart';
import '../widgets/glass_segments.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 { extension on PayRange {
String get label => switch (this) { String get label => switch (this) {
PayRange.all => 'All',
PayRange.week => 'Week', PayRange.week => 'Week',
PayRange.month => 'Month', PayRange.month => 'Month',
PayRange.year => 'Year', PayRange.year => 'Year',
PayRange.all => 'All',
}; };
Duration get window => switch (this) { Duration get window => switch (this) {
PayRange.all => const Duration(days: 3650),
PayRange.week => const Duration(days: 7), PayRange.week => const Duration(days: 7),
PayRange.month => const Duration(days: 30), PayRange.month => const Duration(days: 30),
PayRange.year => const Duration(days: 365), PayRange.year => const Duration(days: 365),
PayRange.all => const Duration(days: 3650),
}; };
} }
@@ -86,16 +89,10 @@ class _PayScreenState extends ConsumerState<PayScreen> {
expanded: Column( expanded: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( const HeaderBrandLine(
'ESTIMATED GROSS PAY · PRE-TAX', trailing: HeaderEyebrow('Gross pay · pre-tax'),
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.5,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5),
),
), ),
const SizedBox(height: 5), const SizedBox(height: 6),
Text( Text(
Fmt.money(paid + pending), Fmt.money(paid + pending),
style: const TextStyle( style: const TextStyle(

View File

@@ -8,6 +8,7 @@ import '../state/compliance_controller.dart';
import '../state/profile_controller.dart'; import '../state/profile_controller.dart';
import '../widgets/collapsing_header.dart'; import '../widgets/collapsing_header.dart';
import '../widgets/common.dart'; import '../widgets/common.dart';
import '../widgets/krow_wordmark.dart';
class ProfileScreen extends ConsumerWidget { class ProfileScreen extends ConsumerWidget {
const ProfileScreen({super.key}); const ProfileScreen({super.key});
@@ -69,11 +70,14 @@ class ProfileScreen extends ConsumerWidget {
// row it added 46px the header could not afford. // row it added 46px the header could not afford.
Positioned( Positioned(
top: 0, top: 0,
left: 0,
right: 0, right: 0,
child: HeaderIconButton( child: HeaderBrandLine(
icon: LucideIcons.settings, trailing: HeaderIconButton(
tooltip: 'Settings', icon: LucideIcons.settings,
onPressed: () => context.push('/settings'), tooltip: 'Settings',
onPressed: () => context.push('/settings'),
),
), ),
), ),
Column( Column(

View File

@@ -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<ComplianceItem> group(ComplianceSection section) => compliance
.section(section)
.where((i) => shift.requirements.contains(i.title))
.toList();
final groups = <(ComplianceSection, String, List<ComplianceItem>)>[
(
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),
],
),
);
}
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/permissions.dart';
import '../core/theme.dart'; import '../core/theme.dart';
import '../state/profile_controller.dart'; import '../state/profile_controller.dart';
import '../state/seed.dart'; import '../state/seed.dart';
@@ -11,13 +12,41 @@ import '../state/shifts_controller.dart';
import '../state/timecards_controller.dart'; import '../state/timecards_controller.dart';
import '../widgets/common.dart'; import '../widgets/common.dart';
class SettingsScreen extends ConsumerWidget { class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
final session = ref.watch(sessionProvider); }
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
@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<void> _toggle(AppPermission permission, bool value) async {
final notifier = ref.read(sessionProvider.notifier); 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 profile = ref.watch(profileProvider);
final reviewCount = ref.watch(timecardsNeedingReviewProvider).length; final reviewCount = ref.watch(timecardsNeedingReviewProvider).length;
@@ -44,7 +73,7 @@ class SettingsScreen extends ConsumerWidget {
title: 'Location', title: 'Location',
subtitle: 'Needed to clock in at the venue', subtitle: 'Needed to clock in at the venue',
value: session.locationGranted, value: session.locationGranted,
onChanged: (v) => v ? notifier.grantLocation() : null, onChanged: (v) => _toggle(AppPermission.location, v),
), ),
const Divider(height: 1), const Divider(height: 1),
_SwitchRow( _SwitchRow(
@@ -52,7 +81,7 @@ class SettingsScreen extends ConsumerWidget {
title: 'Notifications', title: 'Notifications',
subtitle: 'Shift offers, reminders and pay alerts', subtitle: 'Shift offers, reminders and pay alerts',
value: session.notificationsGranted, value: session.notificationsGranted,
onChanged: (v) => v ? notifier.grantNotifications() : null, onChanged: (v) => _toggle(AppPermission.notifications, v),
), ),
const Divider(height: 1), const Divider(height: 1),
_SwitchRow( _SwitchRow(
@@ -60,7 +89,7 @@ class SettingsScreen extends ConsumerWidget {
title: 'Camera', title: 'Camera',
subtitle: 'Photograph documents and attire', subtitle: 'Photograph documents and attire',
value: session.cameraGranted, value: session.cameraGranted,
onChanged: (v) => v ? notifier.grantCamera() : null, onChanged: (v) => _toggle(AppPermission.camera, v),
), ),
], ],
), ),

View File

@@ -152,8 +152,15 @@ class ShiftDetailScreen extends ConsumerWidget {
], ],
), ),
), ),
const SectionLabel('Requirements'), SectionLabel(
_Requirements(shift: shift), 'Requirements',
trailing: TextButton(
onPressed: () =>
context.push('/shift/${shift.id}/requirements'),
child: const Text('See all'),
),
),
ShiftRequirements(shift: shift),
const SizedBox(height: 18), const SizedBox(height: 18),
_Actions(shift: shift), _Actions(shift: shift),
], ],
@@ -166,8 +173,9 @@ class ShiftDetailScreen extends ConsumerWidget {
} }
/// What this business asks for, checked against what is already on file. /// What this business asks for, checked against what is already on file.
class _Requirements extends ConsumerWidget { /// Shown inline on the shift, and on its own screen from the link above it.
const _Requirements({required this.shift}); class ShiftRequirements extends ConsumerWidget {
const ShiftRequirements({super.key, required this.shift});
final Shift shift; final Shift shift;

View File

@@ -10,6 +10,7 @@ import '../models/shift.dart';
import '../state/shifts_controller.dart'; import '../state/shifts_controller.dart';
import '../widgets/common.dart'; import '../widgets/common.dart';
import '../widgets/glass_segments.dart'; import '../widgets/glass_segments.dart';
import '../widgets/krow_wordmark.dart';
import '../widgets/shift_card.dart'; import '../widgets/shift_card.dart';
import '../widgets/week_strip.dart'; import '../widgets/week_strip.dart';
@@ -54,16 +55,10 @@ class _ShiftsScreenState extends ConsumerState<ShiftsScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( HeaderBrandLine(
Fmt.monthYear(DateTime.now()).toUpperCase(), trailing: HeaderEyebrow(Fmt.monthYear(DateTime.now())),
style: TextStyle(
fontSize: 9.5,
letterSpacing: 1.6,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.5),
),
), ),
const SizedBox(height: 4), const SizedBox(height: 8),
const Text( const Text(
'Shifts', 'Shifts',
style: TextStyle( style: TextStyle(

View File

@@ -33,27 +33,36 @@ class TimecardsScreen extends ConsumerWidget {
), ),
], ],
), ),
body: cards.isEmpty body: ListView(
? const Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
padding: EdgeInsets.all(20), children: timecardItems(cards),
child: EmptyState( ),
icon: LucideIcons.fileText,
title: 'No timecards yet',
message: 'One is filed automatically every time you clock out.',
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
itemCount: cards.length,
separatorBuilder: (_, _) => const SizedBox(height: 11),
itemBuilder: (context, i) => _TimecardTile(card: cards[i]),
),
); );
} }
} }
class _TimecardTile extends StatelessWidget { /// The timecard list as plain children, so the Clock tab can drop them into
const _TimecardTile({required this.card}); /// its sliver list and the pushed screen into a ListView, with one layout.
List<Widget> timecardItems(List<Timecard> 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; final Timecard card;

View File

@@ -13,10 +13,15 @@ import '../state/shifts_controller.dart';
import '../widgets/collapsing_header.dart'; import '../widgets/collapsing_header.dart';
import '../widgets/common.dart'; import '../widgets/common.dart';
import '../widgets/shift_card.dart'; import '../widgets/shift_card.dart';
import '../widgets/krow_wordmark.dart';
class TodayScreen extends ConsumerWidget { class TodayScreen extends ConsumerWidget {
const TodayScreen({super.key}); const TodayScreen({super.key});
/// Brand line (40) + gap + identity row (44) + gap + stats, plus 6/18
/// padding.
static const _expandedHeader = 188.0;
String _greeting() { String _greeting() {
final h = DateTime.now().hour; final h = DateTime.now().hour;
if (h < 12) return 'Good morning'; if (h < 12) return 'Good morning';
@@ -39,16 +44,24 @@ class TodayScreen extends ConsumerWidget {
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
KrowSliverHeader( KrowSliverHeader(
expandedHeight: 152, expandedHeight: _expandedHeader,
collapsedHeight: 60, collapsedHeight: 60,
builder: (context, t) => HeaderCrossFade( builder: (context, t) => HeaderCrossFade(
t: t, t: t,
expandedHeight: 152, expandedHeight: _expandedHeader,
expanded: Padding( expanded: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), padding: const EdgeInsets.fromLTRB(20, 6, 20, 18),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
HeaderBrandLine(
trailing: HeaderIconButton(
icon: LucideIcons.messageCircle,
tooltip: 'Support',
onPressed: () => context.push('/support'),
),
),
const SizedBox(height: 14),
Row( Row(
children: [ children: [
_Avatar(worker.initials), _Avatar(worker.initials),
@@ -69,6 +82,8 @@ class TodayScreen extends ConsumerWidget {
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
worker.name, worker.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.w700, 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( Row(
children: [ children: [
// Flexible, not free-sized: on a 320px screen the two // Flexible, not free-sized: on a 320px screen the two

View File

@@ -104,7 +104,12 @@ class ComplianceController extends Notifier<ComplianceData> {
/// Stand-in for the camera / file picker: attaching a file sends the item /// Stand-in for the camera / file picker: attaching a file sends the item
/// back for review. /// back for review.
void uploadFile(ComplianceSection section, String id, String fileName) { void uploadFile(
ComplianceSection section,
String id,
String fileName, {
String? meta,
}) {
_update( _update(
section, section,
id, id,
@@ -112,7 +117,7 @@ class ComplianceController extends Notifier<ComplianceData> {
state: ComplianceState.pending, state: ComplianceState.pending,
statusLabel: 'In review', statusLabel: 'In review',
fileName: fileName, fileName: fileName,
fileMeta: 'Uploaded just now', fileMeta: meta ?? 'Uploaded just now',
), ),
); );
} }

View File

@@ -1,6 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../core/permissions.dart';
/// Injected in `main()` once SharedPreferences has loaded. /// Injected in `main()` once SharedPreferences has loaded.
final prefsProvider = Provider<SharedPreferences>( final prefsProvider = Provider<SharedPreferences>(
(ref) => throw UnimplementedError('prefsProvider must be overridden'), (ref) => throw UnimplementedError('prefsProvider must be overridden'),
@@ -8,7 +10,6 @@ final prefsProvider = Provider<SharedPreferences>(
class SessionState { class SessionState {
const SessionState({ const SessionState({
this.onboardingSeen = false,
this.phone, this.phone,
this.signedIn = false, this.signedIn = false,
this.locationGranted = false, this.locationGranted = false,
@@ -16,7 +17,6 @@ class SessionState {
this.cameraGranted = false, this.cameraGranted = false,
}); });
final bool onboardingSeen;
final String? phone; final String? phone;
final bool signedIn; final bool signedIn;
final bool locationGranted; final bool locationGranted;
@@ -28,14 +28,12 @@ class SessionState {
locationGranted && notificationsGranted && cameraGranted; locationGranted && notificationsGranted && cameraGranted;
SessionState copyWith({ SessionState copyWith({
bool? onboardingSeen,
String? phone, String? phone,
bool? signedIn, bool? signedIn,
bool? locationGranted, bool? locationGranted,
bool? notificationsGranted, bool? notificationsGranted,
bool? cameraGranted, bool? cameraGranted,
}) => SessionState( }) => SessionState(
onboardingSeen: onboardingSeen ?? this.onboardingSeen,
phone: phone ?? this.phone, phone: phone ?? this.phone,
signedIn: signedIn ?? this.signedIn, signedIn: signedIn ?? this.signedIn,
locationGranted: locationGranted ?? this.locationGranted, locationGranted: locationGranted ?? this.locationGranted,
@@ -45,7 +43,6 @@ class SessionState {
} }
class SessionController extends Notifier<SessionState> { class SessionController extends Notifier<SessionState> {
static const _kOnboarding = 'onboarding_seen';
static const _kPhone = 'phone'; static const _kPhone = 'phone';
static const _kSignedIn = 'signed_in'; static const _kSignedIn = 'signed_in';
static const _kLocation = 'perm_location'; static const _kLocation = 'perm_location';
@@ -58,7 +55,6 @@ class SessionController extends Notifier<SessionState> {
SessionState build() { SessionState build() {
final p = _prefs; final p = _prefs;
return SessionState( return SessionState(
onboardingSeen: p.getBool(_kOnboarding) ?? false,
phone: p.getString(_kPhone), phone: p.getString(_kPhone),
signedIn: p.getBool(_kSignedIn) ?? false, signedIn: p.getBool(_kSignedIn) ?? false,
locationGranted: p.getBool(_kLocation) ?? false, locationGranted: p.getBool(_kLocation) ?? false,
@@ -67,11 +63,6 @@ class SessionController extends Notifier<SessionState> {
); );
} }
void completeOnboarding() {
_prefs.setBool(_kOnboarding, true);
state = state.copyWith(onboardingSeen: true);
}
/// The demo backend accepts any 10-digit number and always sends `123456`. /// The demo backend accepts any 10-digit number and always sends `123456`.
static const demoCode = '123456'; static const demoCode = '123456';
@@ -88,19 +79,40 @@ class SessionController extends Notifier<SessionState> {
return true; return true;
} }
void grantLocation() { /// Asks the OS, then records what it actually said.
_prefs.setBool(_kLocation, true); ///
state = state.copyWith(locationGranted: true); /// 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<bool> request(AppPermission permission) async {
final granted = await ref
.read(permissionServiceProvider)
.request(permission);
_record(permission, granted);
return granted;
} }
void grantNotifications() { /// Re-reads all three from the OS. Called when a screen that shows their
_prefs.setBool(_kNotifications, true); /// state appears, so a change made in system settings is picked up.
state = state.copyWith(notificationsGranted: true); Future<void> refresh() async {
final service = ref.read(permissionServiceProvider);
for (final permission in AppPermission.values) {
_record(permission, await service.isGranted(permission));
}
} }
void grantCamera() { void _record(AppPermission permission, bool granted) {
_prefs.setBool(_kCamera, true); switch (permission) {
state = state.copyWith(cameraGranted: true); 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<void> signOut() async { Future<void> signOut() async {

View File

@@ -43,6 +43,9 @@ class KrowNavBar extends StatelessWidget {
/// of pixels apart. /// of pixels apart.
static const _topPadding = 12.0; static const _topPadding = 12.0;
static const _labelSize = 11.0;
static const _dotSlot = 6.0;
/// Lets tests find the travelling indicator unambiguously. /// Lets tests find the travelling indicator unambiguously.
static const indicatorKey = ValueKey('krow-nav-indicator'); static const indicatorKey = ValueKey('krow-nav-indicator');
@@ -55,74 +58,75 @@ class KrowNavBar extends StatelessWidget {
), ),
child: SafeArea( child: SafeArea(
top: false, top: false,
child: SizedBox( // No fixed height: the Stack below takes the height of the item row,
height: 76, // and the pill is positioned within it. A hard number here has to be
child: LayoutBuilder( // retuned every time the label grows — a bigger text scale, a longer
builder: (context, constraints) { // word, a different font — and overflows the moment it is not.
final slot = constraints.maxWidth / items.length; child: LayoutBuilder(
builder: (context, constraints) {
final slot = constraints.maxWidth / items.length;
// The selected index animated as a double, so the pill's // The selected index animated as a double, so the pill's
// position and every icon's colour come from one value. // position and every icon's colour come from one value.
return TweenAnimationBuilder<double>( return TweenAnimationBuilder<double>(
tween: Tween(end: currentIndex.toDouble()), tween: Tween(end: currentIndex.toDouble()),
duration: const Duration(milliseconds: 360), duration: const Duration(milliseconds: 360),
curve: Curves.easeOutQuint, curve: Curves.easeOutQuint,
builder: (context, position, _) { builder: (context, position, _) {
final travel = (position - position.roundToDouble()).abs(); final travel = (position - position.roundToDouble()).abs();
final stretch = 20 * (travel * 2); final stretch = 20 * (travel * 2);
return Stack( return Stack(
children: [ children: [
Positioned( Positioned(
left: left:
position * slot + position * slot +
(slot - _pillWidth) / 2 - (slot - _pillWidth) / 2 -
stretch / 2, stretch / 2,
top: _topPadding, top: _topPadding,
width: _pillWidth + stretch, width: _pillWidth + stretch,
height: _pillHeight, height: _pillHeight,
child: DecoratedBox( child: DecoratedBox(
key: indicatorKey, key: indicatorKey,
decoration: BoxDecoration( decoration: BoxDecoration(
color: KrowPalette.blue, color: KrowPalette.blue,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: KrowPalette.blue.withValues(alpha: 0.28), color: KrowPalette.blue.withValues(alpha: 0.28),
blurRadius: 12, blurRadius: 12,
offset: const Offset(0, 4), offset: const Offset(0, 4),
), ),
], ],
),
), ),
), ),
Row( ),
children: [ Row(
for (var i = 0; i < items.length; i++) children: [
Expanded( for (var i = 0; i < items.length; i++)
child: _NavItem( Expanded(
item: items[i], child: _NavItem(
// 1 when the pill is centred on this tab. item: items[i],
proximity: (1 - (position - i).abs()).clamp( // 1 when the pill is centred on this tab.
0.0, proximity: (1 - (position - i).abs()).clamp(
1.0, 0.0,
), 1.0,
onTap: () {
if (i != currentIndex) {
HapticFeedback.selectionClick();
}
onSelect(i);
},
), ),
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 // Started from the top rather than centred, so the icon sits exactly
// where the pill is drawn. // where the pill is drawn.
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: KrowNavBar._topPadding), const SizedBox(height: KrowNavBar._topPadding),
SizedBox( SizedBox(
@@ -181,7 +186,7 @@ class _NavItem extends StatelessWidget {
// The dot's slot is always reserved, so tabs with and without one // The dot's slot is always reserved, so tabs with and without one
// keep their labels on the same line. // keep their labels on the same line.
SizedBox( SizedBox(
height: 6, height: KrowNavBar._dotSlot,
child: Center( child: Center(
child: AnimatedScale( child: AnimatedScale(
scale: item.showDot ? 1 : 0, scale: item.showDot ? 1 : 0,
@@ -202,11 +207,12 @@ class _NavItem extends StatelessWidget {
Text( Text(
item.label, item.label,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: KrowNavBar._labelSize,
fontWeight: proximity > 0.5 ? FontWeight.w600 : FontWeight.w500, fontWeight: proximity > 0.5 ? FontWeight.w600 : FontWeight.w500,
color: labelColor, color: labelColor,
), ),
), ),
const SizedBox(height: 4),
], ],
), ),
), ),

View File

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

View File

@@ -57,6 +57,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" 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: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -97,6 +105,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" 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: fixnum:
dependency: transitive dependency: transitive
description: description:
@@ -118,6 +158,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" 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: flutter_riverpod:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -126,6 +174,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.3" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -176,6 +232,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" 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: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -288,6 +408,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.18.0"
mime:
dependency: transitive
description:
name: mime
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
url: "https://pub.dev"
source: hosted
version: "2.1.0"
objective_c: objective_c:
dependency: transitive dependency: transitive
description: description:
@@ -312,6 +440,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" 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: path_provider:
dependency: transitive dependency: transitive
description: description:
@@ -360,6 +496,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" 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: platform:
dependency: transitive dependency: transitive
description: description:
@@ -533,6 +725,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.6.0" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -565,6 +781,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:

View File

@@ -40,6 +40,9 @@ dependencies:
lucide_icons_flutter: ^3.1.20 lucide_icons_flutter: ^3.1.20
intl: ^0.20.3 intl: ^0.20.3
shared_preferences: ^2.5.5 shared_preferences: ^2.5.5
flutter_svg: ^2.3.0
permission_handler: ^13.0.2
image_picker: ^1.2.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -63,10 +66,13 @@ flutter:
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
# To add assets to your application, add an assets section, like this: assets:
# assets: # The brand wordmark. Shipped as an alpha mask so a single file can be
# - images/a_dot_burr.jpeg # tinted white, yellow or blue wherever it appears.
# - images/a_dot_ham.jpeg - 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 # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images # https://flutter.dev/to/resolution-aware-images

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:krow_worker_app/core/theme.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/clock_screen.dart';
import 'package:krow_worker_app/screens/pay_screens.dart'; import 'package:krow_worker_app/screens/pay_screens.dart';
import 'package:krow_worker_app/screens/profile_screen.dart'; import 'package:krow_worker_app/screens/profile_screen.dart';
@@ -72,6 +73,9 @@ void main() {
'Clock': const ClockScreen(), 'Clock': const ClockScreen(),
'Pay': const PayScreen(), 'Pay': const PayScreen(),
'Profile': const ProfileScreen(), '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. // A small phone, a tall one, and a short-but-wide one.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 389 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 17 KiB