Files
krow-worker-app/DESIGN.md
2026-10-05 22:14:58 +05:30

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:

  • Notifier controllers 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, 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 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. 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 - _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/. 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/ 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).