Initial commit: Krow Worker App

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

46
.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
.claude/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

36
.metadata Normal file
View File

@@ -0,0 +1,36 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "6b182d2c7585eba26d4edce0f97630effd256c33"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
- platform: android
create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
- platform: ios
create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
- platform: web
create_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
base_revision: 6b182d2c7585eba26d4edce0f97630effd256c33
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

159
README.md Normal file
View File

@@ -0,0 +1,159 @@
# KROW Worker
A Flutter app for shift workers: find hospitality shifts, clock in on site, get
timecards filed automatically, and keep compliance documents in order.
Built from the workflow in the KROW worker-app prototype — the HTML was used as
the reference for screens and flow, not as a pixel spec.
## Running it
```bash
flutter pub get
flutter run
```
Targets Android, iOS and web. To try it in a browser:
```bash
flutter run -d chrome
```
**Demo sign-in:** any 10-digit mobile number, then the code `123456`.
## What works
Everything below is wired to real state, not static screens. Shifts, timecards
and compliance live in memory for the session; onboarding, sign-in,
availability, skills and preferences persist via `shared_preferences`.
**Getting in** — splash routes by how far you got last time · 3-screen
onboarding with skip · phone entry with validation · OTP with a resend
countdown, a wrong-code path and a 3-try budget · 3-step permission run whose
grants are recorded and reflected in Settings.
**Today** — greeting, period earnings and hours logged, a compliance nudge while
anything is outstanding, your next shift, quick tiles and matched open shifts.
**Shifts** — Booked / Open / History. The week strip filters booked shifts by
day and dots the days that have work. Open supports search and filters, and
Apply books the shift immediately. History carries completed, cancelled and
no-show shifts with a stat rail.
**Shift detail** — full schedule and pay breakdown, plus a requirements check
that compares what the business asks for against what you actually have on
file. Actions change with status: apply, cancel (with confirmation), or jump to
the clock.
**Clock** — the button unlocks 30 minutes before the shift, and only once you
are at the venue. Clocking in starts a live stopwatch against a progress ring;
clocking out completes the shift, deducts the unpaid break and files a timecard.
**Timecards** — every filed card with its scheduled vs clocked window, variance
in minutes, and pay. Review one to approve it or raise a dispute with a note.
The tab badge counts what is still waiting on you.
**Pay** — gross pre-tax earnings across week / month / year / all, a trend chart
split paid vs pending, per-payment detail, and a semi-monthly payroll schedule
that computes the current and next period from today's date.
**Profile & compliance** — live stats and a trust index derived from the shifts
you have actually worked. The compliance hub tracks documents, certificates, tax
forms and attire; uploading sends an item back for review, removing it drops it
to missing, and expiry dates are picked from a calendar. Plus emergency
contacts, experience and skills, availability, benefits, direct deposit,
settings, support, FAQs and privacy.
## Motion
Motion is used to explain what changed, not to decorate. The rules:
- **Going deeper pushes.** Detail screens slide in from the right while the
screen behind eases back and dims, so the stack reads as a stack. Going
sideways — sign-up steps, replacing a screen — cross-fades instead.
- **Selection travels.** The bottom nav indicator and the segmented controls
slide between positions and squash slightly at speed. Labels light up as the
indicator arrives over them, driven by the same animated value, so nothing
changes ahead of the thing causing it.
- **Numbers grow.** Progress bars and chart bars tween to new values, so an
uploaded document or a switched pay range is something you watch happen.
- **State changes animate together.** Availability blocks, week-strip days and
the clock dial move every part of themselves in one gesture rather than
flipping several properties at once.
- **Consequential actions are felt.** Clocking in and out give a medium impact;
selecting a tab, segment, or applying for a shift gives a selection click.
**Headers stay put.** On the five tabs the blue header is pinned: content
scrolls beneath it rather than pushing it off the top. A tall header held at
full size would cost a quarter of a phone screen for the whole session, so it
collapses as you scroll and cross-fades to a compact bar that keeps what still
matters — your name and the period figure on Today, the gross total on Pay.
Where a header holds a control, the collapsed state keeps it. Pay stops
shrinking while the range switcher is still on screen; collapsing that away
would mean scrolling back to the top just to change what you are looking at.
Shifts keeps its whole header fixed for the same reason — the Booked / Open /
History switch has to stay reachable.
Scrolling uses bouncing physics on every platform (`KrowScrollBehavior`). Under
a pinned header a rubber-band edge tells you that you have reached the end of
the content rather than the end of the screen; clamping just stops dead. The
overscroll glow is turned off, since the bounce already says it.
Two deliberate exceptions:
- **Tab content does not slide.** Switching bottom tabs fades and lifts the
content instead. An `AnimatedSwitcher` would hold two copies of the shell in
the tree, and the branch navigators are keyed by `GlobalKey`, so duplicating
them throws. `_TabTransition` animates one subtree in place, which keeps each
tab's scroll position.
- **No `Hero` between list and detail.** The tab branches all stay alive in an
`IndexedStack`, so the same shift can be on screen in two branches at once
and duplicate hero tags would throw.
## Layout
```
lib/
core/ theme, router, formatters
models/ shift, timecard, availability, compliance, worker
state/ Riverpod controllers + seed data
screens/ one file per area of the app
widgets/ shift card, week strip, shared pieces
```
## Packages
| Package | Why |
| --- | --- |
| `flutter_riverpod` | State: shifts, timecards, availability, compliance, profile, session |
| `go_router` | Routing, including the 5-tab shell where each tab keeps its own stack |
| `google_fonts` | Poppins, the brand typeface |
| `lucide_icons_flutter` | Icon set matching the prototype's line icons |
| `intl` | Currency, date and time formatting |
| `shared_preferences` | Persisting session, availability and preferences |
## Tests
```bash
flutter test
```
Covers pay calculation (break deduction, clocked vs scheduled hours, zero-pay
states), timecard variance, and the formatters — plus the custom nav and
segmented control: that the indicator actually travels rather than jumping,
that taps report the right index, and that each tab reaches the semantics tree
with its selected state (the dot alone is not readable).
## Wiring it to a backend
The seams are deliberate. `lib/state/seed.dart` is the only source of demo data;
swap each controller's `build()` for an API call and the screens are unchanged.
Two places currently stand in for device APIs:
- **Geofence** — `ClockScreen` has an "at the venue" switch in place of GPS.
Replace with `geolocator` and a 150 m radius check against the venue.
- **File upload** — the compliance sheet fakes a filename. Replace with
`image_picker` / `file_picker` and a real upload.
Notifications and SMS verification are also stubbed: the OTP is always `123456`.

28
analysis_options.yaml Normal file
View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,45 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "in.nearle.krow.krow_worker_app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "in.nearle.krow.krow_worker_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="krow_worker_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package `in`.nearle.krow.krow_worker_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

24
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip

View File

@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")

34
ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1,644 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = in.nearle.krow.krowWorkerApp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = in.nearle.krow.krowWorkerApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = in.nearle.krow.krowWorkerApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = in.nearle.krow.krowWorkerApp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = in.nearle.krow.krowWorkerApp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = in.nearle.krow.krowWorkerApp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}

View File

@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<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>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

70
ios/Runner/Info.plist Normal file
View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Krow Worker App</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>krow_worker_app</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}

View File

@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

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

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

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

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

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

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

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

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

73
lib/main.dart Normal file
View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,388 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/formatters.dart';
import '../core/theme.dart';
import '../models/timecard.dart';
import '../state/timecards_controller.dart';
import '../widgets/common.dart';
class TimecardsScreen extends ConsumerWidget {
const TimecardsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cards = ref.watch(timecardsProvider);
final pending = cards.where((c) => c.status == TimecardStatus.needsReview);
return Scaffold(
appBar: AppBar(
title: const Text('Your timecards'),
actions: [
if (pending.isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: StatusChip(
'${pending.length} to review',
tone: ChipTone.yellow,
),
),
),
],
),
body: cards.isEmpty
? const Padding(
padding: EdgeInsets.all(20),
child: EmptyState(
icon: LucideIcons.fileText,
title: 'No timecards yet',
message: 'One is filed automatically every time you clock out.',
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
itemCount: cards.length,
separatorBuilder: (_, _) => const SizedBox(height: 11),
itemBuilder: (context, i) => _TimecardTile(card: cards[i]),
),
);
}
}
class _TimecardTile extends StatelessWidget {
const _TimecardTile({required this.card});
final Timecard card;
@override
Widget build(BuildContext context) {
final (label, tone) = switch (card.status) {
TimecardStatus.needsReview => ('Needs review', ChipTone.yellow),
TimecardStatus.approved => ('Approved', ChipTone.neutral),
TimecardStatus.disputed => ('Disputed', ChipTone.danger),
};
return KrowCard(
onTap: () => context.push('/timecard/${card.id}'),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
BusinessBadge(card.business[0], size: 40),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.role,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
),
),
Text(
'${card.business} · ${Fmt.dayShort(card.date)}',
style: const TextStyle(
fontSize: 12.5,
color: KrowPalette.mute,
),
),
],
),
),
StatusChip(label, tone: tone),
],
),
const SizedBox(height: 14),
const Divider(height: 1),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _MiniStat(
LucideIcons.calendar,
'Scheduled',
Fmt.window(card.scheduledStart, card.scheduledEnd),
),
),
Container(width: 1, height: 34, color: KrowPalette.line),
Expanded(
child: _MiniStat(
LucideIcons.clock,
'Clocked',
Fmt.window(card.clockIn, card.clockOut),
highlight: true,
),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Filed ${Fmt.time(card.submittedAt)} · '
'${Fmt.duration(card.worked)}',
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: Color(0xFF949FA5),
),
),
Text(
Fmt.money(card.amount),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
),
),
],
),
],
),
);
}
}
class _MiniStat extends StatelessWidget {
const _MiniStat(this.icon, this.label, this.value, {this.highlight = false});
final IconData icon;
final String label;
final String value;
final bool highlight;
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: KrowPalette.blueTint,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 16, color: KrowPalette.blue),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: KrowPalette.mute,
),
),
Text(
value,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: highlight ? KrowPalette.blue : KrowPalette.ink,
),
),
],
),
),
],
);
}
}
/// Review a filed timecard: confirm it, or raise an issue before it locks for
/// payroll.
class TimecardReviewScreen extends ConsumerWidget {
const TimecardReviewScreen({super.key, required this.timecardId});
final String timecardId;
Future<void> _dispute(BuildContext context, WidgetRef ref) async {
final controller = TextEditingController();
final note = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Report an issue'),
content: TextField(
controller: controller,
autofocus: true,
maxLines: 4,
decoration: const InputDecoration(
hintText: 'What is wrong with these hours?',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
child: const Text('Send'),
),
],
),
);
if (note == null || note.isEmpty || !context.mounted) return;
ref.read(timecardsProvider.notifier).dispute(timecardId, note);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Issue raised with payroll')));
context.pop();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final card = ref.watch(timecardsProvider.notifier).byId(timecardId);
if (card == null) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: Text('Timecard not found.')),
);
}
final variance = card.varianceMinutes;
final open = card.status == TimecardStatus.needsReview;
return Scaffold(
appBar: AppBar(
title: const Text('Review timecard'),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: StatusChip(
card.status.label,
tone: switch (card.status) {
TimecardStatus.needsReview => ChipTone.yellow,
TimecardStatus.approved => ChipTone.neutral,
TimecardStatus.disputed => ChipTone.danger,
},
),
),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
Text(
'${card.role} at ${card.business}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: -0.8,
),
),
const SizedBox(height: 4),
Text(
Fmt.dayFull(card.date),
style: const TextStyle(fontSize: 13.5, color: KrowPalette.mute),
),
const SizedBox(height: 16),
InfoBanner(
open
? 'Sent automatically when you clocked out. You have 7 days to '
'raise an issue before it locks for payroll.'
: 'This timecard is ${card.status.label.toLowerCase()}.',
),
const SectionLabel('Shift'),
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
DetailRow('Date', Fmt.dayShort(card.date)),
const Divider(height: 1),
DetailRow(
'Scheduled',
Fmt.window(card.scheduledStart, card.scheduledEnd),
),
const Divider(height: 1),
DetailRow(
'Clocked',
Fmt.window(card.clockIn, card.clockOut),
valueColor: KrowPalette.blue,
),
const Divider(height: 1),
DetailRow(
'Variance',
variance == 0
? 'On time'
: '${variance > 0 ? '+' : ''}$variance min',
valueColor: variance > 5
? const Color(0xFFA6403F)
: KrowPalette.blue,
),
],
),
),
const SectionLabel('Hours and pay'),
KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
DetailRow('Base rate', '${Fmt.money(card.rate)}/hr'),
const Divider(height: 1),
DetailRow('Break, unpaid', '${card.breakMinutes} min'),
const Divider(height: 1),
DetailRow(
'Regular · ${Fmt.duration(card.worked)}',
Fmt.money(card.amount),
),
const Divider(height: 1),
DetailRow(
'Total, pre-tax',
Fmt.money(card.amount),
emphasis: true,
),
],
),
),
if (card.disputeNote != null) ...[
const SectionLabel('Your note'),
KrowCard(child: Text(card.disputeNote!)),
],
const SizedBox(height: 22),
if (open)
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => _dispute(context, ref),
child: const Text('Report an issue'),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
onPressed: () {
ref.read(timecardsProvider.notifier).approve(card.id);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Timecard approved')),
);
context.pop();
},
child: const Text('Looks right'),
),
),
],
),
],
),
);
}
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

Some files were not shown because too many files have changed in this diff Show More