14 KiB
Design
How the KROW worker app is put together, and why. 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):
| 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)- 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) 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/. 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):
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, 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:
Notifiercontrollers own writable state -shiftsProvider,timecardsProvider,complianceProvider,availabilityProvider,profileProvider,sessionProvider.- Plain
Providers derive everything else, and screens read the derived value rather than recomputing it.nextShiftProvider,completedEarningsProvider,hoursLoggedProvider,timecardsNeedingReviewProvider,complianceProgressProvider,isCompliantProvider,daysSetProviderand 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 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 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.
KrowNavBartakes 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
- Route it in router.dart -
_fadeRouteif it replaces what came before,_detailRouteif it goes deeper. - Pick a header:
KrowSliverHeaderfor a tab,BrandHeaderfor a detail. - Compose from widgets/. If you need a new shared component, add it there rather than styling in place.
- Read derived providers, don't recompute. If the value you want does not
exist, add a
Providernext to the controller that owns the source. - Use the palette tokens. No new hex literals for anything that has a token.
Tests
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).