277 lines
14 KiB
Markdown
277 lines
14 KiB
Markdown
# Design
|
|
|
|
How the KROW worker app is put together, and why. [README.md](README.md)
|
|
covers what the app *does*; this covers how it looks, moves and is wired.
|
|
|
|
The HTML prototype was the reference for screens and flow, not a pixel spec.
|
|
Where this document and the prototype disagree, this document is what shipped.
|
|
|
|
## Who it is for
|
|
|
|
A shift worker, standing up, one hand on the phone, often about to start or
|
|
just finishing a shift. Every decision below follows from that:
|
|
|
|
- **One thumb.** Primary actions sit at the bottom of the screen. Buttons are
|
|
52px tall and full width by default.
|
|
- **Glanceable.** A worker checking whether they are late should not have to
|
|
read. Status is carried by colour and position before it is carried by text.
|
|
- **Nothing blocks the run.** A refused permission, a failed lookup or a
|
|
missing document narrows what the app can do; it never dead-ends the worker
|
|
on a step they cannot pass.
|
|
|
|
## Brand
|
|
|
|
Three colours do the work, defined once in `KrowPalette`
|
|
([theme.dart](lib/core/theme.dart)):
|
|
|
|
| Token | Value | Role |
|
|
|---|---|---|
|
|
| `blue` | `#3752A8` | 286CP. Every primary action, every header ground. |
|
|
| `blueDeep` | `#22366F` | The darker end of header gradients. |
|
|
| `blueTint` | `#E1E6F4` | Icon tiles and quiet blue fills. |
|
|
| `yellow` | `#F5C842` | 106CP. **Money, and anything waiting on the worker.** |
|
|
| `yellowSoft` | `#F7E4AE` | Yellow backgrounds that text sits on. |
|
|
| `sage` | `#E9F3EC` | 621CP. A tint for quiet inset blocks, not the canvas. |
|
|
| `ink` | `#26353E` | Body text, and the backdrop behind the phone frame. |
|
|
| `line` | `#CFDDD3` | Hairline borders. Carries every card edge. |
|
|
| `mute` | `#65727A` | Secondary text. |
|
|
|
|
The yellow rule is the one worth protecting. Yellow means *this is money* or
|
|
*this needs you* - pay figures, a timecard awaiting review, a missing
|
|
document. Using it decoratively costs the app its only urgency signal.
|
|
|
|
**Type** is Poppins throughout, pulled by `google_fonts` and applied as a whole
|
|
text theme so no screen picks its own family. Weights in use: 500 for body,
|
|
600 for emphasis and buttons, 700 for headings.
|
|
|
|
## Surfaces
|
|
|
|
The canvas is white. Every scaffold, app bar and content sheet is
|
|
`Colors.white`; sage survives only as a tint inside white cards, where an
|
|
inset block needs to read as recessed.
|
|
|
|
Material elevation is off. `AppBarTheme` and `CardThemeData` both set
|
|
`elevation: 0`, and separation comes from a 1px `line` border instead of a
|
|
shadow. That border is doing all the work now that cards are white on white -
|
|
removing it would dissolve every card into the page.
|
|
|
|
Shadow is kept for things that genuinely float above the surface rather than
|
|
sit on it - the travelling nav pill, the selected day in the week strip, the
|
|
frosted segmented control, the onboarding art. Reaching for a shadow to
|
|
separate a static block is the wrong move; use the hairline.
|
|
|
|
Corner radii cluster deliberately: **16** for cards and blocks, **14** for
|
|
buttons and inputs, **10-12** for chips and small pills, **7-11** for icon
|
|
tiles, **28** for the phone frame. Anything outside that set should have a
|
|
reason.
|
|
|
|
## Layout patterns
|
|
|
|
Three header treatments, and a screen picks one:
|
|
|
|
- **`KrowSliverHeader`** ([collapsing_header.dart](lib/widgets/collapsing_header.dart))
|
|
- the main tabs. Pinned, shrinks from expanded to collapsed as content
|
|
scrolls under it. A tall header pinned at full size would cost a quarter of
|
|
the screen for the whole session; collapsing keeps the context and gives the
|
|
space back.
|
|
- **`BrandHeader`** - pushed detail screens. Fixed, scrolls away.
|
|
- **`BrandSurface`** - the blue ground both sit on: brand colour, rounded
|
|
bottom, and two soft radial glows. The glows are quiet alone, but they give
|
|
the frosted controls on top something to refract.
|
|
|
|
`GlassSegments` ([glass_segments.dart](lib/widgets/glass_segments.dart)) is the
|
|
segmented control that rides on those headers - a real `BackdropFilter` blur
|
|
with a bright top edge and darker bottom edge, so it reads as glass rather
|
|
than a flat tint.
|
|
|
|
## Component inventory
|
|
|
|
Everything shared lives in [widgets/](lib/widgets). The rule is that a screen
|
|
composes these and does not restyle them.
|
|
|
|
| Component | Job |
|
|
|---|---|
|
|
| `SectionLabel` | Small uppercase heading above every group |
|
|
| `KrowCard` | The white rounded container for grouped content |
|
|
| `ShiftCard` | The one card shape: identity, hairline split, money band, status bar welded to the bottom |
|
|
| `NavTile` / `TileGroup` | Icon tile, title, subtitle, optional badge, chevron - grouped with hairline dividers |
|
|
| `StatusChip` | Status pill. `ChipTone.yellow` marks anything waiting on the worker |
|
|
| `InfoBanner` | Soft informational panel; `warning: true` switches it to yellow |
|
|
| `DetailRow` | Dotted-leader row, label left, value right |
|
|
| `StatSlab` / `StatSlabRow` | Three-up figure blocks, fixed height, text scales down rather than wrapping |
|
|
| `StatRail` | Horizontal rail of small stats divided by hairlines |
|
|
| `KrowProgressBar` | Progress that grows into place, so a change is something you see happen |
|
|
| `MiniBarChart` / `LegendDot` | Earnings charts |
|
|
| `WeekStrip` | Seven-day selector; a dot marks a day with something on it |
|
|
| `BusinessBadge` / `MetaPill` | Business identity and shift metadata |
|
|
| `EmptyState` | Every empty list |
|
|
| `HeaderIconButton` | Round icon button, works on light or dark ground |
|
|
| `KrowWordmark` | The brand mark, an alpha mask tinted to any colour |
|
|
| `KrowNavBar` | Bottom navigation |
|
|
| `SwipeAction` | Slide-to-confirm, for actions a stray tap must not trigger |
|
|
| `Entrance` | Staggered fade-and-lift for the rows of a list screen |
|
|
| `PressScale` | The give-under-the-thumb press state, inside the shared cards |
|
|
| `PhoneFrame` | Holds the app to phone proportions on wide viewports |
|
|
|
|
`SwipeAction` is the one control shaped as a pill rather than at the 14px
|
|
button radius, and that is the point: it is the only thing in the app you drag
|
|
instead of tap, and looking slightly unlike a button is what says so before
|
|
you touch it. Its hint animation - a sheen crossing the track, chevrons
|
|
lighting in sequence - runs three cycles and stops rather than looping
|
|
forever, because a control that never stops animating never lets the widget
|
|
tree settle, which hangs `pumpAndSettle` in any test that reaches a screen
|
|
holding one. It re-arms when the control becomes enabled or a swipe falls
|
|
short.
|
|
|
|
`StatSlab` is worth calling out as the house pattern for text that must not
|
|
reflow: both lines are pinned to a fixed height and scale down instead of
|
|
wrapping, because a wrapping caption - "CLOCK OUT", "11:50 PM" - makes one
|
|
card taller than the two beside it and leaves the row ragged.
|
|
|
|
## Motion
|
|
|
|
Motion is used to explain structure, never for decoration. Durations sit
|
|
between 260ms and 360ms; anything slower is felt as lag by someone in a hurry.
|
|
|
|
**Two page transitions only** ([transitions.dart](lib/core/transitions.dart)):
|
|
|
|
- `Transitions.push` - going *deeper* (a shift, a document, a timecard). The
|
|
incoming screen slides in from the right over 320ms; the outgoing screen
|
|
eases back 12% and fades, which is what gives the push its depth. Reverse
|
|
runs at 260ms.
|
|
- `Transitions.fade` - going *sideways*: splash, onboarding, sign-in steps,
|
|
anything that replaces the screen rather than stacking on it. Cross-fade
|
|
with a few pixels of rise, because a flat cross-fade reads as inert.
|
|
|
|
**The nav bar indicator travels.** Material's `NavigationBar` fades its
|
|
indicator out at the origin and in at the destination, so the selection
|
|
appears to teleport. `KrowNavBar` animates the selected index as a *double*,
|
|
so one value drives both the pill's position and every icon's colour, and the
|
|
icons hand the pill over as it passes. It stretches slightly mid-flight and
|
|
settles with `easeOutQuint` over 360ms.
|
|
|
|
**Tabs fade and lift** rather than cut, over 260ms. Deliberately not an
|
|
`AnimatedSwitcher`: that holds two copies of the shell at once, and the branch
|
|
navigators are keyed by `GlobalKey`, so duplicating them throws.
|
|
`_TabTransition` animates a single subtree in place, which also keeps each
|
|
branch's scroll position. Opacity floors at 0.35 - a tab that blinks fully out
|
|
reads as a glitch.
|
|
|
|
**Rows land in reading order.** Every list screen wraps its children in
|
|
`Entrance.stagger`, which fades and lifts each row into place 55ms after the
|
|
one above it. A screen that arrives fully drawn says nothing about how it is
|
|
put together; letting the rows land in order shows the reading order before
|
|
the worker has to find it. The stagger stops counting after six rows, so a
|
|
long list never has a row waiting a second and a half to appear.
|
|
|
|
The delay is folded into each `Entrance`'s controller as a leading `Interval`,
|
|
never run as a `Future.delayed`. A standalone timer outlives the widget it was
|
|
armed for - a `mounted` guard protects the callback but not the timer, which
|
|
then surfaces as a pending timer at teardown and keeps a disposed screen's
|
|
work alive.
|
|
|
|
**Cards give under the thumb.** `PressScale` shrinks a card to 97.5% while a
|
|
finger is down on it. Material's ink ripple answers after the fact, and on a
|
|
white card on a white page it is nearly invisible; a card that moves during
|
|
the press answers in the half-second that otherwise feels unresponsive. It
|
|
lives inside `KrowCard`, `NavTile` and `ShiftCard` rather than in any screen,
|
|
so every screen gets it without being touched. It listens with a `Listener`
|
|
rather than a `GestureDetector`, so it never competes for the gesture: the
|
|
InkWell underneath still gets its tap, and a scroll that starts on a card
|
|
still scrolls.
|
|
|
|
Scroll physics is left to the platform. Forcing `BouncingScrollPhysics`
|
|
everywhere looked smoother on long lists and broke every short one: content
|
|
that already fit could be dragged away from the header, leaving a gap.
|
|
|
|
## Navigation
|
|
|
|
[router.dart](lib/core/router.dart), go_router.
|
|
|
|
Sign-in lives outside the shell. The five tabs are a
|
|
`StatefulShellRoute.indexedStack`, so **each tab keeps its own stack and scroll
|
|
position**. Detail screens push over the whole shell - the nav bar goes with
|
|
them - so a pushed screen is unambiguously "deeper" rather than "elsewhere".
|
|
|
|
Tab order is **Shifts | Pay | Today | Clock | Profile**. Today sits in the
|
|
middle because it is the screen a worker opens the app for; the two browsing
|
|
tabs sit left of it and the two personal ones right. The branch order in the
|
|
router and the item order in `HomeShell` must stay in step - there is no
|
|
mechanism enforcing that, only a comment in both files.
|
|
|
|
The splash route reads persisted session state and sends the worker to
|
|
whichever step they last finished: onboarding, phone entry, the permission
|
|
run, or straight into Today.
|
|
|
|
## State
|
|
|
|
Riverpod, with a consistent split:
|
|
|
|
- **`Notifier` controllers own writable state** - `shiftsProvider`,
|
|
`timecardsProvider`, `complianceProvider`, `availabilityProvider`,
|
|
`profileProvider`, `sessionProvider`.
|
|
- **Plain `Provider`s derive everything else**, and screens read the derived
|
|
value rather than recomputing it. `nextShiftProvider`,
|
|
`completedEarningsProvider`, `hoursLoggedProvider`,
|
|
`timecardsNeedingReviewProvider`, `complianceProgressProvider`,
|
|
`isCompliantProvider`, `daysSetProvider` and friends all live beside the
|
|
controller they derive from.
|
|
|
|
This is why the Clock tab can show a dot when a timecard needs review without
|
|
the shell knowing anything about timecards: it watches
|
|
`timecardsNeedingReviewProvider` and nothing else.
|
|
|
|
**What persists** (`shared_preferences`, injected in `main()` via
|
|
`prefsProvider`): sign-in and phone number, the three permission grants,
|
|
availability, and profile edits including skills. **What does not**: shifts,
|
|
timecards and compliance, which live in memory for the session and reset on
|
|
relaunch. [seed.dart](lib/state/seed.dart) anchors all demo content to *now*,
|
|
so the next shift always starts 20 minutes out - inside the clock-in window -
|
|
whenever the app is opened.
|
|
|
|
## Wide screens
|
|
|
|
Every screen is laid out for a thumb. Stretched across a laptop the same
|
|
screens read as a broken website rather than an app, so
|
|
[phone_frame.dart](lib/widgets/phone_frame.dart) wraps the router output:
|
|
below 600px it passes through untouched, and at 600px and up the app is pinned
|
|
to a 420x900 panel centred on an `ink` backdrop.
|
|
|
|
The panel overrides its own `MediaQuery` so the app measures itself against
|
|
the panel rather than the window - screens that size themselves as a fraction
|
|
of screen height (the permission sheets are `0.86`) would otherwise measure a
|
|
laptop window while drawing inside a phone-width panel. Window padding is
|
|
zeroed with it, since the browser's safe areas belong to page edges the panel
|
|
no longer touches.
|
|
|
|
## Accessibility and robustness
|
|
|
|
- **No fixed heights where text lives.** `KrowNavBar` takes its height from
|
|
the item row rather than a constant, because a hard number has to be retuned
|
|
every time the label grows - a bigger text scale, a longer word, a different
|
|
font - and overflows the moment it is not.
|
|
- **Scale down, don't wrap**, in any row that must stay aligned (`StatSlab`).
|
|
- **Deferring is a real target.** Secondary actions like "not now" get a
|
|
full-width 44px text button, not a small link.
|
|
- **Permissions never block.** Whatever the OS answers is recorded and the run
|
|
continues. Once the OS stops showing its dialog, the app says so and offers
|
|
system settings instead of re-asking into the void.
|
|
|
|
## Adding a screen
|
|
|
|
1. Route it in [router.dart](lib/core/router.dart) - `_fadeRoute` if it
|
|
replaces what came before, `_detailRoute` if it goes deeper.
|
|
2. Pick a header: `KrowSliverHeader` for a tab, `BrandHeader` for a detail.
|
|
3. Compose from [widgets/](lib/widgets). If you need a new shared component,
|
|
add it there rather than styling in place.
|
|
4. Read derived providers, don't recompute. If the value you want does not
|
|
exist, add a `Provider` next to the controller that owns the source.
|
|
5. Use the palette tokens. No new hex literals for anything that has a token.
|
|
|
|
## Tests
|
|
|
|
[test/](test) covers the parts that break quietly: transition end states
|
|
(`transitions_test`), scroll physics per platform (`scroll_behaviour_test`),
|
|
layout invariants like equal-height slabs and nav labels (`widgets_test`,
|
|
`screen_layout_test`), and pay/timecard arithmetic (`shift_logic_test`).
|