Initial commit: Krow Worker App
46
.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
45
android/app/build.gradle.kts
Normal 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 = "../.."
|
||||
}
|
||||
7
android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||
45
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -0,0 +1,5 @@
|
||||
package `in`.nearle.krow.krow_worker_app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal 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>
|
||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal 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>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal 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>
|
||||
18
android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
7
android/app/src/profile/AndroidManifest.xml
Normal 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
@@ -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)
|
||||
}
|
||||
6
android/gradle.properties
Normal 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
|
||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||
26
android/settings.gradle.kts
Normal 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
@@ -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
|
||||
24
ios/Flutter/AppFrameworkInfo.plist
Normal 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>
|
||||
1
ios/Flutter/Debug.xcconfig
Normal file
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
1
ios/Flutter/Release.xcconfig
Normal file
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
644
ios/Runner.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
119
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
Normal 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 "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<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>
|
||||
7
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
16
ios/Runner/AppDelegate.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
122
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal 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.
|
||||
37
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal 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>
|
||||
26
ios/Runner/Base.lproj/Main.storyboard
Normal 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
@@ -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>
|
||||
1
ios/Runner/Runner-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
6
ios/Runner/SceneDelegate.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
|
||||
}
|
||||
12
ios/RunnerTests/RunnerTests.swift
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'core/router.dart';
|
||||
import 'core/theme.dart';
|
||||
import 'state/session_controller.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
),
|
||||
);
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [prefsProvider.overrideWithValue(prefs)],
|
||||
child: const KrowApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class KrowApp extends StatelessWidget {
|
||||
const KrowApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp.router(
|
||||
title: 'KROW Worker',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildKrowTheme(),
|
||||
scrollBehavior: const KrowScrollBehavior(),
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrolling feel, applied everywhere.
|
||||
class KrowScrollBehavior extends MaterialScrollBehavior {
|
||||
const KrowScrollBehavior();
|
||||
|
||||
/// Bouncing on every platform. The lists here sit under a pinned header, and
|
||||
/// a rubber-band edge shows you have reached the end of the content rather
|
||||
/// than the end of the screen — clamping just stops dead against the header.
|
||||
@override
|
||||
ScrollPhysics getScrollPhysics(BuildContext context) =>
|
||||
const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics());
|
||||
|
||||
/// Let the web and desktop builds be dragged with a mouse or trackpad, not
|
||||
/// only scrolled with a wheel.
|
||||
@override
|
||||
Set<PointerDeviceKind> get dragDevices => {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.trackpad,
|
||||
PointerDeviceKind.stylus,
|
||||
};
|
||||
|
||||
/// The bounce is the end-of-list signal; a glow on top of it is noise.
|
||||
@override
|
||||
Widget buildOverscrollIndicator(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
ScrollableDetails details,
|
||||
) => child;
|
||||
}
|
||||
21
lib/models/availability.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
enum DayBlock { morning, afternoon, evening }
|
||||
|
||||
extension DayBlockX on DayBlock {
|
||||
String get label => switch (this) {
|
||||
DayBlock.morning => 'Morning',
|
||||
DayBlock.afternoon => 'Afternoon',
|
||||
DayBlock.evening => 'Evening',
|
||||
};
|
||||
|
||||
String get window => switch (this) {
|
||||
DayBlock.morning => '04:00 - 12:00',
|
||||
DayBlock.afternoon => '12:00 - 18:00',
|
||||
DayBlock.evening => '18:00 - 24:00',
|
||||
};
|
||||
|
||||
int get hours => switch (this) {
|
||||
DayBlock.morning => 8,
|
||||
DayBlock.afternoon => 6,
|
||||
DayBlock.evening => 6,
|
||||
};
|
||||
}
|
||||
82
lib/models/compliance.dart
Normal file
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,57 @@
|
||||
class Worker {
|
||||
const Worker({
|
||||
required this.name,
|
||||
required this.role,
|
||||
required this.city,
|
||||
required this.phone,
|
||||
this.shiftsWorked = 0,
|
||||
this.rating = 5.0,
|
||||
this.onTimePercent = 100,
|
||||
this.noShows = 0,
|
||||
this.cancellations = 0,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String role;
|
||||
final String city;
|
||||
final String phone;
|
||||
final int shiftsWorked;
|
||||
final double rating;
|
||||
final int onTimePercent;
|
||||
final int noShows;
|
||||
final int cancellations;
|
||||
|
||||
/// Stay above 45% to keep picking up shifts. On-time arrivals move it most.
|
||||
int get trustIndex {
|
||||
final base = 40 + onTimePercent ~/ 2 - noShows * 15 - cancellations * 5;
|
||||
return base.clamp(0, 100);
|
||||
}
|
||||
|
||||
String get initials => name
|
||||
.split(' ')
|
||||
.where((p) => p.isNotEmpty)
|
||||
.map((p) => p[0].toUpperCase())
|
||||
.take(2)
|
||||
.join();
|
||||
|
||||
Worker copyWith({
|
||||
String? name,
|
||||
String? role,
|
||||
String? city,
|
||||
String? phone,
|
||||
int? shiftsWorked,
|
||||
int? onTimePercent,
|
||||
int? noShows,
|
||||
int? cancellations,
|
||||
}) => Worker(
|
||||
name: name ?? this.name,
|
||||
role: role ?? this.role,
|
||||
city: city ?? this.city,
|
||||
phone: phone ?? this.phone,
|
||||
shiftsWorked: shiftsWorked ?? this.shiftsWorked,
|
||||
rating: rating,
|
||||
onTimePercent: onTimePercent ?? this.onTimePercent,
|
||||
noShows: noShows ?? this.noShows,
|
||||
cancellations: cancellations ?? this.cancellations,
|
||||
);
|
||||
}
|
||||
650
lib/screens/auth_screens.dart
Normal file
@@ -0,0 +1,650 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../core/theme.dart';
|
||||
import '../state/session_controller.dart';
|
||||
import '../widgets/common.dart';
|
||||
|
||||
/// Step 1 of 2: the mobile number a shift manager would text.
|
||||
class PhoneScreen extends ConsumerStatefulWidget {
|
||||
const PhoneScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PhoneScreen> createState() => _PhoneScreenState();
|
||||
}
|
||||
|
||||
class _PhoneScreenState extends ConsumerState<PhoneScreen> {
|
||||
final _controller = TextEditingController();
|
||||
bool get _valid =>
|
||||
_controller.text.replaceAll(RegExp(r'\D'), '').length >= 10;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _send() {
|
||||
final phone = '+1 ${_controller.text.trim()}';
|
||||
ref.read(sessionProvider.notifier).sendCode(phone);
|
||||
context.push('/otp');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(leading: const BackButton()),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionLabel('Step 1 of 2'),
|
||||
const Text(
|
||||
"What's your\nmobile number?",
|
||||
style: TextStyle(
|
||||
fontSize: 29,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'Shift managers text you here, and we will send a 6-digit code '
|
||||
'to confirm it is yours.',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.55,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.phone,
|
||||
onChanged: (_) => setState(() {}),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[0-9 ]')),
|
||||
LengthLimitingTextInputFormatter(14),
|
||||
],
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '408 555 0148',
|
||||
prefixIcon: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 15, 10, 15),
|
||||
child: Text(
|
||||
'+1',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
prefixIconConstraints: BoxConstraints(minWidth: 0),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'Message and data rates may apply. Reply STOP any time to opt '
|
||||
'out.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1.5,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
FilledButton(
|
||||
onPressed: _valid ? _send : null,
|
||||
child: const Text('Send code'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Email sign-in is coming soon'),
|
||||
),
|
||||
),
|
||||
child: const Text('Verify with email instead'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 2 of 2: the code, with a resend countdown and a limited try budget.
|
||||
class OtpScreen extends ConsumerStatefulWidget {
|
||||
const OtpScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<OtpScreen> createState() => _OtpScreenState();
|
||||
}
|
||||
|
||||
class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
final _controller = TextEditingController();
|
||||
Timer? _timer;
|
||||
int _secondsLeft = 30;
|
||||
int _triesLeft = 3;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startCountdown();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startCountdown() {
|
||||
_timer?.cancel();
|
||||
setState(() => _secondsLeft = 30);
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
if (!mounted) return;
|
||||
if (_secondsLeft <= 1) {
|
||||
t.cancel();
|
||||
setState(() => _secondsLeft = 0);
|
||||
} else {
|
||||
setState(() => _secondsLeft--);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _verify() {
|
||||
final ok = ref.read(sessionProvider.notifier).verifyCode(_controller.text);
|
||||
if (ok) {
|
||||
context.go('/verified');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_triesLeft--;
|
||||
_error = _triesLeft > 0
|
||||
? 'That code does not match. Check the most recent text - older '
|
||||
'codes stop working. $_triesLeft tries left.'
|
||||
: 'Too many attempts. Request a new code to try again.';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final phone = ref.watch(sessionProvider).phone ?? '';
|
||||
final canVerify = _controller.text.length == 6 && _triesLeft > 0;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(leading: const BackButton()),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionLabel('Step 2 of 2'),
|
||||
const Text(
|
||||
'Enter the code',
|
||||
style: TextStyle(
|
||||
fontSize: 29,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Texted to $phone',
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('Change'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
maxLength: 6,
|
||||
onChanged: (_) => setState(() => _error = null),
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
style: const TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 14,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
hintText: '------',
|
||||
errorText: _error,
|
||||
errorMaxLines: 3,
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 30,
|
||||
letterSpacing: 14,
|
||||
color: Color(0xFFC4D4C9),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const InfoBanner('Demo build: the code is always 123456.'),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
"Didn't get the text?",
|
||||
style: TextStyle(fontSize: 13, color: KrowPalette.mute),
|
||||
),
|
||||
if (_secondsLeft > 0)
|
||||
Text(
|
||||
'Resend in 0:${_secondsLeft.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
)
|
||||
else
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_triesLeft = 3;
|
||||
_error = null;
|
||||
});
|
||||
_startCountdown();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('New code sent')),
|
||||
);
|
||||
},
|
||||
child: const Text('Resend code'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
FilledButton(
|
||||
onPressed: canVerify ? _verify : null,
|
||||
child: const Text('Verify number'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirmation, plus the two things still standing between the worker and
|
||||
/// their first shift.
|
||||
class VerifiedScreen extends ConsumerWidget {
|
||||
const VerifiedScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final phone = ref.watch(sessionProvider).phone ?? '';
|
||||
return Scaffold(
|
||||
backgroundColor: KrowPalette.blue,
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 26),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 86,
|
||||
height: 86,
|
||||
decoration: const BoxDecoration(
|
||||
color: KrowPalette.yellow,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.check,
|
||||
size: 40,
|
||||
color: KrowPalette.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
const Text(
|
||||
'Number verified',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -1,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
phone,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: KrowPalette.yellow,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Two things left before you can accept shifts.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.55,
|
||||
color: Colors.white.withValues(alpha: 0.62),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
const _TodoRow(
|
||||
title: 'Upload your ID',
|
||||
subtitle: 'State ID or passport',
|
||||
badge: '2 min',
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const _TodoRow(
|
||||
title: 'Set your availability',
|
||||
subtitle: 'So we only send shifts you can work',
|
||||
badge: '1 min',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
color: KrowPalette.sage,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 22, 20, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: () => context.go('/permissions'),
|
||||
child: const Text('Continue'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TodoRow extends StatelessWidget {
|
||||
const _TodoRow({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.badge,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String badge;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.white24),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
StatusChip(badge, tone: ChipTone.yellow),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Permission {
|
||||
const _Permission(this.icon, this.step, this.title, this.body, this.reasons);
|
||||
|
||||
final IconData icon;
|
||||
final String step;
|
||||
final String title;
|
||||
final String body;
|
||||
final List<String> reasons;
|
||||
}
|
||||
|
||||
/// The three-step permission run. Each grant is recorded in the session so the
|
||||
/// app knows what it is allowed to do later.
|
||||
class PermissionsScreen extends ConsumerStatefulWidget {
|
||||
const PermissionsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PermissionsScreen> createState() => _PermissionsScreenState();
|
||||
}
|
||||
|
||||
class _PermissionsScreenState extends ConsumerState<PermissionsScreen> {
|
||||
int _step = 0;
|
||||
|
||||
static const _permissions = [
|
||||
_Permission(
|
||||
LucideIcons.mapPin,
|
||||
'Step 1 of 3',
|
||||
'Let KROW see where you are',
|
||||
'Clock-in is locked until you are within 150 m of the venue. Without '
|
||||
'location you cannot start a shift.',
|
||||
[
|
||||
'Unlocks clock in when you arrive',
|
||||
'Sorts shifts by distance from you',
|
||||
'Only read while a shift is active',
|
||||
],
|
||||
),
|
||||
_Permission(
|
||||
LucideIcons.bell,
|
||||
'Step 2 of 3',
|
||||
'Turn on notifications',
|
||||
'Shift offers are first-come. If your phone is silent someone else takes '
|
||||
'the shift.',
|
||||
[
|
||||
'New shift offers matched to you',
|
||||
'Clock-in reminders 30 min before',
|
||||
'Pay alerts when money lands',
|
||||
],
|
||||
),
|
||||
_Permission(
|
||||
LucideIcons.camera,
|
||||
'Step 3 of 3',
|
||||
'Camera and photos',
|
||||
'Attire and document checks need a photo of the real item. Nothing is '
|
||||
'uploaded until you tap send.',
|
||||
[
|
||||
'Photograph your uniform items',
|
||||
'Capture ID and certificates',
|
||||
'Nothing is stored on our servers unsent',
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
void _advance({required bool granted}) {
|
||||
final notifier = ref.read(sessionProvider.notifier);
|
||||
if (granted) {
|
||||
switch (_step) {
|
||||
case 0:
|
||||
notifier.grantLocation();
|
||||
case 1:
|
||||
notifier.grantNotifications();
|
||||
case 2:
|
||||
notifier.grantCamera();
|
||||
}
|
||||
}
|
||||
if (_step == _permissions.length - 1) {
|
||||
context.go('/today');
|
||||
} else {
|
||||
setState(() => _step++);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = _permissions[_step];
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(22, 24, 22, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// The copy scrolls if it has to; the actions stay pinned, so a
|
||||
// short screen or a large text scale can never push them off.
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: KrowPalette.blue,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Icon(p.icon, size: 27, color: Colors.white),
|
||||
),
|
||||
SectionLabel(p.step),
|
||||
Text(
|
||||
p.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 25,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.15,
|
||||
letterSpacing: -0.9,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
p.body,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.55,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
for (final reason in p.reasons)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
// Nudged to sit on the first line's optical
|
||||
// centre, so wrapped reasons stay top-aligned.
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: KrowPalette.blueTint,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.check,
|
||||
size: 12,
|
||||
color: KrowPalette.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Text(
|
||||
reason,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.45,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () => _advance(granted: true),
|
||||
child: Text(switch (_step) {
|
||||
0 => 'Allow location',
|
||||
1 => 'Turn on notifications',
|
||||
_ => 'Allow camera',
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Full width so the secondary action shares the primary's edges
|
||||
// rather than hugging the left.
|
||||
TextButton(
|
||||
onPressed: () => _advance(granted: false),
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
),
|
||||
child: const Text('Not now'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
337
lib/screens/availability_screen.dart
Normal file
@@ -0,0 +1,337 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../core/formatters.dart';
|
||||
import '../core/theme.dart';
|
||||
import '../models/availability.dart';
|
||||
import '../state/availability_controller.dart';
|
||||
import '../widgets/common.dart';
|
||||
import '../widgets/week_strip.dart';
|
||||
|
||||
/// Auto-Match only offers shifts inside the blocks switched on here.
|
||||
class AvailabilityScreen extends ConsumerStatefulWidget {
|
||||
const AvailabilityScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AvailabilityScreen> createState() => _AvailabilityScreenState();
|
||||
}
|
||||
|
||||
class _AvailabilityScreenState extends ConsumerState<AvailabilityScreen> {
|
||||
DateTime _selected = DateTime.now();
|
||||
|
||||
List<DateTime> get _week {
|
||||
final now = DateTime.now();
|
||||
return [for (var i = 0; i < 7; i++) now.add(Duration(days: i))];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = ref.watch(availabilityProvider.notifier);
|
||||
ref.watch(availabilityProvider);
|
||||
|
||||
final blocks = controller.blocksFor(_selected);
|
||||
final hours = blocks.fold<int>(0, (sum, b) => sum + b.hours);
|
||||
final marked = <int>{
|
||||
for (var i = 0; i < _week.length; i++)
|
||||
if (controller.blocksFor(_week[i]).isNotEmpty) i,
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
body: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
BrandHeader(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
HeaderIconButton(
|
||||
icon: LucideIcons.arrowLeft,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Availability',
|
||||
style: TextStyle(
|
||||
fontSize: 25,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.9,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_Preset(
|
||||
label: 'All week',
|
||||
onTap: () =>
|
||||
controller.applyPreset(_week, test: (_) => true),
|
||||
),
|
||||
_Preset(
|
||||
label: 'Weekdays',
|
||||
onTap: () => controller.applyPreset(
|
||||
_week,
|
||||
test: (d) => d.weekday <= DateTime.friday,
|
||||
),
|
||||
),
|
||||
_Preset(
|
||||
label: 'Weekends',
|
||||
onTap: () => controller.applyPreset(
|
||||
_week,
|
||||
test: (d) => d.weekday >= DateTime.saturday,
|
||||
),
|
||||
),
|
||||
_Preset(
|
||||
label: 'Clear',
|
||||
accent: true,
|
||||
onTap: () => controller.clearWeek(_week),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
WeekStrip(
|
||||
days: _week,
|
||||
selected: _selected,
|
||||
onSelect: (d) => setState(() => _selected = d),
|
||||
marked: marked,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
KrowCard(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
Fmt.dayShort(_selected),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
blocks.isEmpty
|
||||
? 'Marked as a day off'
|
||||
: 'Available · $hours hrs',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: blocks.isEmpty
|
||||
? KrowPalette.mute
|
||||
: KrowPalette.blue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: blocks.isNotEmpty,
|
||||
onChanged: (v) =>
|
||||
controller.setDayAvailable(_selected, v),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
for (final block in DayBlock.values)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 9),
|
||||
child: _BlockRow(
|
||||
block: block,
|
||||
on: blocks.contains(block),
|
||||
onTap: () =>
|
||||
controller.toggleBlock(_selected, block),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
InfoBanner(
|
||||
blocks.isEmpty
|
||||
? 'With every block off, Auto-Match will not send you '
|
||||
'anything on this day.'
|
||||
: 'Saved. Auto-Match will only offer you shifts inside '
|
||||
'these blocks.',
|
||||
icon: blocks.isEmpty ? null : LucideIcons.check,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Preset extends StatelessWidget {
|
||||
const _Preset({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.accent = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool accent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Built by hand rather than as a Chip: the app's ChipTheme paints a white
|
||||
// fill, which would hide white-on-blue header text.
|
||||
return Material(
|
||||
color: Colors.white.withValues(alpha: accent ? 0.06 : 0.16),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: accent ? KrowPalette.yellow : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlockRow extends StatelessWidget {
|
||||
const _BlockRow({required this.block, required this.on, required this.onTap});
|
||||
|
||||
final DayBlock block;
|
||||
final bool on;
|
||||
final VoidCallback onTap;
|
||||
|
||||
IconData get _icon => switch (block) {
|
||||
DayBlock.morning => LucideIcons.sunrise,
|
||||
DayBlock.afternoon => LucideIcons.sun,
|
||||
DayBlock.evening => LucideIcons.moon,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const duration = Duration(milliseconds: 260);
|
||||
const curve = Curves.easeOutCubic;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
// Every part of the row animates together, so switching a block on is a
|
||||
// single motion rather than four things changing at once.
|
||||
child: AnimatedContainer(
|
||||
duration: duration,
|
||||
curve: curve,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: on ? KrowPalette.blueTint : const Color(0xFFE5F0E8),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: on
|
||||
? KrowPalette.blue.withValues(alpha: 0.3)
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: duration,
|
||||
curve: curve,
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: on ? KrowPalette.blue : Colors.white,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
border: Border.all(
|
||||
color: on ? KrowPalette.blue : KrowPalette.line,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
_icon,
|
||||
size: 18,
|
||||
color: on ? Colors.white : const Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AnimatedDefaultTextStyle(
|
||||
duration: duration,
|
||||
curve: curve,
|
||||
style: TextStyle(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: on ? KrowPalette.ink : KrowPalette.mute,
|
||||
),
|
||||
child: Text(block.label),
|
||||
),
|
||||
AnimatedDefaultTextStyle(
|
||||
duration: duration,
|
||||
curve: curve,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: on ? KrowPalette.blue : const Color(0xFF949FA5),
|
||||
),
|
||||
child: Text(block.window),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: duration,
|
||||
curve: curve,
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: on ? KrowPalette.blue : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
border: Border.all(
|
||||
color: on ? KrowPalette.blue : const Color(0xFFC4D4C9),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: AnimatedScale(
|
||||
scale: on ? 1 : 0,
|
||||
duration: duration,
|
||||
curve: Curves.easeOutBack,
|
||||
child: const Icon(
|
||||
LucideIcons.check,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
397
lib/screens/clock_screen.dart
Normal file
@@ -0,0 +1,397 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../core/formatters.dart';
|
||||
import '../core/theme.dart';
|
||||
import '../models/shift.dart';
|
||||
import '../state/session_controller.dart';
|
||||
import '../state/shifts_controller.dart';
|
||||
import '../widgets/collapsing_header.dart';
|
||||
import '../widgets/common.dart';
|
||||
import '../widgets/shift_card.dart';
|
||||
|
||||
/// Clock in and out. The button unlocks 30 minutes before the shift starts,
|
||||
/// and only once the worker is at the venue.
|
||||
class ClockScreen extends ConsumerStatefulWidget {
|
||||
const ClockScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ClockScreen> createState() => _ClockScreenState();
|
||||
}
|
||||
|
||||
class _ClockScreenState extends ConsumerState<ClockScreen> {
|
||||
Timer? _ticker;
|
||||
|
||||
/// Stands in for the geofence check while there is no GPS plugin wired up.
|
||||
bool _atVenue = false;
|
||||
|
||||
/// Last minute we repainted for. Off shift, only the countdown moves, so
|
||||
/// rebuilding every second would just churn the tree — and a rebuild landing
|
||||
/// between a tap's down and up swallows it.
|
||||
int _lastMinute = -1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Drives the countdown and the running stopwatch.
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
final running = ref.read(activeShiftProvider) != null;
|
||||
final minute = DateTime.now().minute;
|
||||
if (running || minute != _lastMinute) {
|
||||
_lastMinute = minute;
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = ref.watch(activeShiftProvider);
|
||||
final next = ref.watch(nextShiftProvider);
|
||||
final shift = active ?? next;
|
||||
final locationGranted = ref.watch(sessionProvider).locationGranted;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Already compact, so it pins at one height rather than collapsing.
|
||||
// Sized with headroom over the 58px of content, so a larger system
|
||||
// text scale does not push it over.
|
||||
KrowSliverHeader(
|
||||
expandedHeight: 96,
|
||||
collapsedHeight: 96,
|
||||
builder: (context, t) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
Fmt.dayFull(DateTime.now()).toUpperCase(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Clock in',
|
||||
style: TextStyle(
|
||||
fontSize: 27,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.9,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
HeaderIconButton(
|
||||
icon: LucideIcons.fileText,
|
||||
tooltip: 'Timecards',
|
||||
onPressed: () => context.push('/timecards'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (shift == null)
|
||||
const SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(20, 40, 20, 20),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: EmptyState(
|
||||
icon: LucideIcons.clock,
|
||||
title: 'No shift to clock into',
|
||||
message:
|
||||
'Book a shift and the clock unlocks 30 minutes before '
|
||||
'it starts.',
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 24),
|
||||
sliver: SliverList.list(
|
||||
children: [
|
||||
_ClockDial(shift: shift, atVenue: _atVenue),
|
||||
const SizedBox(height: 22),
|
||||
if (shift.status == ShiftStatus.active)
|
||||
FilledButton.icon(
|
||||
onPressed: () => _clockOut(shift),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: KrowPalette.ink,
|
||||
),
|
||||
icon: const Icon(LucideIcons.logOut, size: 18),
|
||||
label: const Text('Clock out'),
|
||||
)
|
||||
else
|
||||
FilledButton.icon(
|
||||
onPressed: _canClockIn(shift, locationGranted)
|
||||
? () => _clockIn(shift)
|
||||
: null,
|
||||
icon: const Icon(LucideIcons.logIn, size: 18),
|
||||
label: Text(
|
||||
_canClockIn(shift, locationGranted)
|
||||
? 'Clock in'
|
||||
: 'Clock in · ${Fmt.untilStart(shift.start)}',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_GeofenceCard(
|
||||
shift: shift,
|
||||
atVenue: _atVenue,
|
||||
locationGranted: locationGranted,
|
||||
onChanged: (v) => setState(() => _atVenue = v),
|
||||
),
|
||||
const SectionLabel("Today's shift"),
|
||||
ShiftCard(
|
||||
shift: shift,
|
||||
onTap: () => context.push('/shift/${shift.id}'),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.push('/timecards'),
|
||||
icon: const Icon(LucideIcons.fileText, size: 18),
|
||||
label: const Text('View timecards'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _canClockIn(Shift shift, bool locationGranted) {
|
||||
if (shift.status != ShiftStatus.booked) return false;
|
||||
if (!_atVenue) return false;
|
||||
final minutesToStart = shift.start.difference(DateTime.now()).inMinutes;
|
||||
return minutesToStart <= 30;
|
||||
}
|
||||
|
||||
void _clockIn(Shift shift) {
|
||||
// Starting and ending a shift are the two moments that matter most in the
|
||||
// app; both get a firm confirmation you can feel without looking.
|
||||
HapticFeedback.mediumImpact();
|
||||
ref.read(shiftsProvider.notifier).clockIn(shift.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Clocked in at ${Fmt.time(DateTime.now())}')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _clockOut(Shift shift) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clock out?'),
|
||||
content: Text(
|
||||
'Your timecard is filed automatically and sent to '
|
||||
'${shift.business} for approval.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Not yet'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
|
||||
child: const Text('Clock out'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm != true || !mounted) return;
|
||||
|
||||
HapticFeedback.mediumImpact();
|
||||
final card = ref.read(shiftsProvider.notifier).clockOut(shift.id);
|
||||
if (card == null || !mounted) return;
|
||||
setState(() => _atVenue = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Timecard filed · ${Fmt.money(card.amount)}'),
|
||||
action: SnackBarAction(
|
||||
label: 'Review',
|
||||
textColor: KrowPalette.yellow,
|
||||
onPressed: () => context.push('/timecard/${card.id}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The big circular status dial: a countdown before the shift, a running
|
||||
/// stopwatch during it.
|
||||
class _ClockDial extends StatelessWidget {
|
||||
const _ClockDial({required this.shift, required this.atVenue});
|
||||
|
||||
final Shift shift;
|
||||
final bool atVenue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final running = shift.status == ShiftStatus.active;
|
||||
final elapsed = running
|
||||
? DateTime.now().difference(shift.clockIn!)
|
||||
: Duration.zero;
|
||||
final progress = running
|
||||
? (elapsed.inSeconds / shift.end.difference(shift.start).inSeconds)
|
||||
.clamp(0.0, 1.0)
|
||||
: 0.0;
|
||||
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 208,
|
||||
height: 208,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// The ring fills from zero when the shift starts, and the track
|
||||
// warms from sage to yellow as it does.
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: running ? progress : 0),
|
||||
duration: const Duration(milliseconds: 700),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, value, _) => SizedBox(
|
||||
width: 208,
|
||||
height: 208,
|
||||
child: CircularProgressIndicator(
|
||||
value: running ? value : 1,
|
||||
strokeWidth: 12,
|
||||
strokeCap: StrokeCap.round,
|
||||
backgroundColor: const Color(0xFFDEEBE2),
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
running ? KrowPalette.yellow : const Color(0xFFDEEBE2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedScale(
|
||||
// A small swell on clock-in: the dial acknowledges the tap.
|
||||
scale: running ? 1 : 0.96,
|
||||
duration: const Duration(milliseconds: 420),
|
||||
curve: Curves.easeOutBack,
|
||||
child: Container(
|
||||
width: 176,
|
||||
height: 176,
|
||||
decoration: const BoxDecoration(
|
||||
color: KrowPalette.blue,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
// Only the label swaps; the ticking digits must not
|
||||
// re-animate every second.
|
||||
child: Text(
|
||||
running ? Fmt.stopwatch(elapsed) : 'Clock in',
|
||||
key: ValueKey(running),
|
||||
style: TextStyle(
|
||||
fontSize: running ? 27 : 23,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.6,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
running
|
||||
? 'ON SHIFT SINCE ${Fmt.time(shift.clockIn!)}'
|
||||
: Fmt.untilStart(shift.start).toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
letterSpacing: 1.3,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: KrowPalette.yellow,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows why the clock is locked, and lets the demo simulate arriving on site.
|
||||
class _GeofenceCard extends StatelessWidget {
|
||||
const _GeofenceCard({
|
||||
required this.shift,
|
||||
required this.atVenue,
|
||||
required this.locationGranted,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final Shift shift;
|
||||
final bool atVenue;
|
||||
final bool locationGranted;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (shift.status == ShiftStatus.active) {
|
||||
return InfoBanner(
|
||||
'Break of ${shift.breakMinutes} minutes is deducted automatically when '
|
||||
'you clock out.',
|
||||
);
|
||||
}
|
||||
return KrowCard(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.mapPin, size: 18, color: KrowPalette.blue),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
atVenue
|
||||
? 'At the venue'
|
||||
: 'You are ${shift.distanceMiles} mi away',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
locationGranted
|
||||
? 'Get within 150 m to unlock the button'
|
||||
: 'Location is off - turn it on in Settings',
|
||||
style: const TextStyle(fontSize: 12, color: KrowPalette.mute),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(value: atVenue, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
565
lib/screens/compliance_screens.dart
Normal file
@@ -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
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../state/timecards_controller.dart';
|
||||
import '../widgets/krow_nav_bar.dart';
|
||||
|
||||
/// The five primary tabs. Each keeps its own navigation stack.
|
||||
class HomeShell extends ConsumerWidget {
|
||||
const HomeShell({super.key, required this.navigationShell});
|
||||
|
||||
final StatefulNavigationShell navigationShell;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final needsReview = ref.watch(timecardsNeedingReviewProvider).isNotEmpty;
|
||||
|
||||
return Scaffold(
|
||||
body: _TabTransition(
|
||||
index: navigationShell.currentIndex,
|
||||
child: navigationShell,
|
||||
),
|
||||
bottomNavigationBar: KrowNavBar(
|
||||
currentIndex: navigationShell.currentIndex,
|
||||
onSelect: (i) => navigationShell.goBranch(
|
||||
i,
|
||||
initialLocation: i == navigationShell.currentIndex,
|
||||
),
|
||||
items: [
|
||||
const KrowNavItem(icon: LucideIcons.house, label: 'Today'),
|
||||
const KrowNavItem(icon: LucideIcons.briefcase, label: 'Shifts'),
|
||||
KrowNavItem(
|
||||
icon: LucideIcons.clock,
|
||||
label: 'Clock',
|
||||
showDot: needsReview,
|
||||
),
|
||||
const KrowNavItem(icon: LucideIcons.wallet, label: 'Pay'),
|
||||
const KrowNavItem(icon: LucideIcons.user, label: 'Profile'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fades and lifts the tab content each time the tab changes.
|
||||
///
|
||||
/// Deliberately not an AnimatedSwitcher: that would hold two copies of the
|
||||
/// shell in the tree at once, and the branch navigators are keyed by
|
||||
/// GlobalKey, so duplicating them would throw. This keeps one subtree — the
|
||||
/// branches stay alive and keep their scroll positions — and animates it in
|
||||
/// place when the index changes.
|
||||
class _TabTransition extends StatefulWidget {
|
||||
const _TabTransition({required this.index, required this.child});
|
||||
|
||||
final int index;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_TabTransition> createState() => _TabTransitionState();
|
||||
}
|
||||
|
||||
class _TabTransitionState extends State<_TabTransition>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
value: 1,
|
||||
);
|
||||
|
||||
late final Animation<double> _curve = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_TabTransition oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.index != widget.index) _controller.forward(from: 0);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _curve,
|
||||
// The shell is passed through untouched so it is never rebuilt.
|
||||
child: widget.child,
|
||||
builder: (context, child) {
|
||||
final t = _curve.value;
|
||||
return Opacity(
|
||||
// Never fully transparent: a tab that blinks out reads as a glitch.
|
||||
opacity: 0.35 + 0.65 * t,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, (1 - t) * 10),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
448
lib/screens/onboarding_screens.dart
Normal file
@@ -0,0 +1,448 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../core/theme.dart';
|
||||
import '../state/session_controller.dart';
|
||||
|
||||
/// Brand wordmark. Drawn rather than shipped as an asset so it tints cleanly
|
||||
/// on either ground.
|
||||
class KrowWordmark extends StatelessWidget {
|
||||
const KrowWordmark({super.key, this.size = 34, this.color});
|
||||
|
||||
final double size;
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
'KROW',
|
||||
style: TextStyle(
|
||||
fontSize: size,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: size * 0.06,
|
||||
color: color ?? KrowPalette.yellow,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Splash. Routes on to onboarding, sign-in or the app depending on how far
|
||||
/// the worker got last time.
|
||||
class SplashScreen extends ConsumerStatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends ConsumerState<SplashScreen> {
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer(const Duration(milliseconds: 1400), _go);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _go() {
|
||||
if (!mounted) return;
|
||||
final s = ref.read(sessionProvider);
|
||||
if (!s.onboardingSeen) {
|
||||
context.go('/onboarding');
|
||||
} else if (!s.signedIn) {
|
||||
context.go('/welcome');
|
||||
} else if (!s.permissionsDone) {
|
||||
context.go('/permissions');
|
||||
} else {
|
||||
context.go('/today');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: KrowPalette.blue,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const KrowWordmark(size: 46),
|
||||
const SizedBox(height: 22),
|
||||
Text(
|
||||
'WORK · HIRE · ELEVATE',
|
||||
style: TextStyle(
|
||||
fontSize: 10.5,
|
||||
letterSpacing: 3,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 44),
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 3,
|
||||
backgroundColor: Colors.white24,
|
||||
valueColor: const AlwaysStoppedAnimation(KrowPalette.yellow),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Slide {
|
||||
const _Slide(this.eyebrow, this.title, this.body, this.icon);
|
||||
|
||||
final String eyebrow;
|
||||
final String title;
|
||||
final String body;
|
||||
final IconData icon;
|
||||
}
|
||||
|
||||
const _slides = [
|
||||
_Slide(
|
||||
'Find work',
|
||||
'Shifts that fit around your week.',
|
||||
'Browse open hospitality shifts near you, filtered to the hours you said '
|
||||
'you can work.',
|
||||
LucideIcons.search,
|
||||
),
|
||||
_Slide(
|
||||
'Know your pay',
|
||||
'See the money before you say yes.',
|
||||
'Every shift shows the hourly rate and the total. No surprises on the '
|
||||
'payslip.',
|
||||
LucideIcons.banknote,
|
||||
),
|
||||
_Slide(
|
||||
'Get paid',
|
||||
'Clock in on site. Paid on schedule.',
|
||||
'Tap in when you arrive, tap out when you leave. Timecards submit '
|
||||
'themselves.',
|
||||
LucideIcons.clock,
|
||||
),
|
||||
];
|
||||
|
||||
class OnboardingScreen extends ConsumerStatefulWidget {
|
||||
const OnboardingScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
|
||||
}
|
||||
|
||||
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||
final _controller = PageController();
|
||||
int _page = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _finish() {
|
||||
ref.read(sessionProvider.notifier).completeOnboarding();
|
||||
context.go('/welcome');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLast = _page == _slides.length - 1;
|
||||
return Scaffold(
|
||||
backgroundColor: KrowPalette.blue,
|
||||
body: Column(
|
||||
children: [
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(22, 8, 12, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const KrowWordmark(size: 20),
|
||||
TextButton(
|
||||
onPressed: _finish,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white70,
|
||||
),
|
||||
child: const Text('Skip'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: _slides.length,
|
||||
onPageChanged: (i) => setState(() => _page = i),
|
||||
itemBuilder: (context, i) {
|
||||
final slide = _slides[i];
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 190,
|
||||
height: 190,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
slide.icon,
|
||||
size: 76,
|
||||
color: KrowPalette.yellow,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
color: KrowPalette.sage,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 26, 24, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_slides[_page].eyebrow.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 1.6,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
_slides[_page].title,
|
||||
style: const TextStyle(
|
||||
fontSize: 29,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_slides[_page].body,
|
||||
style: const TextStyle(
|
||||
fontSize: 14.5,
|
||||
height: 1.55,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (var i = 0; i < _slides.length; i++)
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
width: i == _page ? 24 : 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: i == _page
|
||||
? KrowPalette.blue
|
||||
: const Color(0xFFC6D6CC),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (isLast) {
|
||||
_finish();
|
||||
} else {
|
||||
_controller.nextPage(
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(isLast ? 'Create your account' : 'Next'),
|
||||
),
|
||||
if (isLast)
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: _finish,
|
||||
child: const Text('I already have an account'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WelcomeScreen extends StatelessWidget {
|
||||
const WelcomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: KrowPalette.blue,
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 30),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const KrowWordmark(size: 40),
|
||||
const SizedBox(height: 34),
|
||||
const Text(
|
||||
'Work the shifts\nyou actually want.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
height: 1.1,
|
||||
letterSpacing: -1.1,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Hospitality work in your city, priced up front and paid '
|
||||
'on a fixed schedule.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14.5,
|
||||
height: 1.55,
|
||||
color: Colors.white.withValues(alpha: 0.64),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 34),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_WelcomeStat('4,200', 'Shifts this month'),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 38,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 26),
|
||||
color: Colors.white24,
|
||||
),
|
||||
_WelcomeStat(r'$21+', 'Average rate'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
color: KrowPalette.sage,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(22, 24, 22, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: () => context.push('/phone'),
|
||||
child: const Text('Continue with mobile'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton(
|
||||
onPressed: () =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Invite codes open in the next release',
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('I have an invite code'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'By continuing you accept the Worker Terms.',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WelcomeStat extends StatelessWidget {
|
||||
const _WelcomeStat(this.value, this.caption);
|
||||
|
||||
final String value;
|
||||
final String caption;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.8,
|
||||
color: KrowPalette.yellow,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
caption.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
620
lib/screens/pay_screens.dart
Normal file
@@ -0,0 +1,620 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../core/formatters.dart';
|
||||
import '../core/theme.dart';
|
||||
import '../models/shift.dart';
|
||||
import '../state/profile_controller.dart';
|
||||
import '../state/shifts_controller.dart';
|
||||
import '../widgets/collapsing_header.dart';
|
||||
import '../widgets/common.dart';
|
||||
import '../widgets/glass_segments.dart';
|
||||
|
||||
enum PayRange { week, month, year, all }
|
||||
|
||||
extension on PayRange {
|
||||
String get label => switch (this) {
|
||||
PayRange.week => 'Week',
|
||||
PayRange.month => 'Month',
|
||||
PayRange.year => 'Year',
|
||||
PayRange.all => 'All',
|
||||
};
|
||||
|
||||
Duration get window => switch (this) {
|
||||
PayRange.week => const Duration(days: 7),
|
||||
PayRange.month => const Duration(days: 30),
|
||||
PayRange.year => const Duration(days: 365),
|
||||
PayRange.all => const Duration(days: 3650),
|
||||
};
|
||||
}
|
||||
|
||||
class PayScreen extends ConsumerStatefulWidget {
|
||||
const PayScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PayScreen> createState() => _PayScreenState();
|
||||
}
|
||||
|
||||
class _PayScreenState extends ConsumerState<PayScreen> {
|
||||
PayRange _range = PayRange.month;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shifts = ref.watch(shiftsProvider);
|
||||
final cutoff = DateTime.now().subtract(_range.window);
|
||||
|
||||
final completed = shifts
|
||||
.where(
|
||||
(s) => s.status == ShiftStatus.completed && s.start.isAfter(cutoff),
|
||||
)
|
||||
.toList();
|
||||
final pendingShifts = ref.watch(bookedShiftsProvider);
|
||||
|
||||
final paid = completed.fold<double>(0, (sum, s) => sum + s.pay);
|
||||
final pending = pendingShifts.fold<double>(
|
||||
0,
|
||||
(sum, s) => sum + s.scheduledPay,
|
||||
);
|
||||
|
||||
final segments = GlassSegments<PayRange>(
|
||||
segments: [for (final r in PayRange.values) (r, r.label)],
|
||||
value: _range,
|
||||
onChanged: (r) => setState(() => _range = r),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
KrowSliverHeader(
|
||||
expandedHeight: 168,
|
||||
// Deep enough to keep the range switcher: collapsing it away would
|
||||
// mean scrolling back to the top to change what you are looking at.
|
||||
collapsedHeight: 104,
|
||||
builder: (context, t) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: HeaderCrossFade(
|
||||
t: t,
|
||||
expandedHeight: 84,
|
||||
expanded: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'ESTIMATED GROSS PAY · PRE-TAX',
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
Fmt.money(paid + pending),
|
||||
style: const TextStyle(
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -1.8,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
collapsed: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'${Fmt.money(paid + pending)} · pre-tax',
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.6,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
segments,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
|
||||
sliver: SliverList.list(
|
||||
children: [
|
||||
KrowCard(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Flexible(
|
||||
child: Text(
|
||||
'EARNINGS TREND',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 1.4,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_range.label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
MiniBarChart(bars: _buildBars(shifts)),
|
||||
const SizedBox(height: 14),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
const Row(
|
||||
children: [
|
||||
LegendDot(KrowPalette.blue, 'Paid'),
|
||||
SizedBox(width: 16),
|
||||
LegendDot(KrowPalette.yellow, 'Pending'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: KrowCard(
|
||||
padding: const EdgeInsets.all(15),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const StatusChip('Pending', tone: ChipTone.yellow),
|
||||
const SizedBox(height: 11),
|
||||
Text(
|
||||
Fmt.money(pending),
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.9,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: KrowCard(
|
||||
padding: const EdgeInsets.all(15),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const StatusChip('Paid', tone: ChipTone.neutral),
|
||||
const SizedBox(height: 11),
|
||||
Text(
|
||||
Fmt.money(paid),
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.9,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TileGroup(
|
||||
children: [
|
||||
NavTile(
|
||||
icon: LucideIcons.calendarDays,
|
||||
title: 'Payroll schedule',
|
||||
subtitle: 'Semi-monthly, 11 days after each period',
|
||||
onTap: () => context.push('/payroll'),
|
||||
),
|
||||
NavTile(
|
||||
icon: LucideIcons.landmark,
|
||||
title: 'Direct deposit',
|
||||
subtitle: 'Where your pay lands',
|
||||
badge: ref.watch(profileProvider).bank == null
|
||||
? const StatusChip('Add', tone: ChipTone.yellow)
|
||||
: const StatusChip('Set', tone: ChipTone.neutral),
|
||||
onTap: () => context.push('/direct-deposit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SectionLabel('Payments'),
|
||||
if (completed.isEmpty)
|
||||
const EmptyState(
|
||||
icon: LucideIcons.banknote,
|
||||
title: 'Nothing paid yet',
|
||||
message: 'Finish a shift and the payment shows up here.',
|
||||
)
|
||||
else
|
||||
for (final shift in completed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _PaymentRow(shift: shift),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const InfoBanner(
|
||||
'Amounts are pre-tax. Your take-home differs after tax, '
|
||||
'deductions and withholding from your payroll provider.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Four buckets across the selected range, split paid vs still pending.
|
||||
List<(String, double, bool)> _buildBars(List<Shift> shifts) {
|
||||
final now = DateTime.now();
|
||||
final span = _range.window.inDays;
|
||||
final bucket = (span / 4).ceil();
|
||||
final labels = DateFormat('d MMM');
|
||||
|
||||
return [
|
||||
for (var i = 3; i >= 0; i--)
|
||||
() {
|
||||
final end = now.subtract(Duration(days: bucket * i));
|
||||
final start = end.subtract(Duration(days: bucket));
|
||||
final inBucket = shifts.where(
|
||||
(s) => s.start.isAfter(start) && !s.start.isAfter(end),
|
||||
);
|
||||
final value = inBucket.fold<double>(0, (sum, s) => sum + s.pay);
|
||||
final anyPaid = inBucket.any(
|
||||
(s) => s.status == ShiftStatus.completed,
|
||||
);
|
||||
return (labels.format(end), value, anyPaid);
|
||||
}(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class _PaymentRow extends StatelessWidget {
|
||||
const _PaymentRow({required this.shift});
|
||||
|
||||
final Shift shift;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return KrowCard(
|
||||
onTap: () => context.push('/payment/${shift.id}'),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
BusinessBadge(shift.logoLetter, size: 42),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${shift.role} · ${shift.business}',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${Fmt.dayShort(shift.start)} · '
|
||||
'${Fmt.duration(shift.workedDuration ?? shift.scheduledDuration)}',
|
||||
style: const TextStyle(fontSize: 12, color: KrowPalette.mute),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
Fmt.money(shift.pay),
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Semi-monthly periods: each closes on the 15th and the month end, and pays
|
||||
/// 11 days later.
|
||||
class PayrollScheduleScreen extends ConsumerWidget {
|
||||
const PayrollScheduleScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final now = DateTime.now();
|
||||
final inFirstHalf = now.day <= 15;
|
||||
final periodStart = DateTime(now.year, now.month, inFirstHalf ? 1 : 16);
|
||||
final periodEnd = inFirstHalf
|
||||
? DateTime(now.year, now.month, 15)
|
||||
: DateTime(now.year, now.month + 1, 0);
|
||||
final payDate = periodEnd.add(const Duration(days: 11));
|
||||
|
||||
final nextStart = periodEnd.add(const Duration(days: 1));
|
||||
final nextEnd = inFirstHalf
|
||||
? DateTime(now.year, now.month + 1, 0)
|
||||
: DateTime(now.year, now.month + 1, 15);
|
||||
|
||||
final elapsed = now.difference(periodStart).inDays + 1;
|
||||
final total = periodEnd.difference(periodStart).inDays + 1;
|
||||
final earned = ref.watch(completedEarningsProvider);
|
||||
final d = DateFormat('d MMM yyyy');
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Payroll schedule'),
|
||||
actions: const [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Center(
|
||||
child: StatusChip('Semi-monthly', tone: ChipTone.yellow),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
||||
children: [
|
||||
const Text(
|
||||
'Two pay dates a month, 11 days after each period closes.',
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
height: 1.5,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
const SectionLabel('Current period'),
|
||||
KrowCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${d.format(periodStart)} - ${d.format(periodEnd)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const StatusChip('Open', tone: ChipTone.yellow),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Pay date ${d.format(payDate)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 13),
|
||||
KrowProgressBar(elapsed / total),
|
||||
const SizedBox(height: 9),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'$elapsed of $total days elapsed',
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
Fmt.money(earned),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SectionLabel('Next period'),
|
||||
KrowCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${d.format(nextStart)} - ${d.format(nextEnd)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const StatusChip('Not started', tone: ChipTone.muted),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Pay date '
|
||||
'${d.format(nextEnd.add(const Duration(days: 11)))}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 13),
|
||||
const KrowProgressBar(0),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const InfoBanner(
|
||||
'Payments generate after the shift scheduled end time, not the '
|
||||
'moment you clock out.',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PaymentDetailScreen extends ConsumerWidget {
|
||||
const PaymentDetailScreen({super.key, required this.shiftId});
|
||||
|
||||
final String shiftId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final shift = ref.watch(shiftsProvider.notifier).byId(shiftId);
|
||||
if (shift == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: const Center(child: Text('Payment not found.')),
|
||||
);
|
||||
}
|
||||
final worked = shift.workedDuration ?? shift.scheduledDuration;
|
||||
|
||||
return Scaffold(
|
||||
body: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
BrandHeader(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
HeaderIconButton(
|
||||
icon: LucideIcons.arrowLeft,
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
const StatusChip('Pending', tone: ChipTone.yellow),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'ESTIMATED GROSS · PRE-TAX',
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
Fmt.money(shift.pay),
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -1.6,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
Fmt.dayShort(shift.start),
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: Colors.white.withValues(alpha: 0.62),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
StatSlabRow(
|
||||
slabs: [
|
||||
StatSlab(
|
||||
'Clock in',
|
||||
shift.clockIn == null ? '-' : Fmt.time(shift.clockIn!),
|
||||
),
|
||||
StatSlab(
|
||||
'Clock out',
|
||||
shift.clockOut == null ? '-' : Fmt.time(shift.clockOut!),
|
||||
),
|
||||
StatSlab('Hours', Fmt.duration(worked)),
|
||||
],
|
||||
),
|
||||
const SectionLabel('Pay breakdown'),
|
||||
KrowCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
DetailRow('Hourly rate', '${Fmt.money(shift.rate)}/hr'),
|
||||
const Divider(height: 1),
|
||||
DetailRow(
|
||||
'Regular · ${Fmt.duration(worked)}',
|
||||
Fmt.money(shift.pay),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
DetailRow('Break, unpaid', '${shift.breakMinutes} min'),
|
||||
const Divider(height: 1),
|
||||
DetailRow(
|
||||
'Total, pre-tax',
|
||||
Fmt.money(shift.pay),
|
||||
emphasis: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SectionLabel('Where'),
|
||||
KrowCard(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.mapPin,
|
||||
size: 18,
|
||||
color: KrowPalette.blue,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(shift.address)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
736
lib/screens/profile_detail_screens.dart
Normal file
@@ -0,0 +1,736 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../core/theme.dart';
|
||||
import '../models/compliance.dart';
|
||||
import '../state/profile_controller.dart';
|
||||
import '../state/seed.dart';
|
||||
import '../widgets/common.dart';
|
||||
|
||||
/// Only called if something happens during a shift. Managers never see these.
|
||||
class EmergencyContactsScreen extends ConsumerWidget {
|
||||
const EmergencyContactsScreen({super.key});
|
||||
|
||||
Future<void> _edit(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
EmergencyContact? existing,
|
||||
int? index,
|
||||
}) async {
|
||||
final name = TextEditingController(text: existing?.name ?? '');
|
||||
final phone = TextEditingController(text: existing?.phone ?? '');
|
||||
var relationship = existing?.relationship ?? 'Spouse';
|
||||
|
||||
const options = ['Spouse', 'Parent', 'Sibling', 'Child', 'Friend', 'Other'];
|
||||
|
||||
final saved = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||
),
|
||||
child: StatefulBuilder(
|
||||
builder: (context, setSheetState) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
existing == null ? 'Add a contact' : 'Edit contact',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: name,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Full name',
|
||||
prefixIcon: Icon(LucideIcons.user, size: 18),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: phone,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[0-9 +]')),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Mobile',
|
||||
prefixIcon: Icon(LucideIcons.phone, size: 18),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: relationship,
|
||||
decoration: const InputDecoration(labelText: 'Relationship'),
|
||||
items: [
|
||||
for (final o in options)
|
||||
DropdownMenuItem(value: o, child: Text(o)),
|
||||
],
|
||||
onChanged: (v) =>
|
||||
setSheetState(() => relationship = v ?? relationship),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Save contact'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (saved != true) return;
|
||||
if (name.text.trim().isEmpty || phone.text.trim().isEmpty) return;
|
||||
ref
|
||||
.read(profileProvider.notifier)
|
||||
.saveContact(
|
||||
EmergencyContact(
|
||||
name: name.text.trim(),
|
||||
phone: phone.text.trim(),
|
||||
relationship: relationship,
|
||||
),
|
||||
index: index,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final contacts = ref.watch(profileProvider).contacts;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Emergency contacts')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
||||
children: [
|
||||
const Text(
|
||||
'We only call these people if something happens to you during a '
|
||||
'shift. Managers never see them.',
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
height: 1.55,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
const SectionLabel('Your contacts'),
|
||||
if (contacts.isEmpty)
|
||||
const EmptyState(
|
||||
icon: LucideIcons.phone,
|
||||
title: 'No contact yet',
|
||||
message: 'Add at least one before your first shift.',
|
||||
)
|
||||
else
|
||||
for (var i = 0; i < contacts.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: KrowCard(
|
||||
onTap: () =>
|
||||
_edit(context, ref, existing: contacts[i], index: i),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: KrowPalette.blueTint,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.user,
|
||||
size: 19,
|
||||
color: KrowPalette.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
contacts[i].name,
|
||||
style: const TextStyle(
|
||||
fontSize: 15.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${contacts[i].relationship} · '
|
||||
'${contacts[i].phone}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () =>
|
||||
ref.read(profileProvider.notifier).removeContact(i),
|
||||
icon: const Icon(LucideIcons.trash2, size: 18),
|
||||
color: const Color(0xFF949FA5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _edit(context, ref),
|
||||
icon: const Icon(LucideIcons.plus, size: 18),
|
||||
label: const Text('Add a contact'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// More picks mean more shifts offered.
|
||||
class SkillsScreen extends ConsumerStatefulWidget {
|
||||
const SkillsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SkillsScreen> createState() => _SkillsScreenState();
|
||||
}
|
||||
|
||||
class _SkillsScreenState extends ConsumerState<SkillsScreen> {
|
||||
final _custom = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_custom.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final profile = ref.watch(profileProvider);
|
||||
final controller = ref.read(profileProvider.notifier);
|
||||
final extras = profile.skills.where((s) => !Seed.skills.contains(s));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Experience & skills'),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Center(
|
||||
child: StatusChip(
|
||||
'${profile.settings.length + profile.skills.length} selected',
|
||||
tone: ChipTone.yellow,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
||||
children: [
|
||||
const Text(
|
||||
"Where you've worked",
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Pick every setting you have worked in. More picks mean more '
|
||||
'shifts offered.',
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
height: 1.55,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Wrap(
|
||||
spacing: 7,
|
||||
runSpacing: 7,
|
||||
children: [
|
||||
for (final s in Seed.workSettings)
|
||||
_Tag(
|
||||
label: s,
|
||||
selected: profile.settings.contains(s),
|
||||
onTap: () => controller.toggleSetting(s),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SectionLabel('Skills and trades'),
|
||||
Wrap(
|
||||
spacing: 7,
|
||||
runSpacing: 7,
|
||||
children: [
|
||||
for (final s in Seed.skills)
|
||||
_Tag(
|
||||
label: s,
|
||||
selected: profile.skills.contains(s),
|
||||
onTap: () => controller.toggleSkill(s),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SectionLabel('Anything else you do'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _custom,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (v) {
|
||||
controller.addSkill(v);
|
||||
_custom.clear();
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Type a skill...',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
height: 52,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
controller.addSkill(_custom.text);
|
||||
_custom.clear();
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(52, 52),
|
||||
),
|
||||
child: const Icon(LucideIcons.plus, size: 20),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (extras.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 7,
|
||||
runSpacing: 7,
|
||||
children: [
|
||||
for (final s in extras)
|
||||
Chip(
|
||||
label: Text(s),
|
||||
backgroundColor: KrowPalette.yellow,
|
||||
side: BorderSide.none,
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: KrowPalette.ink,
|
||||
),
|
||||
deleteIcon: const Icon(LucideIcons.x, size: 14),
|
||||
onDeleted: () => controller.toggleSkill(s),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Skills saved')));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Save skills'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tag extends StatelessWidget {
|
||||
const _Tag({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilterChip(
|
||||
label: Text(label),
|
||||
selected: selected,
|
||||
showCheckmark: false,
|
||||
onSelected: (_) => onTap(),
|
||||
selectedColor: KrowPalette.blue,
|
||||
backgroundColor: Colors.white,
|
||||
side: BorderSide(color: selected ? KrowPalette.blue : KrowPalette.line),
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: selected ? Colors.white : KrowPalette.mute,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where your pay lands. Needed before the first pay date.
|
||||
class DirectDepositScreen extends ConsumerStatefulWidget {
|
||||
const DirectDepositScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DirectDepositScreen> createState() =>
|
||||
_DirectDepositScreenState();
|
||||
}
|
||||
|
||||
class _DirectDepositScreenState extends ConsumerState<DirectDepositScreen> {
|
||||
final _bank = TextEditingController();
|
||||
final _routing = TextEditingController();
|
||||
final _account = TextEditingController();
|
||||
String _type = 'Chequing';
|
||||
bool _editing = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bank.dispose();
|
||||
_routing.dispose();
|
||||
_account.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
if (_bank.text.trim().isEmpty ||
|
||||
_routing.text.length != 9 ||
|
||||
_account.text.length < 4) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Check the bank name, 9-digit routing and account'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
ref
|
||||
.read(profileProvider.notifier)
|
||||
.saveBank(
|
||||
BankAccount(
|
||||
bankName: _bank.text.trim(),
|
||||
routing: _routing.text,
|
||||
accountLast4: _account.text.substring(_account.text.length - 4),
|
||||
accountType: _type,
|
||||
),
|
||||
);
|
||||
setState(() => _editing = false);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Account saved')));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bank = ref.watch(profileProvider).bank;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Direct deposit')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
||||
children: [
|
||||
const Text(
|
||||
'Where your pay lands. You need this before the first pay date.',
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
height: 1.55,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
KrowCard(
|
||||
padding: const EdgeInsets.all(15),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: KrowPalette.blueTint,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.shield,
|
||||
size: 17,
|
||||
color: KrowPalette.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Bank-grade encryption',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 3),
|
||||
Text(
|
||||
'Account numbers are tokenised. KROW staff never see '
|
||||
'them, and neither do the businesses you work for.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1.45,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SectionLabel('Your account'),
|
||||
if (bank != null && !_editing)
|
||||
KrowCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
DetailRow('Bank', bank.bankName),
|
||||
const Divider(height: 1),
|
||||
DetailRow('Type', bank.accountType),
|
||||
const Divider(height: 1),
|
||||
DetailRow('Routing', bank.routing),
|
||||
const Divider(height: 1),
|
||||
DetailRow('Account', '····${bank.accountLast4}'),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (!_editing)
|
||||
EmptyState(
|
||||
icon: LucideIcons.landmark,
|
||||
title: 'No account added',
|
||||
message: 'Add one before the next pay date to get paid on time.',
|
||||
action: FilledButton.icon(
|
||||
onPressed: () => setState(() => _editing = true),
|
||||
icon: const Icon(LucideIcons.plus, size: 18),
|
||||
label: const Text('Add an account'),
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _bank,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(labelText: 'Bank name'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _routing,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(9),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Routing number',
|
||||
helperText: 'Nine digits, on the bottom of a cheque',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _account,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(17),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Account number',
|
||||
helperText: 'Chequing or savings, in your own name',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _type,
|
||||
decoration: const InputDecoration(labelText: 'Account type'),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'Chequing',
|
||||
child: Text('Chequing'),
|
||||
),
|
||||
DropdownMenuItem(value: 'Savings', child: Text('Savings')),
|
||||
],
|
||||
onChanged: (v) => setState(() => _type = v ?? _type),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
if (_editing)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => setState(() => _editing = false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Save account'),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (bank != null)
|
||||
OutlinedButton(
|
||||
onPressed: () => setState(() => _editing = true),
|
||||
child: const Text('Replace account'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Policies and balances, per staffing vendor.
|
||||
class BenefitsScreen extends ConsumerWidget {
|
||||
const BenefitsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Sick-leave accrual starts after 30 days worked.
|
||||
const daysWorked = 12;
|
||||
const threshold = 30;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Your benefits'),
|
||||
actions: const [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Center(
|
||||
child: StatusChip('Legendary Events', tone: ChipTone.yellow),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
||||
children: [
|
||||
const Text(
|
||||
'Policies and balances, per staffing vendor.',
|
||||
style: TextStyle(fontSize: 13.5, color: KrowPalette.mute),
|
||||
),
|
||||
const SectionLabel('Active policies'),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 30),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: KrowPalette.line),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: KrowPalette.line),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.gift,
|
||||
size: 24,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'No policy active yet',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
const Text(
|
||||
'Sick-leave accrual starts after 30 days worked. '
|
||||
'You are at day $daysWorked.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24),
|
||||
child: KrowProgressBar(
|
||||
daysWorked / threshold,
|
||||
color: KrowPalette.yellow,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
const Text(
|
||||
'${threshold - daysWorked} days to go',
|
||||
style: TextStyle(fontSize: 11.5, color: Color(0xFF949FA5)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SectionLabel('Balance history'),
|
||||
KrowCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'A read-only record of accruals, usage, adjustments and '
|
||||
'reversals.',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
height: 1.5,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: KrowPalette.sage,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'Nothing recorded yet',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: KrowPalette.mute),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const InfoBanner(
|
||||
'Working for a different vendor? Switch at the top to see their '
|
||||
'policies.',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
362
lib/screens/profile_screen.dart
Normal file
@@ -0,0 +1,362 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../core/theme.dart';
|
||||
import '../state/compliance_controller.dart';
|
||||
import '../state/profile_controller.dart';
|
||||
import '../widgets/collapsing_header.dart';
|
||||
import '../widgets/common.dart';
|
||||
|
||||
class ProfileScreen extends ConsumerWidget {
|
||||
const ProfileScreen({super.key});
|
||||
|
||||
/// Avatar (84) + gap + name + role, plus 4/16 padding.
|
||||
static const _expandedHeader = 192.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final worker = ref.watch(workerStatsProvider);
|
||||
final profile = ref.watch(profileProvider);
|
||||
final (done, total) = ref.watch(complianceProgressProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
KrowSliverHeader(
|
||||
// Must clear the open content: avatar, name and role, plus the
|
||||
// padding around them. The settings button overlays rather than
|
||||
// stacking above, which is what keeps this under 200.
|
||||
expandedHeight: _expandedHeader,
|
||||
collapsedHeight: 60,
|
||||
builder: (context, t) => HeaderCrossFade(
|
||||
t: t,
|
||||
expandedHeight: _expandedHeader,
|
||||
collapsed: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
worker.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
HeaderIconButton(
|
||||
icon: LucideIcons.settings,
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => context.push('/settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
expanded: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
|
||||
child: Stack(
|
||||
// Without this the unpositioned column would shrink-wrap to
|
||||
// the Stack's top-left corner instead of centring.
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
// Overlaid rather than stacked above the avatar: as its own
|
||||
// row it added 46px the header could not afford.
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: HeaderIconButton(
|
||||
icon: LucideIcons.settings,
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => context.push('/settings'),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => _editName(context, ref, worker.name),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 84,
|
||||
height: 84,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: KrowPalette.yellow,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
worker.initials,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: KrowPalette.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: KrowPalette.blue,
|
||||
width: 2.5,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.pencil,
|
||||
size: 12,
|
||||
color: KrowPalette.blue,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
worker.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 23,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.8,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${worker.role} · ${worker.city}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
letterSpacing: 0.4,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
|
||||
sliver: SliverList.list(
|
||||
children: [
|
||||
StatRail(
|
||||
stats: [
|
||||
('Shifts', '${worker.shiftsWorked}'),
|
||||
('Rating', worker.rating.toStringAsFixed(1)),
|
||||
('On time', '${worker.onTimePercent}%'),
|
||||
('No show', '${worker.noShows}'),
|
||||
('Cancel', '${worker.cancellations}'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 11),
|
||||
KrowCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Trust index',
|
||||
style: TextStyle(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${worker.trustIndex}%',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 11),
|
||||
KrowProgressBar(
|
||||
worker.trustIndex / 100,
|
||||
color: worker.trustIndex < 45
|
||||
? const Color(0xFFA6403F)
|
||||
: KrowPalette.blue,
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
const Text(
|
||||
'Stay above 45% to keep picking up shifts. On-time '
|
||||
'arrivals raise it fastest.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1.45,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SectionLabel('Compliance'),
|
||||
TileGroup(
|
||||
children: [
|
||||
NavTile(
|
||||
icon: LucideIcons.shieldCheck,
|
||||
title: 'Compliance hub',
|
||||
subtitle: 'Everything a business must see',
|
||||
badge: StatusChip(
|
||||
'$done of $total',
|
||||
tone: done == total
|
||||
? ChipTone.neutral
|
||||
: ChipTone.yellow,
|
||||
),
|
||||
onTap: () => context.push('/compliance'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SectionLabel('Your details'),
|
||||
TileGroup(
|
||||
children: [
|
||||
NavTile(
|
||||
icon: LucideIcons.user,
|
||||
title: 'Personal info',
|
||||
subtitle: worker.phone,
|
||||
onTap: () => _editName(context, ref, worker.name),
|
||||
),
|
||||
NavTile(
|
||||
icon: LucideIcons.phone,
|
||||
title: 'Emergency contact',
|
||||
subtitle: 'Who we call if something happens',
|
||||
badge: StatusChip(
|
||||
'${profile.contacts.length}',
|
||||
tone: ChipTone.neutral,
|
||||
),
|
||||
onTap: () => context.push('/emergency-contacts'),
|
||||
),
|
||||
NavTile(
|
||||
icon: LucideIcons.briefcase,
|
||||
title: 'Experience & skills',
|
||||
subtitle: 'Work history and trades',
|
||||
badge: StatusChip(
|
||||
'${profile.skills.length}',
|
||||
tone: ChipTone.neutral,
|
||||
),
|
||||
onTap: () => context.push('/skills'),
|
||||
),
|
||||
NavTile(
|
||||
icon: LucideIcons.calendar,
|
||||
title: 'Availability',
|
||||
subtitle: 'The hours you can work',
|
||||
onTap: () => context.push('/availability'),
|
||||
),
|
||||
NavTile(
|
||||
icon: LucideIcons.gift,
|
||||
title: 'Benefits',
|
||||
subtitle: 'Policies and balance history',
|
||||
onTap: () => context.push('/benefits'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SectionLabel('Money'),
|
||||
TileGroup(
|
||||
children: [
|
||||
NavTile(
|
||||
icon: LucideIcons.landmark,
|
||||
title: 'Direct deposit',
|
||||
subtitle: profile.bank == null
|
||||
? 'Not set up yet'
|
||||
: '${profile.bank!.bankName} '
|
||||
'····${profile.bank!.accountLast4}',
|
||||
badge: profile.bank == null
|
||||
? const StatusChip('Add', tone: ChipTone.yellow)
|
||||
: null,
|
||||
onTap: () => context.push('/direct-deposit'),
|
||||
),
|
||||
NavTile(
|
||||
icon: LucideIcons.fileText,
|
||||
title: 'Timecards',
|
||||
subtitle: 'Hours worked and disputes',
|
||||
onTap: () => context.push('/timecards'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SectionLabel('Support'),
|
||||
TileGroup(
|
||||
children: [
|
||||
NavTile(
|
||||
icon: LucideIcons.lifeBuoy,
|
||||
title: 'Contact support',
|
||||
subtitle: 'Usually answered within 4 hours',
|
||||
onTap: () => context.push('/support'),
|
||||
),
|
||||
NavTile(
|
||||
icon: LucideIcons.circleHelp,
|
||||
title: 'FAQs',
|
||||
subtitle: 'Common questions answered',
|
||||
onTap: () => context.push('/faqs'),
|
||||
),
|
||||
NavTile(
|
||||
icon: LucideIcons.shield,
|
||||
title: 'Privacy & security',
|
||||
subtitle: 'Account and data settings',
|
||||
onTap: () => context.push('/privacy'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _editName(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
) async {
|
||||
final controller = TextEditingController(text: current);
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Your name'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(hintText: 'Full name'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text.trim()),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (name == null || name.isEmpty) return;
|
||||
ref.read(profileProvider.notifier).updateName(name);
|
||||
}
|
||||
}
|
||||
589
lib/screens/settings_screens.dart
Normal file
@@ -0,0 +1,589 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../core/theme.dart';
|
||||
import '../state/profile_controller.dart';
|
||||
import '../state/seed.dart';
|
||||
import '../state/session_controller.dart';
|
||||
import '../state/shifts_controller.dart';
|
||||
import '../state/timecards_controller.dart';
|
||||
import '../widgets/common.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final session = ref.watch(sessionProvider);
|
||||
final notifier = ref.read(sessionProvider.notifier);
|
||||
final profile = ref.watch(profileProvider);
|
||||
final reviewCount = ref.watch(timecardsNeedingReviewProvider).length;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Settings'),
|
||||
actions: const [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Center(child: StatusChip('v1.1.1', tone: ChipTone.yellow)),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
||||
children: [
|
||||
const SectionLabel('Permissions'),
|
||||
KrowCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
_SwitchRow(
|
||||
icon: LucideIcons.mapPin,
|
||||
title: 'Location',
|
||||
subtitle: 'Needed to clock in at the venue',
|
||||
value: session.locationGranted,
|
||||
onChanged: (v) => v ? notifier.grantLocation() : null,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
_SwitchRow(
|
||||
icon: LucideIcons.bell,
|
||||
title: 'Notifications',
|
||||
subtitle: 'Shift offers, reminders and pay alerts',
|
||||
value: session.notificationsGranted,
|
||||
onChanged: (v) => v ? notifier.grantNotifications() : null,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
_SwitchRow(
|
||||
icon: LucideIcons.camera,
|
||||
title: 'Camera',
|
||||
subtitle: 'Photograph documents and attire',
|
||||
value: session.cameraGranted,
|
||||
onChanged: (v) => v ? notifier.grantCamera() : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SectionLabel('Money'),
|
||||
TileGroup(
|
||||
children: [
|
||||
NavTile(
|
||||
icon: LucideIcons.landmark,
|
||||
title: 'Direct deposit',
|
||||
subtitle: profile.bank == null
|
||||
? 'Where your pay lands'
|
||||
: profile.bank!.bankName,
|
||||
badge: profile.bank == null
|
||||
? const StatusChip('Add', tone: ChipTone.yellow)
|
||||
: null,
|
||||
onTap: () => context.push('/direct-deposit'),
|
||||
),
|
||||
NavTile(
|
||||
icon: LucideIcons.clock,
|
||||
title: 'Timecards',
|
||||
subtitle: 'Hours worked and disputes',
|
||||
badge: reviewCount > 0
|
||||
? StatusChip('$reviewCount', tone: ChipTone.yellow)
|
||||
: null,
|
||||
onTap: () => context.push('/timecards'),
|
||||
),
|
||||
NavTile(
|
||||
icon: LucideIcons.calendarDays,
|
||||
title: 'Payroll schedule',
|
||||
subtitle: 'When each period closes and pays',
|
||||
onTap: () => context.push('/payroll'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SectionLabel('Support'),
|
||||
TileGroup(
|
||||
children: [
|
||||
NavTile(
|
||||
icon: LucideIcons.lifeBuoy,
|
||||
title: 'Contact support',
|
||||
subtitle: 'Usually answered within 4 hours',
|
||||
onTap: () => context.push('/support'),
|
||||
),
|
||||
NavTile(
|
||||
icon: LucideIcons.circleHelp,
|
||||
title: 'FAQs',
|
||||
subtitle: 'Common questions answered',
|
||||
onTap: () => context.push('/faqs'),
|
||||
),
|
||||
NavTile(
|
||||
icon: LucideIcons.shield,
|
||||
title: 'Privacy & security',
|
||||
subtitle: 'Account and data settings',
|
||||
onTap: () => context.push('/privacy'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _signOut(context, ref),
|
||||
icon: const Icon(LucideIcons.logOut, size: 18),
|
||||
label: const Text('Sign out'),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
const Center(
|
||||
child: Text(
|
||||
'VERSION 1.1.1 · BUILD 26',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.2,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _signOut(BuildContext context, WidgetRef ref) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Sign out?'),
|
||||
content: const Text('You will need to verify your number again.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Stay'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
|
||||
child: const Text('Sign out'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !context.mounted) return;
|
||||
await ref.read(sessionProvider.notifier).signOut();
|
||||
if (!context.mounted) return;
|
||||
context.go('/welcome');
|
||||
}
|
||||
}
|
||||
|
||||
class _SwitchRow extends StatelessWidget {
|
||||
const _SwitchRow({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: KrowPalette.blueTint,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: KrowPalette.blue),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(fontSize: 12, color: KrowPalette.mute),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(value: value, onChanged: onChanged),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick what it is about so it reaches the right person first time.
|
||||
class SupportScreen extends ConsumerStatefulWidget {
|
||||
const SupportScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SupportScreen> createState() => _SupportScreenState();
|
||||
}
|
||||
|
||||
class _SupportScreenState extends ConsumerState<SupportScreen> {
|
||||
String _topic = Seed.supportTopics.first;
|
||||
String? _shiftId;
|
||||
final _message = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_message.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _send() {
|
||||
if (_message.text.trim().length < 10) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Tell us a little more than that')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Sent to support · $_topic')));
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shifts = ref.watch(shiftsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Get help'),
|
||||
actions: const [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Center(child: StatusChip('~4 hr reply')),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
||||
children: [
|
||||
const Text(
|
||||
'Pick what it is about so it reaches the right person first time.',
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
height: 1.55,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
const SectionLabel("What's it about?"),
|
||||
Wrap(
|
||||
spacing: 7,
|
||||
runSpacing: 7,
|
||||
children: [
|
||||
for (final t in Seed.supportTopics)
|
||||
ChoiceChip(
|
||||
label: Text(t),
|
||||
selected: t == _topic,
|
||||
showCheckmark: false,
|
||||
selectedColor: KrowPalette.blue,
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: t == _topic ? Colors.white : KrowPalette.mute,
|
||||
),
|
||||
onSelected: (_) => setState(() => _topic = t),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SectionLabel('Which shift?'),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _shiftId,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Not about a specific shift',
|
||||
),
|
||||
items: [
|
||||
for (final s in shifts)
|
||||
DropdownMenuItem(
|
||||
value: s.id,
|
||||
child: Text(
|
||||
'${s.role} · ${s.business}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => setState(() => _shiftId = v),
|
||||
),
|
||||
const SectionLabel('Tell us what happened'),
|
||||
TextField(
|
||||
controller: _message,
|
||||
maxLines: 5,
|
||||
maxLength: 500,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'The venue door was locked when I arrived...',
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Attachments open the camera roll')),
|
||||
),
|
||||
icon: const Icon(LucideIcons.paperclip, size: 16),
|
||||
label: const Text('Attach a photo'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _send, child: const Text('Send to support')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FaqScreen extends StatefulWidget {
|
||||
const FaqScreen({super.key});
|
||||
|
||||
@override
|
||||
State<FaqScreen> createState() => _FaqScreenState();
|
||||
}
|
||||
|
||||
class _FaqScreenState extends State<FaqScreen> {
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final q = _query.toLowerCase();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Questions')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
||||
children: [
|
||||
TextField(
|
||||
onChanged: (v) => setState(() => _query = v),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search help',
|
||||
prefixIcon: Icon(LucideIcons.search, size: 18),
|
||||
),
|
||||
),
|
||||
for (final entry in Seed.faqs.entries)
|
||||
() {
|
||||
final matches = entry.value
|
||||
.where(
|
||||
(f) =>
|
||||
q.isEmpty ||
|
||||
f.$1.toLowerCase().contains(q) ||
|
||||
f.$2.toLowerCase().contains(q),
|
||||
)
|
||||
.toList();
|
||||
if (matches.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SectionLabel(entry.key),
|
||||
KrowCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < matches.length; i++) ...[
|
||||
if (i > 0) const Divider(height: 1),
|
||||
ExpansionTile(
|
||||
shape: const Border(),
|
||||
collapsedShape: const Border(),
|
||||
tilePadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
),
|
||||
childrenPadding: const EdgeInsets.fromLTRB(
|
||||
12,
|
||||
0,
|
||||
12,
|
||||
14,
|
||||
),
|
||||
title: Text(
|
||||
matches[i].$1,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
matches[i].$2,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.55,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PrivacyScreen extends ConsumerWidget {
|
||||
const PrivacyScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final profile = ref.watch(profileProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Privacy & security')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
||||
children: [
|
||||
const SectionLabel('Legal'),
|
||||
TileGroup(
|
||||
children: [
|
||||
NavTile(
|
||||
icon: LucideIcons.fileText,
|
||||
title: 'Terms of service',
|
||||
subtitle: 'Updated 14 Jun 2026',
|
||||
onTap: () => _open(context, 'Terms of service'),
|
||||
),
|
||||
NavTile(
|
||||
icon: LucideIcons.shield,
|
||||
title: 'Privacy policy',
|
||||
subtitle: 'What we collect and why',
|
||||
onTap: () => _open(context, 'Privacy policy'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SectionLabel('Who sees your data'),
|
||||
TileGroup(
|
||||
children: [
|
||||
NavTile(
|
||||
icon: LucideIcons.briefcase,
|
||||
title: "Businesses you've worked for",
|
||||
subtitle: '2 have access to your documents',
|
||||
onTap: () => _open(context, 'Data sharing'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SectionLabel('Email'),
|
||||
KrowCard(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: KrowPalette.blueTint,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.mail,
|
||||
size: 18,
|
||||
color: KrowPalette.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'KROW newsletter',
|
||||
style: TextStyle(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Product news and operational tips',
|
||||
style: TextStyle(fontSize: 12, color: KrowPalette.mute),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: profile.newsletter,
|
||||
onChanged: (v) =>
|
||||
ref.read(profileProvider.notifier).setNewsletter(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SectionLabel('Account'),
|
||||
KrowCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Delete your account',
|
||||
style: TextStyle(fontSize: 14.5, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
const Text(
|
||||
'Permanent. Timecards and tax records are kept for 4 years '
|
||||
'by law, then destroyed.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1.45,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _confirmDelete(context),
|
||||
icon: const Icon(LucideIcons.trash2, size: 18),
|
||||
label: const Text('Delete account'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _open(BuildContext context, String title) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('$title opens in the browser')));
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context) async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete your account?'),
|
||||
content: const Text(
|
||||
'This cannot be undone. To go ahead, contact support so we can '
|
||||
'confirm it is really you.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Keep my account'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
context.push('/support');
|
||||
},
|
||||
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
|
||||
child: const Text('Contact support'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
379
lib/screens/shift_detail_screen.dart
Normal file
@@ -0,0 +1,379 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../core/formatters.dart';
|
||||
import '../core/theme.dart';
|
||||
import '../models/compliance.dart';
|
||||
import '../models/shift.dart';
|
||||
import '../state/compliance_controller.dart';
|
||||
import '../state/shifts_controller.dart';
|
||||
import '../widgets/common.dart';
|
||||
|
||||
class ShiftDetailScreen extends ConsumerWidget {
|
||||
const ShiftDetailScreen({super.key, required this.shiftId});
|
||||
|
||||
final String shiftId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final shift = ref.watch(shiftsProvider.notifier).byId(shiftId);
|
||||
if (shift == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: const Center(child: Text('That shift is no longer listed.')),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
BrandHeader(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
HeaderIconButton(
|
||||
icon: LucideIcons.arrowLeft,
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
HeaderIconButton(
|
||||
icon: LucideIcons.messageCircle,
|
||||
tooltip: 'Message the manager',
|
||||
onPressed: () => context.push('/support'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
StatusChip(
|
||||
shift.status.label,
|
||||
tone: switch (shift.status) {
|
||||
ShiftStatus.active || ShiftStatus.booked => ChipTone.yellow,
|
||||
ShiftStatus.noShow => ChipTone.danger,
|
||||
_ => ChipTone.neutral,
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
shift.role,
|
||||
style: const TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -1.1,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${shift.business} · ${Fmt.dayShort(shift.start)}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.62),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
StatSlabRow(
|
||||
slabs: [
|
||||
StatSlab(
|
||||
shift.status == ShiftStatus.completed ? 'Paid' : 'Est.',
|
||||
Fmt.money(shift.pay),
|
||||
),
|
||||
StatSlab('Rate', '${Fmt.money(shift.rate)}/hr'),
|
||||
StatSlab(
|
||||
'Hours',
|
||||
Fmt.duration(
|
||||
shift.workedDuration ?? shift.scheduledDuration,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SectionLabel('The shift'),
|
||||
KrowCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
DetailRow('Date', Fmt.dayShort(shift.start)),
|
||||
const Divider(height: 1),
|
||||
DetailRow(
|
||||
'Scheduled',
|
||||
Fmt.window(shift.start, shift.end),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
DetailRow('Location', shift.address),
|
||||
const Divider(height: 1),
|
||||
DetailRow('Break, unpaid', '${shift.breakMinutes} min'),
|
||||
if (shift.manager != null) ...[
|
||||
const Divider(height: 1),
|
||||
DetailRow('Report to', shift.manager!),
|
||||
],
|
||||
if (shift.clockIn != null) ...[
|
||||
const Divider(height: 1),
|
||||
DetailRow(
|
||||
'Clocked',
|
||||
shift.clockOut == null
|
||||
? '${Fmt.time(shift.clockIn!)} - running'
|
||||
: Fmt.window(shift.clockIn!, shift.clockOut!),
|
||||
valueColor: KrowPalette.blue,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SectionLabel('Pay breakdown'),
|
||||
KrowCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
DetailRow('Base rate', '${Fmt.money(shift.rate)}/hr'),
|
||||
const Divider(height: 1),
|
||||
DetailRow(
|
||||
'Regular · '
|
||||
'${Fmt.duration(shift.workedDuration ?? shift.scheduledDuration)}',
|
||||
Fmt.money(shift.pay),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
DetailRow(
|
||||
'Total, pre-tax',
|
||||
Fmt.money(shift.pay),
|
||||
emphasis: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SectionLabel('Requirements'),
|
||||
_Requirements(shift: shift),
|
||||
const SizedBox(height: 18),
|
||||
_Actions(shift: shift),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What this business asks for, checked against what is already on file.
|
||||
class _Requirements extends ConsumerWidget {
|
||||
const _Requirements({required this.shift});
|
||||
|
||||
final Shift shift;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final held = ref.watch(complianceProvider).all;
|
||||
var met = 0;
|
||||
final rows = <Widget>[];
|
||||
|
||||
for (final req in shift.requirements) {
|
||||
final item = held.where((i) => i.title == req).firstOrNull;
|
||||
final ok = item?.state == ComplianceState.verified;
|
||||
if (ok) met++;
|
||||
rows.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: ok ? KrowPalette.blueTint : const Color(0xFFF7E4AE),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Icon(
|
||||
ok ? LucideIcons.check : LucideIcons.clock,
|
||||
size: 12,
|
||||
color: ok ? KrowPalette.blue : KrowPalette.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
req,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
ok ? 'On file' : (item?.badge ?? 'Missing'),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: ok ? KrowPalette.mute : KrowPalette.ink,
|
||||
fontWeight: ok ? FontWeight.w400 : FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final total = shift.requirements.length;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
KrowCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 54,
|
||||
height: 54,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: total == 0 ? 1 : met / total,
|
||||
strokeWidth: 6,
|
||||
backgroundColor: const Color(0xFFDEEBE2),
|
||||
valueColor: const AlwaysStoppedAnimation(
|
||||
KrowPalette.blue,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$met/$total',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: KrowPalette.blue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
met == total
|
||||
? 'You are cleared to work'
|
||||
: 'Some items still missing',
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
met == total
|
||||
? 'Everything ${shift.business} asks for is on file.'
|
||||
: 'Clear them from the compliance hub to apply.',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.45,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
KrowCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Column(children: rows),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Actions extends ConsumerWidget {
|
||||
const _Actions({required this.shift});
|
||||
|
||||
final Shift shift;
|
||||
|
||||
Future<void> _confirmCancel(BuildContext context, WidgetRef ref) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Give this shift back?'),
|
||||
content: const Text(
|
||||
'It goes back on the open market straight away, and the '
|
||||
'cancellation counts against your trust index.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Keep it'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
|
||||
child: const Text('Cancel shift'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !context.mounted) return;
|
||||
ref.read(shiftsProvider.notifier).cancel(shift.id);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Shift cancelled')));
|
||||
context.pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
switch (shift.status) {
|
||||
case ShiftStatus.open:
|
||||
return FilledButton.icon(
|
||||
onPressed: () {
|
||||
ref.read(shiftsProvider.notifier).apply(shift.id);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Shift booked')));
|
||||
},
|
||||
icon: const Icon(LucideIcons.check, size: 18),
|
||||
label: const Text('Apply for this shift'),
|
||||
);
|
||||
case ShiftStatus.booked:
|
||||
return Column(
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.go('/clock'),
|
||||
icon: const Icon(LucideIcons.clock, size: 18),
|
||||
label: const Text('Go to clock in'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton(
|
||||
onPressed: () => _confirmCancel(context, ref),
|
||||
child: const Text('Cancel this shift'),
|
||||
),
|
||||
],
|
||||
);
|
||||
case ShiftStatus.active:
|
||||
return FilledButton.icon(
|
||||
onPressed: () => context.go('/clock'),
|
||||
icon: const Icon(LucideIcons.squareCheck, size: 18),
|
||||
label: const Text('You are on shift - clock out'),
|
||||
);
|
||||
default:
|
||||
return OutlinedButton.icon(
|
||||
onPressed: () => context.push('/support'),
|
||||
icon: const Icon(LucideIcons.lifeBuoy, size: 18),
|
||||
label: const Text('Something wrong? Get help'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
396
lib/screens/shifts_screen.dart
Normal file
@@ -0,0 +1,396 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../core/formatters.dart';
|
||||
import '../core/theme.dart';
|
||||
import '../models/shift.dart';
|
||||
import '../state/shifts_controller.dart';
|
||||
import '../widgets/common.dart';
|
||||
import '../widgets/glass_segments.dart';
|
||||
import '../widgets/shift_card.dart';
|
||||
import '../widgets/week_strip.dart';
|
||||
|
||||
enum _Tab { booked, open, history }
|
||||
|
||||
class ShiftsScreen extends ConsumerStatefulWidget {
|
||||
const ShiftsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ShiftsScreen> createState() => _ShiftsScreenState();
|
||||
}
|
||||
|
||||
class _ShiftsScreenState extends ConsumerState<ShiftsScreen> {
|
||||
_Tab _tab = _Tab.booked;
|
||||
|
||||
/// Where we came from, so the new list can slide in from the right side.
|
||||
_Tab _previousTab = _Tab.booked;
|
||||
DateTime _selectedDay = DateTime.now();
|
||||
String _query = '';
|
||||
String _filter = 'All';
|
||||
|
||||
void _selectTab(_Tab tab) {
|
||||
if (tab == _tab) return;
|
||||
setState(() {
|
||||
_previousTab = _tab;
|
||||
_tab = tab;
|
||||
});
|
||||
}
|
||||
|
||||
List<DateTime> get _week {
|
||||
final now = DateTime.now();
|
||||
return [for (var i = 0; i < 7; i++) now.add(Duration(days: i))];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
BrandHeader(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
Fmt.monthYear(DateTime.now()).toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.6,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Shifts',
|
||||
style: TextStyle(
|
||||
fontSize: 27,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.9,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_HeaderSegments(value: _tab, onChanged: _selectTab),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
// The list slides in from the side the new tab sits on, so the
|
||||
// content moves the same way your eye just did.
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 280),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
layoutBuilder: (current, previous) => Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: [...previous, ?current],
|
||||
),
|
||||
transitionBuilder: (child, animation) {
|
||||
final incoming = child.key == ValueKey(_tab);
|
||||
final direction = _tab.index >= _previousTab.index ? 1.0 : -1.0;
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: Offset(incoming ? 0.08 * direction : 0, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey(_tab),
|
||||
child: switch (_tab) {
|
||||
_Tab.booked => _BookedTab(
|
||||
week: _week,
|
||||
selected: _selectedDay,
|
||||
onSelect: (d) => setState(() => _selectedDay = d),
|
||||
),
|
||||
_Tab.open => _OpenTab(
|
||||
query: _query,
|
||||
filter: _filter,
|
||||
onQuery: (q) => setState(() => _query = q),
|
||||
onFilter: (f) => setState(() => _filter = f),
|
||||
),
|
||||
_Tab.history => const _HistoryTab(),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderSegments extends StatelessWidget {
|
||||
const _HeaderSegments({required this.value, required this.onChanged});
|
||||
|
||||
final _Tab value;
|
||||
final ValueChanged<_Tab> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GlassSegments<_Tab>(
|
||||
segments: const [
|
||||
(_Tab.booked, 'Booked'),
|
||||
(_Tab.open, 'Open'),
|
||||
(_Tab.history, 'History'),
|
||||
],
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BookedTab extends ConsumerWidget {
|
||||
const _BookedTab({
|
||||
required this.week,
|
||||
required this.selected,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final List<DateTime> week;
|
||||
final DateTime selected;
|
||||
final ValueChanged<DateTime> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final booked = ref.watch(bookedShiftsProvider);
|
||||
final forDay = booked.where((s) => Fmt.sameDay(s.start, selected)).toList();
|
||||
final marked = <int>{
|
||||
for (var i = 0; i < week.length; i++)
|
||||
if (booked.any((s) => Fmt.sameDay(s.start, week[i]))) i,
|
||||
};
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
|
||||
children: [
|
||||
WeekStrip(
|
||||
days: week,
|
||||
selected: selected,
|
||||
onSelect: onSelect,
|
||||
marked: marked,
|
||||
),
|
||||
SectionLabel(
|
||||
'${Fmt.dayFull(selected)} · ${forDay.length} '
|
||||
'${forDay.length == 1 ? 'shift' : 'shifts'}',
|
||||
),
|
||||
if (forDay.isEmpty)
|
||||
const EmptyState(
|
||||
icon: LucideIcons.ticket,
|
||||
title: 'Nothing booked this day',
|
||||
message: 'Switch to Open above to claim a shift near you.',
|
||||
)
|
||||
else
|
||||
for (final shift in forDay)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 11),
|
||||
child: ShiftCard(
|
||||
shift: shift,
|
||||
onTap: () => context.push('/shift/${shift.id}'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const InfoBanner(
|
||||
'Auto-Match only offers you shifts inside the availability blocks you '
|
||||
'have switched on.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OpenTab extends ConsumerWidget {
|
||||
const _OpenTab({
|
||||
required this.query,
|
||||
required this.filter,
|
||||
required this.onQuery,
|
||||
required this.onFilter,
|
||||
});
|
||||
|
||||
final String query;
|
||||
final String filter;
|
||||
final ValueChanged<String> onQuery;
|
||||
final ValueChanged<String> onFilter;
|
||||
|
||||
static const _filters = ['All', 'Today', 'This week', 'Top pay'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var shifts = ref.watch(openShiftsProvider);
|
||||
final now = DateTime.now();
|
||||
|
||||
if (query.isNotEmpty) {
|
||||
final q = query.toLowerCase();
|
||||
shifts = shifts
|
||||
.where(
|
||||
(s) =>
|
||||
s.role.toLowerCase().contains(q) ||
|
||||
s.business.toLowerCase().contains(q) ||
|
||||
s.venue.toLowerCase().contains(q),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
shifts = switch (filter) {
|
||||
'Today' => shifts.where((s) => Fmt.sameDay(s.start, now)).toList(),
|
||||
'This week' =>
|
||||
shifts.where((s) => s.start.difference(now).inDays < 7).toList(),
|
||||
'Top pay' => shifts.where((s) => s.rate >= 22).toList(),
|
||||
_ => shifts,
|
||||
};
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
|
||||
children: [
|
||||
TextField(
|
||||
onChanged: onQuery,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Role, business or area',
|
||||
prefixIcon: Icon(LucideIcons.search, size: 18),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final f in _filters)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(f),
|
||||
selected: f == filter,
|
||||
showCheckmark: false,
|
||||
selectedColor: KrowPalette.blue,
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: f == filter ? Colors.white : KrowPalette.mute,
|
||||
),
|
||||
onSelected: (_) => onFilter(f),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SectionLabel('${shifts.length} shifts · highest paying first'),
|
||||
if (shifts.isEmpty)
|
||||
const EmptyState(
|
||||
icon: LucideIcons.search,
|
||||
title: 'Nothing matches',
|
||||
message: 'Try a different filter or clear your search.',
|
||||
)
|
||||
else
|
||||
for (final shift in shifts)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 11),
|
||||
child: ShiftCard(
|
||||
shift: shift,
|
||||
showDate: true,
|
||||
onTap: () => context.push('/shift/${shift.id}'),
|
||||
trailingAction: _ApplyButton(shift: shift),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ApplyButton extends ConsumerWidget {
|
||||
const _ApplyButton({required this.shift});
|
||||
|
||||
final Shift shift;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
Fmt.money(shift.scheduledPay),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.6,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.selectionClick();
|
||||
ref.read(shiftsProvider.notifier).apply(shift.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'${shift.role} at ${shift.business} booked for '
|
||||
'${Fmt.dayShort(shift.start)}',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, 34),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
child: const Text('Apply'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistoryTab extends ConsumerWidget {
|
||||
const _HistoryTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final history = ref.watch(historyShiftsProvider);
|
||||
final completed = history
|
||||
.where((s) => s.status == ShiftStatus.completed)
|
||||
.length;
|
||||
final cancelled = history
|
||||
.where((s) => s.status == ShiftStatus.cancelled)
|
||||
.length;
|
||||
final logged = ref.watch(hoursLoggedProvider);
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
|
||||
children: [
|
||||
StatRail(
|
||||
stats: [
|
||||
('Completed', '$completed'),
|
||||
('Cancelled', '$cancelled'),
|
||||
('Logged', Fmt.duration(logged)),
|
||||
],
|
||||
),
|
||||
const SectionLabel('Everything you have worked'),
|
||||
if (history.isEmpty)
|
||||
const EmptyState(
|
||||
icon: LucideIcons.history,
|
||||
title: 'No history yet',
|
||||
message: 'Shifts you finish or cancel will show up here.',
|
||||
)
|
||||
else
|
||||
for (final shift in history)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 11),
|
||||
child: ShiftCard(
|
||||
shift: shift,
|
||||
showDate: true,
|
||||
onTap: () => context.push('/shift/${shift.id}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
388
lib/screens/timecard_screens.dart
Normal file
@@ -0,0 +1,388 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../core/formatters.dart';
|
||||
import '../core/theme.dart';
|
||||
import '../models/timecard.dart';
|
||||
import '../state/timecards_controller.dart';
|
||||
import '../widgets/common.dart';
|
||||
|
||||
class TimecardsScreen extends ConsumerWidget {
|
||||
const TimecardsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cards = ref.watch(timecardsProvider);
|
||||
final pending = cards.where((c) => c.status == TimecardStatus.needsReview);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Your timecards'),
|
||||
actions: [
|
||||
if (pending.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Center(
|
||||
child: StatusChip(
|
||||
'${pending.length} to review',
|
||||
tone: ChipTone.yellow,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: cards.isEmpty
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: EmptyState(
|
||||
icon: LucideIcons.fileText,
|
||||
title: 'No timecards yet',
|
||||
message: 'One is filed automatically every time you clock out.',
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
|
||||
itemCount: cards.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 11),
|
||||
itemBuilder: (context, i) => _TimecardTile(card: cards[i]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimecardTile extends StatelessWidget {
|
||||
const _TimecardTile({required this.card});
|
||||
|
||||
final Timecard card;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, tone) = switch (card.status) {
|
||||
TimecardStatus.needsReview => ('Needs review', ChipTone.yellow),
|
||||
TimecardStatus.approved => ('Approved', ChipTone.neutral),
|
||||
TimecardStatus.disputed => ('Disputed', ChipTone.danger),
|
||||
};
|
||||
|
||||
return KrowCard(
|
||||
onTap: () => context.push('/timecard/${card.id}'),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
BusinessBadge(card.business[0], size: 40),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
card.role,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.4,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${card.business} · ${Fmt.dayShort(card.date)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
StatusChip(label, tone: tone),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MiniStat(
|
||||
LucideIcons.calendar,
|
||||
'Scheduled',
|
||||
Fmt.window(card.scheduledStart, card.scheduledEnd),
|
||||
),
|
||||
),
|
||||
Container(width: 1, height: 34, color: KrowPalette.line),
|
||||
Expanded(
|
||||
child: _MiniStat(
|
||||
LucideIcons.clock,
|
||||
'Clocked',
|
||||
Fmt.window(card.clockIn, card.clockOut),
|
||||
highlight: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Filed ${Fmt.time(card.submittedAt)} · '
|
||||
'${Fmt.duration(card.worked)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
Fmt.money(card.amount),
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniStat extends StatelessWidget {
|
||||
const _MiniStat(this.icon, this.label, this.value, {this.highlight = false});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final bool highlight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: KrowPalette.blueTint,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, size: 16, color: KrowPalette.blue),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
color: highlight ? KrowPalette.blue : KrowPalette.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Review a filed timecard: confirm it, or raise an issue before it locks for
|
||||
/// payroll.
|
||||
class TimecardReviewScreen extends ConsumerWidget {
|
||||
const TimecardReviewScreen({super.key, required this.timecardId});
|
||||
|
||||
final String timecardId;
|
||||
|
||||
Future<void> _dispute(BuildContext context, WidgetRef ref) async {
|
||||
final controller = TextEditingController();
|
||||
final note = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Report an issue'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'What is wrong with these hours?',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text.trim()),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
|
||||
child: const Text('Send'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (note == null || note.isEmpty || !context.mounted) return;
|
||||
ref.read(timecardsProvider.notifier).dispute(timecardId, note);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Issue raised with payroll')));
|
||||
context.pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final card = ref.watch(timecardsProvider.notifier).byId(timecardId);
|
||||
if (card == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: const Center(child: Text('Timecard not found.')),
|
||||
);
|
||||
}
|
||||
|
||||
final variance = card.varianceMinutes;
|
||||
final open = card.status == TimecardStatus.needsReview;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Review timecard'),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Center(
|
||||
child: StatusChip(
|
||||
card.status.label,
|
||||
tone: switch (card.status) {
|
||||
TimecardStatus.needsReview => ChipTone.yellow,
|
||||
TimecardStatus.approved => ChipTone.neutral,
|
||||
TimecardStatus.disputed => ChipTone.danger,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
||||
children: [
|
||||
Text(
|
||||
'${card.role} at ${card.business}',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
Fmt.dayFull(card.date),
|
||||
style: const TextStyle(fontSize: 13.5, color: KrowPalette.mute),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
InfoBanner(
|
||||
open
|
||||
? 'Sent automatically when you clocked out. You have 7 days to '
|
||||
'raise an issue before it locks for payroll.'
|
||||
: 'This timecard is ${card.status.label.toLowerCase()}.',
|
||||
),
|
||||
const SectionLabel('Shift'),
|
||||
KrowCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
DetailRow('Date', Fmt.dayShort(card.date)),
|
||||
const Divider(height: 1),
|
||||
DetailRow(
|
||||
'Scheduled',
|
||||
Fmt.window(card.scheduledStart, card.scheduledEnd),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
DetailRow(
|
||||
'Clocked',
|
||||
Fmt.window(card.clockIn, card.clockOut),
|
||||
valueColor: KrowPalette.blue,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
DetailRow(
|
||||
'Variance',
|
||||
variance == 0
|
||||
? 'On time'
|
||||
: '${variance > 0 ? '+' : ''}$variance min',
|
||||
valueColor: variance > 5
|
||||
? const Color(0xFFA6403F)
|
||||
: KrowPalette.blue,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SectionLabel('Hours and pay'),
|
||||
KrowCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
DetailRow('Base rate', '${Fmt.money(card.rate)}/hr'),
|
||||
const Divider(height: 1),
|
||||
DetailRow('Break, unpaid', '${card.breakMinutes} min'),
|
||||
const Divider(height: 1),
|
||||
DetailRow(
|
||||
'Regular · ${Fmt.duration(card.worked)}',
|
||||
Fmt.money(card.amount),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
DetailRow(
|
||||
'Total, pre-tax',
|
||||
Fmt.money(card.amount),
|
||||
emphasis: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (card.disputeNote != null) ...[
|
||||
const SectionLabel('Your note'),
|
||||
KrowCard(child: Text(card.disputeNote!)),
|
||||
],
|
||||
const SizedBox(height: 22),
|
||||
if (open)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _dispute(context, ref),
|
||||
child: const Text('Report an issue'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
ref.read(timecardsProvider.notifier).approve(card.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Timecard approved')),
|
||||
);
|
||||
context.pop();
|
||||
},
|
||||
child: const Text('Looks right'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
421
lib/screens/today_screen.dart
Normal file
@@ -0,0 +1,421 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../core/formatters.dart';
|
||||
import '../core/theme.dart';
|
||||
import '../models/shift.dart';
|
||||
import '../state/availability_controller.dart';
|
||||
import '../state/compliance_controller.dart';
|
||||
import '../state/profile_controller.dart';
|
||||
import '../state/shifts_controller.dart';
|
||||
import '../widgets/collapsing_header.dart';
|
||||
import '../widgets/common.dart';
|
||||
import '../widgets/shift_card.dart';
|
||||
|
||||
class TodayScreen extends ConsumerWidget {
|
||||
const TodayScreen({super.key});
|
||||
|
||||
String _greeting() {
|
||||
final h = DateTime.now().hour;
|
||||
if (h < 12) return 'Good morning';
|
||||
if (h < 18) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final worker = ref.watch(workerStatsProvider);
|
||||
final next = ref.watch(nextShiftProvider);
|
||||
final open = ref.watch(openShiftsProvider);
|
||||
final scheduled = ref.watch(scheduledEarningsProvider);
|
||||
final hours = ref.watch(hoursLoggedProvider);
|
||||
final daysSet = ref.watch(daysSetProvider);
|
||||
final compliant = ref.watch(isCompliantProvider);
|
||||
final (done, total) = ref.watch(complianceProgressProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
KrowSliverHeader(
|
||||
expandedHeight: 152,
|
||||
collapsedHeight: 60,
|
||||
builder: (context, t) => HeaderCrossFade(
|
||||
t: t,
|
||||
expandedHeight: 152,
|
||||
expanded: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_Avatar(worker.initials),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_greeting().toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.6,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
worker.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.7,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
HeaderIconButton(
|
||||
icon: LucideIcons.messageCircle,
|
||||
tooltip: 'Support',
|
||||
onPressed: () => context.push('/support'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
children: [
|
||||
// Flexible, not free-sized: on a 320px screen the two
|
||||
// captions plus the rule are wider than the header.
|
||||
Flexible(
|
||||
child: _HeaderStat(
|
||||
Fmt.money(scheduled),
|
||||
'This period',
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 34,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 18),
|
||||
color: Colors.white24,
|
||||
),
|
||||
Flexible(
|
||||
child: _HeaderStat(
|
||||
Fmt.duration(hours),
|
||||
'Hours logged',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Once collapsed, keep who you are and the figure that matters,
|
||||
// plus the one control that was in the open state.
|
||||
collapsed: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
_Avatar(worker.initials, size: 32),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
worker.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.4,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
Fmt.money(scheduled),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
color: KrowPalette.yellow,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
HeaderIconButton(
|
||||
icon: LucideIcons.messageCircle,
|
||||
tooltip: 'Support',
|
||||
onPressed: () => context.push('/support'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
||||
sliver: SliverList.list(
|
||||
children: [
|
||||
if (!compliant) ...[
|
||||
const SizedBox(height: 16),
|
||||
KrowCard(
|
||||
onTap: () => context.push('/compliance'),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.triangleAlert,
|
||||
size: 20,
|
||||
color: KrowPalette.ink,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Finish your compliance',
|
||||
style: TextStyle(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'$done of $total items cleared',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const StatusChip('Action', tone: ChipTone.yellow),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SectionLabel('Up next'),
|
||||
if (next == null)
|
||||
EmptyState(
|
||||
icon: LucideIcons.ticket,
|
||||
title: 'Nothing booked',
|
||||
message:
|
||||
'Open shifts near you start at '
|
||||
'${open.isEmpty ? '-' : Fmt.money(open.last.rate)}/hr.',
|
||||
action: FilledButton(
|
||||
onPressed: () => context.go('/shifts'),
|
||||
child: const Text('Browse open shifts'),
|
||||
),
|
||||
)
|
||||
else
|
||||
ShiftCard(
|
||||
shift: next,
|
||||
showDate: !Fmt.sameDay(next.start, DateTime.now()),
|
||||
onTap: () => context.push('/shift/${next.id}'),
|
||||
),
|
||||
if (next != null && next.status == ShiftStatus.active) ...[
|
||||
const SizedBox(height: 10),
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.go('/clock'),
|
||||
icon: const Icon(LucideIcons.squareCheck, size: 18),
|
||||
label: const Text('You are on shift - go to clock'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _QuickTile(
|
||||
icon: LucideIcons.search,
|
||||
label: 'Find shifts',
|
||||
caption: '${open.length} OPEN',
|
||||
onTap: () => context.go('/shifts'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _QuickTile(
|
||||
icon: LucideIcons.calendar,
|
||||
label: 'Availability',
|
||||
caption: '$daysSet DAYS SET',
|
||||
onTap: () => context.push('/availability'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _QuickTile(
|
||||
icon: LucideIcons.banknote,
|
||||
label: 'Earnings',
|
||||
caption: 'PENDING',
|
||||
onTap: () => context.go('/pay'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SectionLabel(
|
||||
'Matched to you',
|
||||
trailing: TextButton(
|
||||
onPressed: () => context.go('/shifts'),
|
||||
child: const Text('See all'),
|
||||
),
|
||||
),
|
||||
if (open.isEmpty)
|
||||
const InfoBanner(
|
||||
'No open shifts right now. Turn on more availability blocks '
|
||||
'and we will match you to more work.',
|
||||
)
|
||||
else
|
||||
for (final shift in open.take(3))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: ShiftCard(
|
||||
shift: shift,
|
||||
showDate: true,
|
||||
onTap: () => context.push('/shift/${shift.id}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The worker's initials, used at two sizes as the header collapses.
|
||||
class _Avatar extends StatelessWidget {
|
||||
const _Avatar(this.initials, {this.size = 44});
|
||||
|
||||
final String initials;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: KrowPalette.yellow,
|
||||
borderRadius: BorderRadius.circular(size / 3.4),
|
||||
),
|
||||
child: Text(
|
||||
initials,
|
||||
style: TextStyle(
|
||||
fontSize: size * 0.39,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: KrowPalette.ink,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderStat extends StatelessWidget {
|
||||
const _HeaderStat(this.value, this.caption);
|
||||
|
||||
final String value;
|
||||
final String caption;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Both lines hold to one line and shrink to fit: a wrapped caption
|
||||
// would push the header past its pinned height.
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.8,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
caption.toUpperCase(),
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.4,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickTile extends StatelessWidget {
|
||||
const _QuickTile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.caption,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String caption;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return KrowCard(
|
||||
onTap: onTap,
|
||||
padding: const EdgeInsets.fromLTRB(12, 14, 12, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: KrowPalette.blueTint,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
child: Icon(icon, size: 17, color: KrowPalette.blue),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
caption,
|
||||
style: const TextStyle(
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
128
lib/state/availability_controller.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/availability.dart';
|
||||
import 'session_controller.dart';
|
||||
|
||||
/// Availability is stored as `yyyy-mm-dd` -> the blocks switched on that day.
|
||||
typedef AvailabilityMap = Map<String, Set<DayBlock>>;
|
||||
|
||||
String dayKey(DateTime d) =>
|
||||
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||
|
||||
class AvailabilityController extends Notifier<AvailabilityMap> {
|
||||
static const _key = 'availability';
|
||||
|
||||
SharedPreferences get _prefs => ref.read(prefsProvider);
|
||||
|
||||
@override
|
||||
AvailabilityMap build() {
|
||||
final raw = _prefs.getStringList(_key) ?? const <String>[];
|
||||
final map = <String, Set<DayBlock>>{};
|
||||
for (final entry in raw) {
|
||||
// Stored as "2026-08-14:morning,evening".
|
||||
final parts = entry.split(':');
|
||||
if (parts.length != 2) continue;
|
||||
final blocks = parts[1]
|
||||
.split(',')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.map(
|
||||
(name) => DayBlock.values.firstWhere(
|
||||
(b) => b.name == name,
|
||||
orElse: () => DayBlock.morning,
|
||||
),
|
||||
)
|
||||
.toSet();
|
||||
if (blocks.isNotEmpty) map[parts[0]] = blocks;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
void _persist(AvailabilityMap map) {
|
||||
_prefs.setStringList(_key, [
|
||||
for (final e in map.entries)
|
||||
if (e.value.isNotEmpty)
|
||||
'${e.key}:${e.value.map((b) => b.name).join(',')}',
|
||||
]);
|
||||
state = map;
|
||||
}
|
||||
|
||||
Set<DayBlock> blocksFor(DateTime day) => state[dayKey(day)] ?? <DayBlock>{};
|
||||
|
||||
bool isDayOff(DateTime day) => blocksFor(day).isEmpty;
|
||||
|
||||
void toggleBlock(DateTime day, DayBlock block) {
|
||||
final map = {
|
||||
for (final e in state.entries) e.key: {...e.value},
|
||||
};
|
||||
final key = dayKey(day);
|
||||
final blocks = map[key] ?? <DayBlock>{};
|
||||
if (!blocks.remove(block)) blocks.add(block);
|
||||
if (blocks.isEmpty) {
|
||||
map.remove(key);
|
||||
} else {
|
||||
map[key] = blocks;
|
||||
}
|
||||
_persist(map);
|
||||
}
|
||||
|
||||
/// The day toggle: all three blocks on, or the day cleared.
|
||||
void setDayAvailable(DateTime day, bool available) {
|
||||
final map = {
|
||||
for (final e in state.entries) e.key: {...e.value},
|
||||
};
|
||||
final key = dayKey(day);
|
||||
if (available) {
|
||||
map[key] = DayBlock.values.toSet();
|
||||
} else {
|
||||
map.remove(key);
|
||||
}
|
||||
_persist(map);
|
||||
}
|
||||
|
||||
/// Presets across the visible week: all days, weekdays only, weekends only.
|
||||
void applyPreset(
|
||||
List<DateTime> week, {
|
||||
required bool Function(DateTime) test,
|
||||
}) {
|
||||
final map = {
|
||||
for (final e in state.entries) e.key: {...e.value},
|
||||
};
|
||||
for (final day in week) {
|
||||
final key = dayKey(day);
|
||||
if (test(day)) {
|
||||
map[key] = DayBlock.values.toSet();
|
||||
} else {
|
||||
map.remove(key);
|
||||
}
|
||||
}
|
||||
_persist(map);
|
||||
}
|
||||
|
||||
void clearWeek(List<DateTime> week) {
|
||||
final map = {
|
||||
for (final e in state.entries) e.key: {...e.value},
|
||||
};
|
||||
for (final day in week) {
|
||||
map.remove(dayKey(day));
|
||||
}
|
||||
_persist(map);
|
||||
}
|
||||
}
|
||||
|
||||
final availabilityProvider =
|
||||
NotifierProvider<AvailabilityController, AvailabilityMap>(
|
||||
AvailabilityController.new,
|
||||
);
|
||||
|
||||
/// How many days in the coming week have at least one block switched on.
|
||||
final daysSetProvider = Provider<int>((ref) {
|
||||
final map = ref.watch(availabilityProvider);
|
||||
final now = DateTime.now();
|
||||
var count = 0;
|
||||
for (var i = 0; i < 7; i++) {
|
||||
final d = now.add(Duration(days: i));
|
||||
if ((map[dayKey(d)] ?? const <DayBlock>{}).isNotEmpty) count++;
|
||||
}
|
||||
return count;
|
||||
});
|
||||
178
lib/state/compliance_controller.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/compliance.dart';
|
||||
import 'seed.dart';
|
||||
|
||||
/// The four compliance sections, each a list the worker can act on.
|
||||
enum ComplianceSection { documents, certificates, taxForms, wardrobe }
|
||||
|
||||
extension ComplianceSectionX on ComplianceSection {
|
||||
String get title => switch (this) {
|
||||
ComplianceSection.documents => 'Documents',
|
||||
ComplianceSection.certificates => 'Certificates',
|
||||
ComplianceSection.taxForms => 'Tax forms',
|
||||
ComplianceSection.wardrobe => 'Attire',
|
||||
};
|
||||
|
||||
String get subtitle => switch (this) {
|
||||
ComplianceSection.documents => 'ID, SSN, work permit, address',
|
||||
ComplianceSection.certificates => 'Food handler, RBS and more',
|
||||
ComplianceSection.taxForms => 'Form I-9 and Form W-4',
|
||||
ComplianceSection.wardrobe => "Photos of what you'll wear",
|
||||
};
|
||||
}
|
||||
|
||||
class ComplianceData {
|
||||
const ComplianceData({
|
||||
required this.documents,
|
||||
required this.certificates,
|
||||
required this.taxForms,
|
||||
required this.wardrobe,
|
||||
});
|
||||
|
||||
final List<ComplianceItem> documents;
|
||||
final List<ComplianceItem> certificates;
|
||||
final List<ComplianceItem> taxForms;
|
||||
final List<ComplianceItem> wardrobe;
|
||||
|
||||
List<ComplianceItem> section(ComplianceSection s) => switch (s) {
|
||||
ComplianceSection.documents => documents,
|
||||
ComplianceSection.certificates => certificates,
|
||||
ComplianceSection.taxForms => taxForms,
|
||||
ComplianceSection.wardrobe => wardrobe,
|
||||
};
|
||||
|
||||
List<ComplianceItem> get all => [
|
||||
...documents,
|
||||
...certificates,
|
||||
...taxForms,
|
||||
...wardrobe,
|
||||
];
|
||||
|
||||
ComplianceData copyWith({
|
||||
List<ComplianceItem>? documents,
|
||||
List<ComplianceItem>? certificates,
|
||||
List<ComplianceItem>? taxForms,
|
||||
List<ComplianceItem>? wardrobe,
|
||||
}) => ComplianceData(
|
||||
documents: documents ?? this.documents,
|
||||
certificates: certificates ?? this.certificates,
|
||||
taxForms: taxForms ?? this.taxForms,
|
||||
wardrobe: wardrobe ?? this.wardrobe,
|
||||
);
|
||||
}
|
||||
|
||||
class ComplianceController extends Notifier<ComplianceData> {
|
||||
@override
|
||||
ComplianceData build() => ComplianceData(
|
||||
documents: Seed.documents(),
|
||||
certificates: Seed.certificates(),
|
||||
taxForms: Seed.taxForms(),
|
||||
wardrobe: Seed.wardrobe(),
|
||||
);
|
||||
|
||||
List<ComplianceItem> _apply(
|
||||
List<ComplianceItem> list,
|
||||
String id,
|
||||
ComplianceItem Function(ComplianceItem) f,
|
||||
) => [
|
||||
for (final i in list)
|
||||
if (i.id == id) f(i) else i,
|
||||
];
|
||||
|
||||
void _update(
|
||||
ComplianceSection section,
|
||||
String id,
|
||||
ComplianceItem Function(ComplianceItem) f,
|
||||
) {
|
||||
final s = state;
|
||||
state = switch (section) {
|
||||
ComplianceSection.documents => s.copyWith(
|
||||
documents: _apply(s.documents, id, f),
|
||||
),
|
||||
ComplianceSection.certificates => s.copyWith(
|
||||
certificates: _apply(s.certificates, id, f),
|
||||
),
|
||||
ComplianceSection.taxForms => s.copyWith(
|
||||
taxForms: _apply(s.taxForms, id, f),
|
||||
),
|
||||
ComplianceSection.wardrobe => s.copyWith(
|
||||
wardrobe: _apply(s.wardrobe, id, f),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Stand-in for the camera / file picker: attaching a file sends the item
|
||||
/// back for review.
|
||||
void uploadFile(ComplianceSection section, String id, String fileName) {
|
||||
_update(
|
||||
section,
|
||||
id,
|
||||
(i) => i.copyWith(
|
||||
state: ComplianceState.pending,
|
||||
statusLabel: 'In review',
|
||||
fileName: fileName,
|
||||
fileMeta: 'Uploaded just now',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void removeFile(ComplianceSection section, String id) {
|
||||
_update(
|
||||
section,
|
||||
id,
|
||||
(i) => ComplianceItem(
|
||||
id: i.id,
|
||||
title: i.title,
|
||||
subtitle: i.subtitle,
|
||||
state: ComplianceState.missing,
|
||||
statusLabel: 'Not added',
|
||||
required_: i.required_,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void setExpiry(ComplianceSection section, String id, DateTime date) {
|
||||
_update(section, id, (i) => i.copyWith(expires: date));
|
||||
}
|
||||
|
||||
ComplianceItem? find(ComplianceSection section, String id) {
|
||||
for (final i in state.section(section)) {
|
||||
if (i.id == id) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final complianceProvider =
|
||||
NotifierProvider<ComplianceController, ComplianceData>(
|
||||
ComplianceController.new,
|
||||
);
|
||||
|
||||
/// `(verified, total)` across every required item — what the hub reports.
|
||||
final complianceProgressProvider = Provider<(int, int)>((ref) {
|
||||
final items = ref.watch(complianceProvider).all.where((i) => i.required_);
|
||||
final total = items.length;
|
||||
final done = items.where((i) => i.state == ComplianceState.verified).length;
|
||||
return (done, total);
|
||||
});
|
||||
|
||||
/// Whether the worker is cleared to take shifts at all.
|
||||
final isCompliantProvider = Provider<bool>((ref) {
|
||||
final (done, total) = ref.watch(complianceProgressProvider);
|
||||
return done == total;
|
||||
});
|
||||
|
||||
final sectionProgressProvider = Provider.family<(int, int), ComplianceSection>((
|
||||
ref,
|
||||
section,
|
||||
) {
|
||||
final items = ref
|
||||
.watch(complianceProvider)
|
||||
.section(section)
|
||||
.where((i) => i.required_);
|
||||
return (
|
||||
items.where((i) => i.state == ComplianceState.verified).length,
|
||||
items.length,
|
||||
);
|
||||
});
|
||||
168
lib/state/profile_controller.dart
Normal file
@@ -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
@@ -0,0 +1,392 @@
|
||||
import '../models/compliance.dart';
|
||||
import '../models/shift.dart';
|
||||
import '../models/timecard.dart';
|
||||
|
||||
/// Demo content. Everything is anchored to "now" so the app is live whenever
|
||||
/// it is opened: the next shift always starts in 41 minutes.
|
||||
abstract final class Seed {
|
||||
static DateTime get _now => DateTime.now();
|
||||
|
||||
static DateTime _at(int dayOffset, int hour, int minute) {
|
||||
final base = _now.add(Duration(days: dayOffset));
|
||||
return DateTime(base.year, base.month, base.day, hour, minute);
|
||||
}
|
||||
|
||||
static const _baristaReqs = [
|
||||
'Government ID',
|
||||
'Food handler certification',
|
||||
'Black pants',
|
||||
'Black shirt',
|
||||
'Non-slip black shoes',
|
||||
];
|
||||
|
||||
static List<Shift> shifts() {
|
||||
final now = _now;
|
||||
// Inside the 30-minute clock-in window, so the app opens on a shift you
|
||||
// can actually start.
|
||||
final soon = now.add(const Duration(minutes: 20));
|
||||
return [
|
||||
// Today: the shift you are about to clock into.
|
||||
Shift(
|
||||
id: 's1',
|
||||
role: 'Barista',
|
||||
business: 'Zoox',
|
||||
venue: 'Zoox - San Jose Hub',
|
||||
address: '848 E Gish Rd, San Jose CA',
|
||||
start: soon,
|
||||
end: soon.add(const Duration(hours: 6)),
|
||||
rate: 21.00,
|
||||
status: ShiftStatus.booked,
|
||||
distanceMiles: 2.4,
|
||||
manager: 'Kirk R. - Owner',
|
||||
requirements: _baristaReqs,
|
||||
),
|
||||
Shift(
|
||||
id: 's2',
|
||||
role: 'Server',
|
||||
business: 'Google LLC',
|
||||
venue: 'Google LLC - Midpoint',
|
||||
address: 'Midpoint, Sunnyvale CA',
|
||||
start: _at(1, 13, 0),
|
||||
end: _at(1, 18, 0),
|
||||
rate: 24.00,
|
||||
status: ShiftStatus.booked,
|
||||
distanceMiles: 3.1,
|
||||
manager: 'Dana W. - Floor lead',
|
||||
requirements: _baristaReqs,
|
||||
),
|
||||
// Open market, highest paying first.
|
||||
Shift(
|
||||
id: 's3',
|
||||
role: 'Bartender',
|
||||
business: 'Google LLC',
|
||||
venue: 'Google LLC - Midpoint',
|
||||
address: 'Midpoint, Sunnyvale CA',
|
||||
start: _at(2, 16, 0),
|
||||
end: _at(2, 23, 0),
|
||||
rate: 28.00,
|
||||
status: ShiftStatus.open,
|
||||
distanceMiles: 3.1,
|
||||
requirements: [..._baristaReqs, 'Responsible beverage service'],
|
||||
),
|
||||
Shift(
|
||||
id: 's4',
|
||||
role: 'Barista',
|
||||
business: 'Zoox',
|
||||
venue: 'Zoox - San Jose Hub',
|
||||
address: '848 E Gish Rd, San Jose CA',
|
||||
start: _at(3, 18, 0),
|
||||
end: _at(3, 23, 50),
|
||||
rate: 21.00,
|
||||
status: ShiftStatus.open,
|
||||
distanceMiles: 2.4,
|
||||
requirements: _baristaReqs,
|
||||
),
|
||||
Shift(
|
||||
id: 's5',
|
||||
role: 'Host',
|
||||
business: 'Google LLC',
|
||||
venue: 'Google LLC - Midpoint',
|
||||
address: 'Midpoint, Sunnyvale CA',
|
||||
start: _at(4, 11, 0),
|
||||
end: _at(4, 17, 0),
|
||||
rate: 19.50,
|
||||
status: ShiftStatus.open,
|
||||
distanceMiles: 3.1,
|
||||
requirements: _baristaReqs,
|
||||
),
|
||||
Shift(
|
||||
id: 's6',
|
||||
role: 'Banquet server',
|
||||
business: 'Legendary Events',
|
||||
venue: 'Legendary Events - Convention Centre',
|
||||
address: '150 W San Carlos St, San Jose CA',
|
||||
start: _at(5, 9, 0),
|
||||
end: _at(5, 17, 30),
|
||||
rate: 22.50,
|
||||
status: ShiftStatus.open,
|
||||
distanceMiles: 4.8,
|
||||
requirements: _baristaReqs,
|
||||
),
|
||||
// Worked history.
|
||||
Shift(
|
||||
id: 's7',
|
||||
role: 'Barista',
|
||||
business: 'Zoox',
|
||||
venue: 'Zoox - San Jose Hub',
|
||||
address: '848 E Gish Rd, San Jose CA',
|
||||
start: _at(-4, 17, 50),
|
||||
end: _at(-4, 23, 50),
|
||||
rate: 21.00,
|
||||
status: ShiftStatus.completed,
|
||||
distanceMiles: 2.4,
|
||||
clockIn: _at(-4, 18, 1),
|
||||
clockOut: _at(-4, 23, 50),
|
||||
requirements: _baristaReqs,
|
||||
),
|
||||
Shift(
|
||||
id: 's8',
|
||||
role: 'Barista',
|
||||
business: 'Google LLC',
|
||||
venue: 'Google LLC - Midpoint',
|
||||
address: 'Midpoint, Sunnyvale CA',
|
||||
start: _at(-6, 11, 0),
|
||||
end: _at(-6, 18, 0),
|
||||
rate: 21.00,
|
||||
status: ShiftStatus.cancelled,
|
||||
distanceMiles: 3.1,
|
||||
requirements: _baristaReqs,
|
||||
),
|
||||
Shift(
|
||||
id: 's9',
|
||||
role: 'Barista',
|
||||
business: 'Google LLC',
|
||||
venue: 'Google LLC - Midpoint',
|
||||
address: 'Midpoint, Sunnyvale CA',
|
||||
start: _at(-13, 9, 0),
|
||||
end: _at(-13, 15, 0),
|
||||
rate: 21.00,
|
||||
status: ShiftStatus.noShow,
|
||||
distanceMiles: 3.1,
|
||||
requirements: _baristaReqs,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static List<Timecard> timecards() {
|
||||
final worked = shifts().firstWhere((s) => s.id == 's7');
|
||||
return [
|
||||
Timecard(
|
||||
id: 'tc1',
|
||||
shiftId: worked.id,
|
||||
role: worked.role,
|
||||
business: worked.business,
|
||||
scheduledStart: worked.start,
|
||||
scheduledEnd: worked.end,
|
||||
clockIn: worked.clockIn!,
|
||||
clockOut: worked.clockOut!,
|
||||
breakMinutes: worked.breakMinutes,
|
||||
rate: worked.rate,
|
||||
status: TimecardStatus.needsReview,
|
||||
submittedAt: worked.clockOut!.add(const Duration(minutes: 12)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static List<ComplianceItem> documents() => [
|
||||
ComplianceItem(
|
||||
id: 'd1',
|
||||
title: 'Government ID',
|
||||
subtitle: 'State ID or passport',
|
||||
state: ComplianceState.verified,
|
||||
fileName: 'government-id-front.jpg',
|
||||
fileMeta: '2.1 MB - uploaded 2 Aug',
|
||||
expires: DateTime(2029, 3, 14),
|
||||
),
|
||||
ComplianceItem(
|
||||
id: 'd2',
|
||||
title: 'Social security',
|
||||
subtitle: 'Card or SSA letter',
|
||||
state: ComplianceState.verified,
|
||||
fileName: 'ssn-card.jpg',
|
||||
fileMeta: '1.2 MB - uploaded 2 Aug',
|
||||
),
|
||||
ComplianceItem(
|
||||
id: 'd3',
|
||||
title: 'Work permit',
|
||||
subtitle: 'Right-to-work evidence',
|
||||
state: ComplianceState.verified,
|
||||
fileName: 'work-permit.pdf',
|
||||
fileMeta: '640 KB - uploaded 2 Aug',
|
||||
),
|
||||
ComplianceItem(
|
||||
id: 'd4',
|
||||
title: 'Proof of address',
|
||||
subtitle: 'Bill or statement, last 90 days',
|
||||
state: ComplianceState.pending,
|
||||
statusLabel: 'In review',
|
||||
fileName: 'utility-bill.pdf',
|
||||
fileMeta: '380 KB - uploaded yesterday',
|
||||
),
|
||||
];
|
||||
|
||||
static List<ComplianceItem> certificates() => [
|
||||
ComplianceItem(
|
||||
id: 'c1',
|
||||
title: 'Food handler certification',
|
||||
subtitle: 'Accepted by all California venues',
|
||||
state: ComplianceState.verified,
|
||||
statusLabel: 'Validated',
|
||||
fileName: 'food-safety-certificate.pdf',
|
||||
fileMeta: '840 KB - uploaded 2 Aug',
|
||||
expires: DateTime(2027, 3, 14),
|
||||
),
|
||||
ComplianceItem(
|
||||
id: 'c2',
|
||||
title: 'Responsible beverage service',
|
||||
subtitle: 'Needed for bartender shifts',
|
||||
state: ComplianceState.verified,
|
||||
statusLabel: 'Validated',
|
||||
fileName: 'rbs-certificate.pdf',
|
||||
fileMeta: '510 KB - uploaded 2 Aug',
|
||||
expires: DateTime(2028, 1, 9),
|
||||
),
|
||||
ComplianceItem(
|
||||
id: 'c3',
|
||||
title: 'CPR / AED',
|
||||
subtitle: 'Adds around 4 shifts a week near you',
|
||||
state: ComplianceState.missing,
|
||||
statusLabel: 'Not added',
|
||||
required_: false,
|
||||
),
|
||||
ComplianceItem(
|
||||
id: 'c4',
|
||||
title: 'Forklift licence',
|
||||
subtitle: 'Warehouse and logistics roles',
|
||||
state: ComplianceState.missing,
|
||||
statusLabel: 'Not added',
|
||||
required_: false,
|
||||
),
|
||||
];
|
||||
|
||||
static List<ComplianceItem> taxForms() => [
|
||||
ComplianceItem(
|
||||
id: 't1',
|
||||
title: 'Form I-9',
|
||||
subtitle: 'Employment eligibility verification',
|
||||
state: ComplianceState.verified,
|
||||
statusLabel: 'Approved',
|
||||
fileName: 'i9-form.pdf',
|
||||
fileMeta: 'Approved 3 Aug',
|
||||
),
|
||||
ComplianceItem(
|
||||
id: 't2',
|
||||
title: 'Form W-4',
|
||||
subtitle: 'Sets how much tax is withheld',
|
||||
state: ComplianceState.verified,
|
||||
statusLabel: 'Approved',
|
||||
fileName: 'w4-form.pdf',
|
||||
fileMeta: 'Approved 3 Aug',
|
||||
),
|
||||
];
|
||||
|
||||
static List<ComplianceItem> wardrobe() => [
|
||||
ComplianceItem(
|
||||
id: 'w1',
|
||||
title: 'Black pants',
|
||||
subtitle: 'Solid black dress pants or slacks',
|
||||
state: ComplianceState.verified,
|
||||
statusLabel: 'Approved',
|
||||
fileName: 'black-pants.jpg',
|
||||
fileMeta: '1.4 MB - uploaded 2 Aug',
|
||||
),
|
||||
ComplianceItem(
|
||||
id: 'w2',
|
||||
title: 'Black shirt',
|
||||
subtitle: 'Plain, no logos',
|
||||
state: ComplianceState.verified,
|
||||
statusLabel: 'Approved',
|
||||
fileName: 'black-shirt.jpg',
|
||||
fileMeta: '1.1 MB - uploaded 2 Aug',
|
||||
),
|
||||
ComplianceItem(
|
||||
id: 'w3',
|
||||
title: 'Non-slip black shoes',
|
||||
subtitle: 'Closed-toe, required on every shift',
|
||||
state: ComplianceState.verified,
|
||||
statusLabel: 'Approved',
|
||||
fileName: 'black-shoes.jpg',
|
||||
fileMeta: '0.9 MB - uploaded 2 Aug',
|
||||
),
|
||||
ComplianceItem(
|
||||
id: 'w4',
|
||||
title: 'Black belt',
|
||||
subtitle: 'Plain, simple buckle',
|
||||
state: ComplianceState.missing,
|
||||
statusLabel: 'Not added',
|
||||
required_: false,
|
||||
),
|
||||
];
|
||||
|
||||
static const workSettings = <String>[
|
||||
'Hospitality',
|
||||
'Restaurant',
|
||||
'Warehouse',
|
||||
'Logistics',
|
||||
'Events',
|
||||
'Stadiums',
|
||||
'Education',
|
||||
'Healthcare & cafeteria',
|
||||
'Convention centre',
|
||||
'Catering',
|
||||
];
|
||||
|
||||
static const skills = <String>[
|
||||
'Food prep',
|
||||
'Cooking',
|
||||
'Grilling',
|
||||
'Dishwashing',
|
||||
'Food service',
|
||||
'Banquet',
|
||||
'Bartender',
|
||||
'Barista',
|
||||
'Customer service',
|
||||
'Safe food handling',
|
||||
'Event setup',
|
||||
];
|
||||
|
||||
static const supportTopics = <String>[
|
||||
'Shift issue',
|
||||
'Pay or billing',
|
||||
'Schedule clash',
|
||||
'Account',
|
||||
'Bug report',
|
||||
'Feature idea',
|
||||
];
|
||||
|
||||
static const faqs = <String, List<(String, String)>>{
|
||||
'Getting started': [
|
||||
(
|
||||
'How do I finish my worker profile?',
|
||||
'Open Profile, then Compliance. Work down the sections until every one '
|
||||
'shows a green tick. You can accept shifts as soon as documents, '
|
||||
'tax forms and attire are complete.',
|
||||
),
|
||||
(
|
||||
"Why can't I apply to shifts yet?",
|
||||
'Businesses only see workers who are fully compliant. Anything still '
|
||||
'marked yellow on the compliance hub is blocking you.',
|
||||
),
|
||||
],
|
||||
'Shifts and attendance': [
|
||||
(
|
||||
'How does clock-in work?',
|
||||
'The clock-in button unlocks 30 minutes before your shift starts, once '
|
||||
'you are within 150 m of the venue. Tap out when you leave and the '
|
||||
'timecard submits itself.',
|
||||
),
|
||||
(
|
||||
'What happens if I request a swap?',
|
||||
'Your shift is offered to other qualified workers. You stay responsible '
|
||||
'for it until someone accepts, so keep an eye on notifications.',
|
||||
),
|
||||
(
|
||||
'What counts as a no-show?',
|
||||
'Not clocking in within 30 minutes of the scheduled start, with no '
|
||||
'cancellation. No-shows cost 15 points of trust index each.',
|
||||
),
|
||||
],
|
||||
'Pay and compliance': [
|
||||
(
|
||||
'When do I actually get paid?',
|
||||
'Semi-monthly. Each period closes on the 15th and the last day of the '
|
||||
'month, and pays 11 days later.',
|
||||
),
|
||||
(
|
||||
'How are documents verified?',
|
||||
'A reviewer checks each upload by hand, usually within one business '
|
||||
'day. You get a notification either way.',
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
115
lib/state/session_controller.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Injected in `main()` once SharedPreferences has loaded.
|
||||
final prefsProvider = Provider<SharedPreferences>(
|
||||
(ref) => throw UnimplementedError('prefsProvider must be overridden'),
|
||||
);
|
||||
|
||||
class SessionState {
|
||||
const SessionState({
|
||||
this.onboardingSeen = false,
|
||||
this.phone,
|
||||
this.signedIn = false,
|
||||
this.locationGranted = false,
|
||||
this.notificationsGranted = false,
|
||||
this.cameraGranted = false,
|
||||
});
|
||||
|
||||
final bool onboardingSeen;
|
||||
final String? phone;
|
||||
final bool signedIn;
|
||||
final bool locationGranted;
|
||||
final bool notificationsGranted;
|
||||
final bool cameraGranted;
|
||||
|
||||
/// The three-step permission run shown right after verification.
|
||||
bool get permissionsDone =>
|
||||
locationGranted && notificationsGranted && cameraGranted;
|
||||
|
||||
SessionState copyWith({
|
||||
bool? onboardingSeen,
|
||||
String? phone,
|
||||
bool? signedIn,
|
||||
bool? locationGranted,
|
||||
bool? notificationsGranted,
|
||||
bool? cameraGranted,
|
||||
}) => SessionState(
|
||||
onboardingSeen: onboardingSeen ?? this.onboardingSeen,
|
||||
phone: phone ?? this.phone,
|
||||
signedIn: signedIn ?? this.signedIn,
|
||||
locationGranted: locationGranted ?? this.locationGranted,
|
||||
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
|
||||
cameraGranted: cameraGranted ?? this.cameraGranted,
|
||||
);
|
||||
}
|
||||
|
||||
class SessionController extends Notifier<SessionState> {
|
||||
static const _kOnboarding = 'onboarding_seen';
|
||||
static const _kPhone = 'phone';
|
||||
static const _kSignedIn = 'signed_in';
|
||||
static const _kLocation = 'perm_location';
|
||||
static const _kNotifications = 'perm_notifications';
|
||||
static const _kCamera = 'perm_camera';
|
||||
|
||||
SharedPreferences get _prefs => ref.read(prefsProvider);
|
||||
|
||||
@override
|
||||
SessionState build() {
|
||||
final p = _prefs;
|
||||
return SessionState(
|
||||
onboardingSeen: p.getBool(_kOnboarding) ?? false,
|
||||
phone: p.getString(_kPhone),
|
||||
signedIn: p.getBool(_kSignedIn) ?? false,
|
||||
locationGranted: p.getBool(_kLocation) ?? false,
|
||||
notificationsGranted: p.getBool(_kNotifications) ?? false,
|
||||
cameraGranted: p.getBool(_kCamera) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
void completeOnboarding() {
|
||||
_prefs.setBool(_kOnboarding, true);
|
||||
state = state.copyWith(onboardingSeen: true);
|
||||
}
|
||||
|
||||
/// The demo backend accepts any 10-digit number and always sends `123456`.
|
||||
static const demoCode = '123456';
|
||||
|
||||
String sendCode(String phone) {
|
||||
_prefs.setString(_kPhone, phone);
|
||||
state = state.copyWith(phone: phone);
|
||||
return demoCode;
|
||||
}
|
||||
|
||||
bool verifyCode(String code) {
|
||||
if (code != demoCode) return false;
|
||||
_prefs.setBool(_kSignedIn, true);
|
||||
state = state.copyWith(signedIn: true);
|
||||
return true;
|
||||
}
|
||||
|
||||
void grantLocation() {
|
||||
_prefs.setBool(_kLocation, true);
|
||||
state = state.copyWith(locationGranted: true);
|
||||
}
|
||||
|
||||
void grantNotifications() {
|
||||
_prefs.setBool(_kNotifications, true);
|
||||
state = state.copyWith(notificationsGranted: true);
|
||||
}
|
||||
|
||||
void grantCamera() {
|
||||
_prefs.setBool(_kCamera, true);
|
||||
state = state.copyWith(cameraGranted: true);
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
await _prefs.remove(_kSignedIn);
|
||||
await _prefs.remove(_kPhone);
|
||||
state = state.copyWith(signedIn: false, phone: '');
|
||||
}
|
||||
}
|
||||
|
||||
final sessionProvider = NotifierProvider<SessionController, SessionState>(
|
||||
SessionController.new,
|
||||
);
|
||||
172
lib/state/shifts_controller.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/formatters.dart';
|
||||
import '../models/shift.dart';
|
||||
import '../models/timecard.dart';
|
||||
import 'seed.dart';
|
||||
import 'timecards_controller.dart';
|
||||
|
||||
class ShiftsController extends Notifier<List<Shift>> {
|
||||
@override
|
||||
List<Shift> build() => Seed.shifts();
|
||||
|
||||
Shift? byId(String id) {
|
||||
for (final s in state) {
|
||||
if (s.id == id) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _replace(Shift updated) {
|
||||
state = [
|
||||
for (final s in state)
|
||||
if (s.id == updated.id) updated else s,
|
||||
];
|
||||
}
|
||||
|
||||
/// Accept an open shift. It moves straight into Booked.
|
||||
void apply(String id) {
|
||||
final shift = byId(id);
|
||||
if (shift == null || shift.status != ShiftStatus.open) return;
|
||||
_replace(shift.copyWith(status: ShiftStatus.booked));
|
||||
}
|
||||
|
||||
/// Give a booked shift back. Counts against the cancellation tally.
|
||||
void cancel(String id) {
|
||||
final shift = byId(id);
|
||||
if (shift == null) return;
|
||||
_replace(shift.copyWith(status: ShiftStatus.cancelled));
|
||||
}
|
||||
|
||||
void clockIn(String id) {
|
||||
final shift = byId(id);
|
||||
if (shift == null || shift.status != ShiftStatus.booked) return;
|
||||
_replace(
|
||||
shift.copyWith(status: ShiftStatus.active, clockIn: DateTime.now()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Clocking out completes the shift and files the timecard automatically.
|
||||
Timecard? clockOut(String id) {
|
||||
final shift = byId(id);
|
||||
if (shift == null || shift.status != ShiftStatus.active) return null;
|
||||
final now = DateTime.now();
|
||||
final done = shift.copyWith(status: ShiftStatus.completed, clockOut: now);
|
||||
_replace(done);
|
||||
|
||||
final card = Timecard(
|
||||
id: 'tc-${shift.id}-${now.millisecondsSinceEpoch}',
|
||||
shiftId: shift.id,
|
||||
role: shift.role,
|
||||
business: shift.business,
|
||||
scheduledStart: shift.start,
|
||||
scheduledEnd: shift.end,
|
||||
clockIn: done.clockIn!,
|
||||
clockOut: now,
|
||||
breakMinutes: shift.breakMinutes,
|
||||
rate: shift.rate,
|
||||
status: TimecardStatus.needsReview,
|
||||
submittedAt: now,
|
||||
);
|
||||
ref.read(timecardsProvider.notifier).add(card);
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
final shiftsProvider = NotifierProvider<ShiftsController, List<Shift>>(
|
||||
ShiftsController.new,
|
||||
);
|
||||
|
||||
final bookedShiftsProvider = Provider<List<Shift>>((ref) {
|
||||
final list =
|
||||
ref
|
||||
.watch(shiftsProvider)
|
||||
.where(
|
||||
(s) =>
|
||||
s.status == ShiftStatus.booked ||
|
||||
s.status == ShiftStatus.active,
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => a.start.compareTo(b.start));
|
||||
return list;
|
||||
});
|
||||
|
||||
final openShiftsProvider = Provider<List<Shift>>((ref) {
|
||||
final list =
|
||||
ref
|
||||
.watch(shiftsProvider)
|
||||
.where((s) => s.status == ShiftStatus.open)
|
||||
.toList()
|
||||
..sort((a, b) => b.rate.compareTo(a.rate));
|
||||
return list;
|
||||
});
|
||||
|
||||
final historyShiftsProvider = Provider<List<Shift>>((ref) {
|
||||
final list =
|
||||
ref
|
||||
.watch(shiftsProvider)
|
||||
.where(
|
||||
(s) => const {
|
||||
ShiftStatus.completed,
|
||||
ShiftStatus.cancelled,
|
||||
ShiftStatus.noShow,
|
||||
}.contains(s.status),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => b.start.compareTo(a.start));
|
||||
return list;
|
||||
});
|
||||
|
||||
/// The shift the Today screen leads with: whatever is running now, else the
|
||||
/// next one on the calendar.
|
||||
final nextShiftProvider = Provider<Shift?>((ref) {
|
||||
final booked = ref.watch(bookedShiftsProvider);
|
||||
for (final s in booked) {
|
||||
if (s.status == ShiftStatus.active) return s;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
for (final s in booked) {
|
||||
if (s.end.isAfter(now)) return s;
|
||||
}
|
||||
return booked.isEmpty ? null : booked.first;
|
||||
});
|
||||
|
||||
final activeShiftProvider = Provider<Shift?>((ref) {
|
||||
for (final s in ref.watch(shiftsProvider)) {
|
||||
if (s.status == ShiftStatus.active) return s;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
/// Gross pay, pre-tax, already banked from completed shifts.
|
||||
final completedEarningsProvider = Provider<double>((ref) {
|
||||
return ref
|
||||
.watch(shiftsProvider)
|
||||
.where((s) => s.status == ShiftStatus.completed)
|
||||
.fold<double>(0, (sum, s) => sum + s.pay);
|
||||
});
|
||||
|
||||
final hoursLoggedProvider = Provider<Duration>((ref) {
|
||||
return ref
|
||||
.watch(shiftsProvider)
|
||||
.where((s) => s.status == ShiftStatus.completed)
|
||||
.fold<Duration>(
|
||||
Duration.zero,
|
||||
(sum, s) => sum + (s.workedDuration ?? Duration.zero),
|
||||
);
|
||||
});
|
||||
|
||||
/// Booked but not yet worked: the money still coming.
|
||||
final scheduledEarningsProvider = Provider<double>((ref) {
|
||||
return ref
|
||||
.watch(bookedShiftsProvider)
|
||||
.fold<double>(0, (sum, s) => sum + s.scheduledPay);
|
||||
});
|
||||
|
||||
/// Shifts falling on a given calendar day, used by the week strip.
|
||||
final shiftsOnDayProvider = Provider.family<List<Shift>, DateTime>((ref, day) {
|
||||
return ref
|
||||
.watch(bookedShiftsProvider)
|
||||
.where((s) => Fmt.sameDay(s.start, day))
|
||||
.toList();
|
||||
});
|
||||
49
lib/state/timecards_controller.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/timecard.dart';
|
||||
import 'seed.dart';
|
||||
|
||||
class TimecardsController extends Notifier<List<Timecard>> {
|
||||
@override
|
||||
List<Timecard> build() => Seed.timecards();
|
||||
|
||||
void add(Timecard card) {
|
||||
state = [card, ...state];
|
||||
}
|
||||
|
||||
void approve(String id) {
|
||||
state = [
|
||||
for (final t in state)
|
||||
if (t.id == id) t.copyWith(status: TimecardStatus.approved) else t,
|
||||
];
|
||||
}
|
||||
|
||||
void dispute(String id, String note) {
|
||||
state = [
|
||||
for (final t in state)
|
||||
if (t.id == id)
|
||||
t.copyWith(status: TimecardStatus.disputed, disputeNote: note)
|
||||
else
|
||||
t,
|
||||
];
|
||||
}
|
||||
|
||||
Timecard? byId(String id) {
|
||||
for (final t in state) {
|
||||
if (t.id == id) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final timecardsProvider = NotifierProvider<TimecardsController, List<Timecard>>(
|
||||
TimecardsController.new,
|
||||
);
|
||||
|
||||
/// Timecards still waiting on the worker to confirm or dispute.
|
||||
final timecardsNeedingReviewProvider = Provider<List<Timecard>>((ref) {
|
||||
return ref
|
||||
.watch(timecardsProvider)
|
||||
.where((t) => t.status == TimecardStatus.needsReview)
|
||||
.toList();
|
||||
});
|
||||
137
lib/widgets/collapsing_header.dart
Normal file
@@ -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
@@ -0,0 +1,835 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../core/theme.dart';
|
||||
|
||||
/// Small uppercase section heading used above every group of cards.
|
||||
class SectionLabel extends StatelessWidget {
|
||||
const SectionLabel(this.text, {super.key, this.trailing});
|
||||
|
||||
final String text;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// No horizontal inset: the label has to share a left edge with the cards
|
||||
// and body copy it sits above.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 22, 0, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
text.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 1.4,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum ChipTone { blue, yellow, neutral, muted, danger }
|
||||
|
||||
/// Status pill. Yellow marks anything waiting on the worker.
|
||||
class StatusChip extends StatelessWidget {
|
||||
const StatusChip(
|
||||
this.label, {
|
||||
super.key,
|
||||
this.tone = ChipTone.neutral,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final ChipTone tone;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (bg, fg) = switch (tone) {
|
||||
ChipTone.blue => (KrowPalette.blueTint, KrowPalette.blue),
|
||||
ChipTone.yellow => (KrowPalette.yellow, KrowPalette.ink),
|
||||
ChipTone.neutral => (const Color(0xFFDEEBE2), KrowPalette.ink),
|
||||
ChipTone.muted => (const Color(0xFFDEEBE2), const Color(0xFF65727A)),
|
||||
ChipTone.danger => (const Color(0xFFFBE3E3), const Color(0xFFA6403F)),
|
||||
};
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(icon == null ? 10 : 8, 6, 10, 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 13, color: fg),
|
||||
const SizedBox(width: 5),
|
||||
],
|
||||
// Flexible so the chip gives way when the row it sits in runs out of
|
||||
// width — at a large text scale it otherwise overflows its parent.
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Soft informational panel. `warning: true` switches it to the yellow tone.
|
||||
class InfoBanner extends StatelessWidget {
|
||||
const InfoBanner(this.text, {super.key, this.warning = false, this.icon});
|
||||
|
||||
final String text;
|
||||
final bool warning;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bg = warning ? const Color(0xFFF7E4AE) : KrowPalette.blueTint;
|
||||
final fg = warning ? KrowPalette.ink : KrowPalette.blueDeep;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? (warning ? LucideIcons.triangleAlert : LucideIcons.info),
|
||||
size: 16,
|
||||
color: fg,
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(fontSize: 12.5, height: 1.5, color: fg),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// White rounded container used for every grouped block.
|
||||
class KrowCard extends StatelessWidget {
|
||||
const KrowCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(16),
|
||||
this.onTap,
|
||||
this.dashed = false,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final VoidCallback? onTap;
|
||||
final bool dashed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: dashed ? Colors.transparent : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: KrowPalette.line),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A navigation row: icon tile, title, subtitle, optional badge, chevron.
|
||||
class NavTile extends StatelessWidget {
|
||||
const NavTile({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.badge,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? badge;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: KrowPalette.blueTint,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: KrowPalette.blue),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (subtitle != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
subtitle!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1.35,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (badge != null) ...[const SizedBox(width: 8), badge!],
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 17,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups NavTiles into a single card with hairline dividers between rows.
|
||||
class TileGroup extends StatelessWidget {
|
||||
const TileGroup({super.key, required this.children});
|
||||
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return KrowCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
if (i > 0) const Divider(height: 1),
|
||||
children[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dotted-leader row: label on the left, value on the right.
|
||||
class DetailRow extends StatelessWidget {
|
||||
const DetailRow(
|
||||
this.label,
|
||||
this.value, {
|
||||
super.key,
|
||||
this.emphasis = false,
|
||||
this.valueColor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final bool emphasis;
|
||||
final Color? valueColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: emphasis ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: emphasis ? 16 : 13.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
color: valueColor ?? KrowPalette.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Three-up figure block: caption over a big number.
|
||||
///
|
||||
/// Both lines are pinned to a fixed height and scale down instead of wrapping.
|
||||
/// A wrapping caption or figure — "CLOCK OUT", "11:50 PM" — would make one
|
||||
/// card taller than the two beside it and leave the row ragged.
|
||||
class StatSlab extends StatelessWidget {
|
||||
const StatSlab(this.caption, this.value, {super.key, this.valueColor});
|
||||
|
||||
final String caption;
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
|
||||
static const _captionHeight = 13.0;
|
||||
static const _valueHeight = 25.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return KrowCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: _captionHeight,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
caption.toUpperCase(),
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 1.2,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: _valueHeight,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 17.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.6,
|
||||
color: valueColor ?? KrowPalette.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lays out slabs as an evenly spaced row of equal-height cards.
|
||||
class StatSlabRow extends StatelessWidget {
|
||||
const StatSlabRow({super.key, required this.slabs});
|
||||
|
||||
final List<StatSlab> slabs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// These rows live inside scrollables, where the height is unbounded, so
|
||||
// `stretch` alone would try to fill infinity. IntrinsicHeight gives the
|
||||
// row the height of its tallest card first, and stretch then matches the
|
||||
// others to it.
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < slabs.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 9),
|
||||
Expanded(child: slabs[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal rail of small stats, divided by hairlines.
|
||||
class StatRail extends StatelessWidget {
|
||||
const StatRail({super.key, required this.stats});
|
||||
|
||||
final List<(String, String)> stats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return KrowCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < stats.length; i++) ...[
|
||||
if (i > 0) const VerticalDivider(width: 1),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
stats[i].$2,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
stats[i].$1.toUpperCase(),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 8.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.6,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress that grows into place rather than snapping, so a change — an
|
||||
/// uploaded document, a day worked — is something you see happen.
|
||||
class KrowProgressBar extends StatelessWidget {
|
||||
const KrowProgressBar(this.value, {super.key, this.color});
|
||||
|
||||
final double value;
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: value.clamp(0, 1)),
|
||||
duration: const Duration(milliseconds: 600),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, animated, _) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
value: animated,
|
||||
minHeight: 6,
|
||||
backgroundColor: KrowPalette.line,
|
||||
valueColor: AlwaysStoppedAnimation(color ?? KrowPalette.blue),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyState extends StatelessWidget {
|
||||
const EmptyState({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.message,
|
||||
this.action,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String message;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 34),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: KrowPalette.line, style: BorderStyle.solid),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: KrowPalette.line),
|
||||
),
|
||||
child: Icon(icon, size: 24, color: const Color(0xFF949FA5)),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
color: KrowPalette.mute,
|
||||
),
|
||||
),
|
||||
if (action != null) ...[const SizedBox(height: 16), action!],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Business identity tile used on every shift card.
|
||||
class BusinessBadge extends StatelessWidget {
|
||||
const BusinessBadge(this.letter, {super.key, this.size = 46});
|
||||
|
||||
final String letter;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: KrowPalette.blue,
|
||||
borderRadius: BorderRadius.circular(size / 3.2),
|
||||
),
|
||||
child: Text(
|
||||
letter,
|
||||
style: TextStyle(
|
||||
fontSize: size * 0.42,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small icon + text pair used for shift metadata.
|
||||
class MetaPill extends StatelessWidget {
|
||||
const MetaPill(this.icon, this.text, {super.key});
|
||||
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x0F26353E),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 12, color: const Color(0xFF949FA5)),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The blue ground every header sits on: brand colour, rounded bottom, and two
|
||||
/// soft glows. The glows are quiet on their own, but they give the frosted
|
||||
/// controls sitting on top something to refract.
|
||||
class BrandSurface extends StatelessWidget {
|
||||
const BrandSurface({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
color: KrowPalette.blue,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: -70,
|
||||
right: -40,
|
||||
child: _Glow(220, Colors.white.withValues(alpha: 0.16)),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -90,
|
||||
left: -50,
|
||||
child: _Glow(240, KrowPalette.yellow.withValues(alpha: 0.14)),
|
||||
),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The blue header block that tops a pushed detail screen. Main tabs use
|
||||
/// KrowSliverHeader instead, so their header stays put while content scrolls.
|
||||
class BrandHeader extends StatelessWidget {
|
||||
const BrandHeader({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BrandSurface(
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(padding: padding, child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A soft circular bloom. Drawn as a radial gradient rather than a blurred
|
||||
/// shape, which costs nothing per frame.
|
||||
class _Glow extends StatelessWidget {
|
||||
const _Glow(this.size, this.color);
|
||||
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(colors: [color, color.withValues(alpha: 0)]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Round icon button used in headers, on light or dark ground.
|
||||
class HeaderIconButton extends StatelessWidget {
|
||||
const HeaderIconButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.onDark = true,
|
||||
this.tooltip,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
final bool onDark;
|
||||
final String? tooltip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: onDark ? Colors.white24 : Colors.white,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
child: Tooltip(
|
||||
message: tooltip ?? '',
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
border: onDark ? null : Border.all(color: KrowPalette.line),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 19,
|
||||
color: onDark ? Colors.white : KrowPalette.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple bar chart used on the earnings screens.
|
||||
class MiniBarChart extends StatelessWidget {
|
||||
const MiniBarChart({super.key, required this.bars});
|
||||
|
||||
/// `(label, value, isPaid)` — paid bars read blue, pending read yellow.
|
||||
final List<(String, double, bool)> bars;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final max = bars.fold<double>(0, (m, b) => b.$2 > m ? b.$2 : m);
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 118,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (final bar in bars)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
// Bars grow to their new height when the range changes,
|
||||
// so you can see which column moved.
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(
|
||||
end: max == 0
|
||||
? 0.0
|
||||
: (bar.$2 / max).clamp(0.0, 1.0).toDouble(),
|
||||
),
|
||||
duration: const Duration(milliseconds: 520),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, height, _) => FractionallySizedBox(
|
||||
heightFactor: height,
|
||||
child: TweenAnimationBuilder<Color?>(
|
||||
tween: ColorTween(
|
||||
end: bar.$3 ? KrowPalette.blue : KrowPalette.yellow,
|
||||
),
|
||||
duration: const Duration(milliseconds: 320),
|
||||
builder: (context, color, _) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(6),
|
||||
bottom: Radius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
Row(
|
||||
children: [
|
||||
for (final bar in bars)
|
||||
Expanded(
|
||||
child: Text(
|
||||
bar.$1,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Legend dot + label, paired with the chart.
|
||||
class LegendDot extends StatelessWidget {
|
||||
const LegendDot(this.color, this.label, {super.key});
|
||||
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, color: KrowPalette.mute),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
191
lib/widgets/glass_segments.dart
Normal file
@@ -0,0 +1,191 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../core/theme.dart';
|
||||
|
||||
/// A frosted-glass segmented control for use on the brand header.
|
||||
///
|
||||
/// The track is translucent and blurs whatever sits behind it, with a bright
|
||||
/// top edge and a darker bottom edge so it reads as a pane of glass rather
|
||||
/// than a flat tint. The selected segment is a brighter frosted pill that
|
||||
/// slides between positions.
|
||||
class GlassSegments<T> extends StatelessWidget {
|
||||
const GlassSegments({
|
||||
super.key,
|
||||
required this.segments,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.height = 44,
|
||||
});
|
||||
|
||||
/// Each segment as `(value, label)`.
|
||||
final List<(T, String)> segments;
|
||||
final T value;
|
||||
final ValueChanged<T> onChanged;
|
||||
final double height;
|
||||
|
||||
static const _radius = 15.0;
|
||||
static const _pad = 4.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final index = segments.indexWhere((s) => s.$1 == value);
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(_radius),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(_radius),
|
||||
// Light falling across the pane: brighter at the top edge.
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.26),
|
||||
Colors.white.withValues(alpha: 0.12),
|
||||
],
|
||||
),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.28),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(_pad),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final segmentWidth = constraints.maxWidth / segments.length;
|
||||
|
||||
// One animation drives everything: the pill's position, its
|
||||
// stretch, and each label's colour. Animating the selected
|
||||
// index as a double — rather than moving the pill and flipping
|
||||
// the labels separately — is what makes the labels light up
|
||||
// exactly as the pill arrives over them. Retargeting mid-flight
|
||||
// continues from wherever the pill currently is.
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: index.toDouble()),
|
||||
duration: const Duration(milliseconds: 340),
|
||||
curve: Curves.easeOutQuint,
|
||||
builder: (context, position, _) {
|
||||
// Squash and stretch: the pill widens at speed and settles
|
||||
// back to size as it lands.
|
||||
final travel = (position - position.roundToDouble()).abs();
|
||||
final stretch = segmentWidth * 0.12 * (travel * 2);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
if (index >= 0)
|
||||
Positioned(
|
||||
left: position * segmentWidth - stretch / 2,
|
||||
width: segmentWidth + stretch,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: const _SelectedPill(),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
for (var i = 0; i < segments.length; i++)
|
||||
Expanded(
|
||||
child: _Segment(
|
||||
label: segments[i].$2,
|
||||
// 1 when the pill is centred on this label,
|
||||
// 0 once it has moved a full segment away.
|
||||
selectedAmount: (1 - (position - i).abs())
|
||||
.clamp(0.0, 1.0),
|
||||
onTap: () {
|
||||
if (segments[i].$1 == value) return;
|
||||
HapticFeedback.selectionClick();
|
||||
onChanged(segments[i].$1);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectedPill extends StatelessWidget {
|
||||
const _SelectedPill();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.96),
|
||||
Colors.white.withValues(alpha: 0.86),
|
||||
],
|
||||
),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.9)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: KrowPalette.blueDeep.withValues(alpha: 0.28),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Segment extends StatelessWidget {
|
||||
const _Segment({
|
||||
required this.label,
|
||||
required this.selectedAmount,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
|
||||
/// How far the travelling pill has arrived over this label, 0 to 1.
|
||||
final double selectedAmount;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
// Weight is snapped to the nearest real weight; the eye reads the
|
||||
// colour ramp as the smooth part.
|
||||
fontWeight: selectedAmount > 0.5
|
||||
? FontWeight.w700
|
||||
: FontWeight.w600,
|
||||
color: Color.lerp(
|
||||
Colors.white.withValues(alpha: 0.82),
|
||||
KrowPalette.blue,
|
||||
Curves.easeIn.transform(selectedAmount),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
215
lib/widgets/krow_nav_bar.dart
Normal file
@@ -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
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||