fixed designs and flow
This commit is contained in:
276
DESIGN.md
Normal file
276
DESIGN.md
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
# 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`).
|
||||||
@@ -77,7 +77,7 @@ Motion is used to explain what changed, not to decorate. The rules:
|
|||||||
changes ahead of the thing causing it.
|
changes ahead of the thing causing it.
|
||||||
- **Numbers grow.** Progress bars and chart bars tween to new values, so an
|
- **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.
|
uploaded document or a switched pay range is something you watch happen.
|
||||||
- **State changes animate together.** Availability blocks, week-strip days and
|
- **State changes animate together.** Availability options, week-strip days and
|
||||||
the clock dial move every part of themselves in one gesture rather than
|
the clock dial move every part of themselves in one gesture rather than
|
||||||
flipping several properties at once.
|
flipping several properties at once.
|
||||||
- **Consequential actions are felt.** Clocking in and out give a medium impact;
|
- **Consequential actions are felt.** Clocking in and out give a medium impact;
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
||||||
/// KROW brand palette: 286CP blue carries every surface and action, 106CP
|
/// KROW brand palette: 286CP blue carries every surface and action, 106CP
|
||||||
/// yellow marks money and anything waiting on you, 621CP sage is the canvas.
|
/// yellow marks money and anything waiting on you, and the canvas is white.
|
||||||
|
///
|
||||||
|
/// 621CP sage is still here, but it is a tint now rather than the ground: it
|
||||||
|
/// fills the quiet inset blocks that would otherwise vanish against a white
|
||||||
|
/// card.
|
||||||
abstract final class KrowPalette {
|
abstract final class KrowPalette {
|
||||||
static const blue = Color(0xFF3752A8);
|
static const blue = Color(0xFF3752A8);
|
||||||
static const blueDeep = Color(0xFF22366F);
|
static const blueDeep = Color(0xFF22366F);
|
||||||
@@ -13,6 +18,18 @@ abstract final class KrowPalette {
|
|||||||
static const ink = Color(0xFF26353E);
|
static const ink = Color(0xFF26353E);
|
||||||
static const line = Color(0xFFCFDDD3);
|
static const line = Color(0xFFCFDDD3);
|
||||||
static const mute = Color(0xFF65727A);
|
static const mute = Color(0xFF65727A);
|
||||||
|
|
||||||
|
/// Inert surfaces: a disabled button, an un-dragged swipe track.
|
||||||
|
///
|
||||||
|
/// Cool grey rather than the green-grey these used to be. That tint made
|
||||||
|
/// sense while sage was the canvas and a disabled control was a slightly
|
||||||
|
/// darker patch of the page; against white it reads as a colour someone
|
||||||
|
/// chose, which is the last thing an inert control should look like.
|
||||||
|
static const disabled = Color(0xFFDFE3E6);
|
||||||
|
|
||||||
|
/// The outline on an unselected control — an empty radio, an OTP box.
|
||||||
|
/// Heavier than [line], which is only ever a hairline between rows.
|
||||||
|
static const edge = Color(0xFFC8D0D6);
|
||||||
}
|
}
|
||||||
|
|
||||||
ThemeData buildKrowTheme() {
|
ThemeData buildKrowTheme() {
|
||||||
@@ -36,9 +53,9 @@ ThemeData buildKrowTheme() {
|
|||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: scheme,
|
colorScheme: scheme,
|
||||||
textTheme: text,
|
textTheme: text,
|
||||||
scaffoldBackgroundColor: KrowPalette.sage,
|
scaffoldBackgroundColor: Colors.white,
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
backgroundColor: KrowPalette.sage,
|
backgroundColor: Colors.white,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
foregroundColor: KrowPalette.ink,
|
foregroundColor: KrowPalette.ink,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -58,6 +75,14 @@ ThemeData buildKrowTheme() {
|
|||||||
side: const BorderSide(color: KrowPalette.line),
|
side: const BorderSide(color: KrowPalette.line),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Most detail screens use a plain AppBar, whose back button would
|
||||||
|
// otherwise draw Material's own glyph — a full arrow on Android and web,
|
||||||
|
// a chevron on iOS. Naming it here keeps every back button in the app the
|
||||||
|
// same shape as the ones built by hand with HeaderIconButton.
|
||||||
|
actionIconTheme: ActionIconThemeData(
|
||||||
|
backButtonIconBuilder: (context) =>
|
||||||
|
const Icon(LucideIcons.chevronLeft, size: 20),
|
||||||
|
),
|
||||||
dividerTheme: const DividerThemeData(
|
dividerTheme: const DividerThemeData(
|
||||||
color: KrowPalette.line,
|
color: KrowPalette.line,
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
@@ -68,7 +93,7 @@ ThemeData buildKrowTheme() {
|
|||||||
minimumSize: const Size.fromHeight(52),
|
minimumSize: const Size.fromHeight(52),
|
||||||
backgroundColor: KrowPalette.blue,
|
backgroundColor: KrowPalette.blue,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
disabledBackgroundColor: const Color(0xFFD5E2D9),
|
disabledBackgroundColor: KrowPalette.disabled,
|
||||||
disabledForegroundColor: const Color(0xFF949FA5),
|
disabledForegroundColor: const Color(0xFF949FA5),
|
||||||
textStyle: GoogleFonts.poppins(
|
textStyle: GoogleFonts.poppins(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
|
|||||||
@@ -1,21 +1,97 @@
|
|||||||
enum DayBlock { morning, afternoon, evening }
|
/// What a worker has said about a given day.
|
||||||
|
///
|
||||||
|
/// Three states rather than an on/off switch, because "I can work" and "I want
|
||||||
|
/// work" are different answers and Auto-Match treats them differently: an
|
||||||
|
/// available day is one it may fill, a preferred day is one it should fill
|
||||||
|
/// first.
|
||||||
|
enum DayAvailability { available, prefers, unavailable }
|
||||||
|
|
||||||
extension DayBlockX on DayBlock {
|
extension DayAvailabilityX on DayAvailability {
|
||||||
String get label => switch (this) {
|
String get label => switch (this) {
|
||||||
DayBlock.morning => 'Morning',
|
DayAvailability.available => 'Available',
|
||||||
DayBlock.afternoon => 'Afternoon',
|
DayAvailability.prefers => 'Prefers to work',
|
||||||
DayBlock.evening => 'Evening',
|
DayAvailability.unavailable => 'Unavailable',
|
||||||
};
|
};
|
||||||
|
|
||||||
String get window => switch (this) {
|
/// The line under the option, in the picker.
|
||||||
DayBlock.morning => '04:00 - 12:00',
|
String get blurb => switch (this) {
|
||||||
DayBlock.afternoon => '12:00 - 18:00',
|
DayAvailability.available => 'Can be scheduled any time',
|
||||||
DayBlock.evening => '18:00 - 24:00',
|
DayAvailability.prefers => 'Wants shifts — optionally within a time window',
|
||||||
|
DayAvailability.unavailable => 'Marked off (vacation, sick, personal)',
|
||||||
};
|
};
|
||||||
|
|
||||||
int get hours => switch (this) {
|
/// The line under the date, once the day is set.
|
||||||
DayBlock.morning => 8,
|
String get summary => switch (this) {
|
||||||
DayBlock.afternoon => 6,
|
DayAvailability.available => 'Can be scheduled any time',
|
||||||
DayBlock.evening => 6,
|
DayAvailability.prefers => 'Wants shifts',
|
||||||
|
DayAvailability.unavailable => 'Not available',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The hours a worker would rather be given, on a day they want work.
|
||||||
|
///
|
||||||
|
/// Both ends are optional and independent: "from 6pm, any end" is a normal
|
||||||
|
/// thing to want, and forcing a full range would make the worker invent one.
|
||||||
|
/// Null at both ends means any time, which is also the default.
|
||||||
|
class PreferredWindow {
|
||||||
|
const PreferredWindow({this.from, this.to});
|
||||||
|
|
||||||
|
/// Hour of the day, 0-23. Null is "any time".
|
||||||
|
final int? from;
|
||||||
|
final int? to;
|
||||||
|
|
||||||
|
static const any = PreferredWindow();
|
||||||
|
|
||||||
|
bool get isAnyTime => from == null && to == null;
|
||||||
|
|
||||||
|
/// How the window reads on its own, as a label.
|
||||||
|
String get label {
|
||||||
|
if (isAnyTime) return 'Any time';
|
||||||
|
if (from != null && to != null) return '${_hhmm(from!)} - ${_hhmm(to!)}';
|
||||||
|
if (from != null) return 'From ${_hhmm(from!)}';
|
||||||
|
return 'Until ${_hhmm(to!)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the window reads inside a sentence — "look for shifts $sentence
|
||||||
|
/// first". The label does not survive being dropped mid-sentence: "look for
|
||||||
|
/// From 09:00 first" is not English.
|
||||||
|
String get sentence {
|
||||||
|
if (isAnyTime) return 'at any time';
|
||||||
|
if (from != null && to != null) {
|
||||||
|
return 'between ${_hhmm(from!)} and ${_hhmm(to!)}';
|
||||||
|
}
|
||||||
|
if (from != null) return 'from ${_hhmm(from!)}';
|
||||||
|
return 'until ${_hhmm(to!)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
PreferredWindow copyWith({
|
||||||
|
int? from,
|
||||||
|
int? to,
|
||||||
|
bool clearFrom = false,
|
||||||
|
bool clearTo = false,
|
||||||
|
}) => PreferredWindow(
|
||||||
|
from: clearFrom ? null : (from ?? this.from),
|
||||||
|
to: clearTo ? null : (to ?? this.to),
|
||||||
|
);
|
||||||
|
|
||||||
|
static String _hhmm(int hour) => '${hour.toString().padLeft(2, '0')}:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A day's answer: the status, and the window if one was given.
|
||||||
|
class DayPlan {
|
||||||
|
const DayPlan({
|
||||||
|
this.status = DayAvailability.unavailable,
|
||||||
|
this.window = PreferredWindow.any,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DayAvailability status;
|
||||||
|
|
||||||
|
/// Only meaningful while [status] is [DayAvailability.prefers]; kept across
|
||||||
|
/// a status change so flipping away and back does not lose the hours.
|
||||||
|
final PreferredWindow window;
|
||||||
|
|
||||||
|
bool get isSet => status != DayAvailability.unavailable;
|
||||||
|
|
||||||
|
DayPlan copyWith({DayAvailability? status, PreferredWindow? window}) =>
|
||||||
|
DayPlan(status: status ?? this.status, window: window ?? this.window);
|
||||||
|
}
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
hintStyle: const TextStyle(
|
hintStyle: const TextStyle(
|
||||||
fontSize: 30,
|
fontSize: 30,
|
||||||
letterSpacing: 14,
|
letterSpacing: 14,
|
||||||
color: Color(0xFFC4D4C9),
|
color: KrowPalette.edge,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -382,7 +382,7 @@ class VerifiedScreen extends ConsumerWidget {
|
|||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: KrowPalette.sage,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
@@ -603,7 +603,7 @@ class _PermissionsScreenState extends ConsumerState<PermissionsScreen> {
|
|||||||
final permission = _permissions[_step];
|
final permission = _permissions[_step];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: KrowPalette.sage,
|
backgroundColor: Colors.white,
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
// The app itself, already built, sitting behind the ask.
|
// The app itself, already built, sitting behind the ask.
|
||||||
@@ -681,7 +681,7 @@ class _PermissionSheet extends StatelessWidget {
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 4,
|
height: 4,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFCFDDD4),
|
color: KrowPalette.edge,
|
||||||
borderRadius: BorderRadius.circular(2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import '../state/availability_controller.dart';
|
|||||||
import '../widgets/common.dart';
|
import '../widgets/common.dart';
|
||||||
import '../widgets/week_strip.dart';
|
import '../widgets/week_strip.dart';
|
||||||
|
|
||||||
/// Auto-Match only offers shifts inside the blocks switched on here.
|
/// What the worker tells Auto-Match about each day of the coming week:
|
||||||
|
/// available, actively wanting work, or marked off.
|
||||||
class AvailabilityScreen extends ConsumerStatefulWidget {
|
class AvailabilityScreen extends ConsumerStatefulWidget {
|
||||||
const AvailabilityScreen({super.key});
|
const AvailabilityScreen({super.key});
|
||||||
|
|
||||||
@@ -30,11 +31,10 @@ class _AvailabilityScreenState extends ConsumerState<AvailabilityScreen> {
|
|||||||
final controller = ref.watch(availabilityProvider.notifier);
|
final controller = ref.watch(availabilityProvider.notifier);
|
||||||
ref.watch(availabilityProvider);
|
ref.watch(availabilityProvider);
|
||||||
|
|
||||||
final blocks = controller.blocksFor(_selected);
|
final plan = controller.planFor(_selected);
|
||||||
final hours = blocks.fold<int>(0, (sum, b) => sum + b.hours);
|
|
||||||
final marked = <int>{
|
final marked = <int>{
|
||||||
for (var i = 0; i < _week.length; i++)
|
for (var i = 0; i < _week.length; i++)
|
||||||
if (controller.blocksFor(_week[i]).isNotEmpty) i,
|
if (!controller.isDayOff(_week[i])) i,
|
||||||
};
|
};
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -49,7 +49,7 @@ class _AvailabilityScreenState extends ConsumerState<AvailabilityScreen> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
HeaderIconButton(
|
HeaderIconButton(
|
||||||
icon: LucideIcons.arrowLeft,
|
icon: LucideIcons.chevronLeft,
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -115,12 +115,7 @@ class _AvailabilityScreenState extends ConsumerState<AvailabilityScreen> {
|
|||||||
KrowCard(
|
KrowCard(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
Fmt.dayShort(_selected),
|
Fmt.dayShort(_selected),
|
||||||
@@ -132,50 +127,51 @@ class _AvailabilityScreenState extends ConsumerState<AvailabilityScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
blocks.isEmpty
|
plan.status.summary,
|
||||||
? 'Marked as a day off'
|
|
||||||
: 'Available · $hours hrs',
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12.5,
|
fontSize: 12.5,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: blocks.isEmpty
|
color: plan.isSet
|
||||||
? KrowPalette.mute
|
? KrowPalette.blue
|
||||||
: KrowPalette.blue,
|
: KrowPalette.mute,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Switch(
|
|
||||||
value: blocks.isNotEmpty,
|
|
||||||
onChanged: (v) =>
|
|
||||||
controller.setDayAvailable(_selected, v),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
for (final block in DayBlock.values)
|
for (final status in DayAvailability.values) ...[
|
||||||
Padding(
|
_StatusOption(
|
||||||
padding: const EdgeInsets.only(bottom: 9),
|
status: status,
|
||||||
child: _BlockRow(
|
selected: plan.status == status,
|
||||||
block: block,
|
onTap: () => controller.setStatus(_selected, status),
|
||||||
on: blocks.contains(block),
|
),
|
||||||
onTap: () =>
|
// The window belongs to the option it qualifies, so it
|
||||||
controller.toggleBlock(_selected, block),
|
// opens directly beneath it rather than at the foot of
|
||||||
|
// the card where it would read as a separate question.
|
||||||
|
if (status == DayAvailability.prefers)
|
||||||
|
_Expand(
|
||||||
|
open: plan.status == DayAvailability.prefers,
|
||||||
|
child: _WindowPicker(
|
||||||
|
window: plan.window,
|
||||||
|
onChanged: (w) =>
|
||||||
|
controller.setWindow(_selected, w),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
InfoBanner(
|
InfoBanner(switch (plan.status) {
|
||||||
blocks.isEmpty
|
DayAvailability.unavailable =>
|
||||||
? 'With every block off, Auto-Match will not send you '
|
'Auto-Match will not send you anything on this day.',
|
||||||
'anything on this day.'
|
DayAvailability.available =>
|
||||||
: 'Saved. Auto-Match will only offer you shifts inside '
|
'Saved. Auto-Match can offer you any shift on this day.',
|
||||||
'these blocks.',
|
DayAvailability.prefers =>
|
||||||
icon: blocks.isEmpty ? null : LucideIcons.check,
|
plan.window.isAnyTime
|
||||||
),
|
? 'Saved. Auto-Match will offer you this day first.'
|
||||||
|
: 'Saved. Auto-Match will look for shifts '
|
||||||
|
'${plan.window.sentence} first.',
|
||||||
|
}, icon: plan.isSet ? LucideIcons.check : null),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -222,62 +218,46 @@ class _Preset extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BlockRow extends StatelessWidget {
|
/// One of the three answers, as a selectable card.
|
||||||
const _BlockRow({required this.block, required this.on, required this.onTap});
|
class _StatusOption extends StatelessWidget {
|
||||||
|
const _StatusOption({
|
||||||
|
required this.status,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
final DayBlock block;
|
final DayAvailability status;
|
||||||
final bool on;
|
final bool selected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
IconData get _icon => switch (block) {
|
|
||||||
DayBlock.morning => LucideIcons.sunrise,
|
|
||||||
DayBlock.afternoon => LucideIcons.sun,
|
|
||||||
DayBlock.evening => LucideIcons.moon,
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
const duration = Duration(milliseconds: 260);
|
const duration = Duration(milliseconds: 220);
|
||||||
const curve = Curves.easeOutCubic;
|
const curve = Curves.easeOutCubic;
|
||||||
|
|
||||||
return GestureDetector(
|
return Semantics(
|
||||||
|
inMutuallyExclusiveGroup: true,
|
||||||
|
selected: selected,
|
||||||
|
button: true,
|
||||||
|
child: GestureDetector(
|
||||||
onTap: onTap,
|
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(
|
child: AnimatedContainer(
|
||||||
duration: duration,
|
duration: duration,
|
||||||
curve: curve,
|
curve: curve,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: on ? KrowPalette.blueTint : const Color(0xFFE5F0E8),
|
color: selected ? KrowPalette.blueTint : Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: on
|
color: selected ? KrowPalette.blue : KrowPalette.line,
|
||||||
? KrowPalette.blue.withValues(alpha: 0.3)
|
width: selected ? 1.5 : 1,
|
||||||
: Colors.transparent,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
AnimatedContainer(
|
_Radio(selected: selected),
|
||||||
duration: duration,
|
const SizedBox(width: 12),
|
||||||
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(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -288,50 +268,216 @@ class _BlockRow extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14.5,
|
fontSize: 14.5,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: on ? KrowPalette.ink : KrowPalette.mute,
|
color: selected ? KrowPalette.blue : KrowPalette.ink,
|
||||||
),
|
),
|
||||||
child: Text(block.label),
|
child: Text(status.label),
|
||||||
),
|
),
|
||||||
AnimatedDefaultTextStyle(
|
const SizedBox(height: 2),
|
||||||
duration: duration,
|
Text(
|
||||||
curve: curve,
|
status.blurb,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11.5,
|
||||||
|
height: 1.35,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: on ? KrowPalette.blue : const Color(0xFF949FA5),
|
color: KrowPalette.mute,
|
||||||
),
|
),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The filled dot. Drawn rather than using Material's Radio, which carries its
|
||||||
|
/// own 48px tap target and would push the rows apart.
|
||||||
|
class _Radio extends StatelessWidget {
|
||||||
|
const _Radio({required this.selected});
|
||||||
|
|
||||||
|
final bool selected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const duration = Duration(milliseconds: 220);
|
||||||
|
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: duration,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
// Nudged down to sit on the title's optical centre rather than its box.
|
||||||
|
margin: const EdgeInsets.only(top: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? KrowPalette.blue : Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? KrowPalette.blue : KrowPalette.edge,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: AnimatedScale(
|
||||||
|
scale: selected ? 1 : 0,
|
||||||
|
duration: duration,
|
||||||
|
curve: Curves.easeOutBack,
|
||||||
|
child: const Icon(LucideIcons.check, size: 12, color: Colors.white),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reveals its child by height, so the card grows into the extra question
|
||||||
|
/// instead of the rows below it jumping.
|
||||||
|
class _Expand extends StatelessWidget {
|
||||||
|
const _Expand({required this.open, required this.child});
|
||||||
|
|
||||||
|
final bool open;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: open
|
||||||
|
? Padding(padding: const EdgeInsets.only(top: 10), child: child)
|
||||||
|
: const SizedBox(width: double.infinity),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The optional hours on a "prefers to work" day.
|
||||||
|
class _WindowPicker extends StatelessWidget {
|
||||||
|
const _WindowPicker({required this.window, required this.onChanged});
|
||||||
|
|
||||||
|
final PreferredWindow window;
|
||||||
|
final ValueChanged<PreferredWindow> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: KrowPalette.sage,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SectionLabel('Preferred window (optional)'),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _HourField(
|
||||||
|
label: 'From',
|
||||||
|
value: window.from,
|
||||||
|
// A start later than the end is not a range, so the stale
|
||||||
|
// end is dropped rather than kept as something impossible.
|
||||||
|
onChanged: (h) => onChanged(
|
||||||
|
PreferredWindow(
|
||||||
|
from: h,
|
||||||
|
to: (h != null && window.to != null && window.to! <= h)
|
||||||
|
? null
|
||||||
|
: window.to,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _HourField(
|
||||||
|
label: 'To',
|
||||||
|
value: window.to,
|
||||||
|
// Only hours after the start are offered, so the pair cannot
|
||||||
|
// be put into an order that means nothing.
|
||||||
|
min: window.from == null ? null : window.from! + 1,
|
||||||
|
onChanged: (h) =>
|
||||||
|
onChanged(PreferredWindow(from: window.from, to: h)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HourField extends StatelessWidget {
|
||||||
|
const _HourField({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
this.min,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final int? value;
|
||||||
|
final ValueChanged<int?> onChanged;
|
||||||
|
|
||||||
|
/// Earliest hour offered, used to keep "To" after "From".
|
||||||
|
final int? min;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final first = min ?? 0;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: KrowPalette.mute,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Container(
|
||||||
|
height: 44,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: KrowPalette.line),
|
||||||
|
),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<int?>(
|
||||||
|
value: value,
|
||||||
|
isExpanded: true,
|
||||||
|
isDense: true,
|
||||||
|
icon: const Icon(
|
||||||
|
LucideIcons.chevronDown,
|
||||||
|
size: 16,
|
||||||
|
color: KrowPalette.mute,
|
||||||
|
),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: KrowPalette.ink,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onChanged: onChanged,
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<int?>(
|
||||||
|
value: null,
|
||||||
|
child: Text('Any time'),
|
||||||
|
),
|
||||||
|
for (var h = first; h < 24; h++)
|
||||||
|
DropdownMenuItem<int?>(
|
||||||
|
value: h,
|
||||||
|
child: Text('${h.toString().padLeft(2, '0')}:00'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import '../state/shifts_controller.dart';
|
|||||||
import '../state/timecards_controller.dart';
|
import '../state/timecards_controller.dart';
|
||||||
import '../widgets/collapsing_header.dart';
|
import '../widgets/collapsing_header.dart';
|
||||||
import '../widgets/common.dart';
|
import '../widgets/common.dart';
|
||||||
|
import '../widgets/motion.dart';
|
||||||
import '../widgets/glass_segments.dart';
|
import '../widgets/glass_segments.dart';
|
||||||
import '../widgets/krow_wordmark.dart';
|
import '../widgets/krow_wordmark.dart';
|
||||||
import '../widgets/shift_card.dart';
|
import '../widgets/shift_card.dart';
|
||||||
|
import '../widgets/swipe_action.dart';
|
||||||
import 'timecard_screens.dart';
|
import 'timecard_screens.dart';
|
||||||
|
|
||||||
/// Clock in and out. The button unlocks 30 minutes before the shift starts,
|
/// Clock in and out. The button unlocks 30 minutes before the shift starts,
|
||||||
@@ -122,7 +124,9 @@ class _ClockScreenState extends ConsumerState<ClockScreen> {
|
|||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
|
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
|
||||||
sliver: SliverList.list(
|
sliver: SliverList.list(
|
||||||
children: timecardItems(ref.watch(timecardsProvider)),
|
children: Entrance.stagger(
|
||||||
|
timecardItems(ref.watch(timecardsProvider)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (shift == null)
|
else if (shift == null)
|
||||||
@@ -142,7 +146,7 @@ class _ClockScreenState extends ConsumerState<ClockScreen> {
|
|||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 24),
|
padding: const EdgeInsets.fromLTRB(20, 28, 20, 24),
|
||||||
sliver: SliverList.list(
|
sliver: SliverList.list(
|
||||||
children: [
|
children: Entrance.stagger([
|
||||||
_ClockDial(shift: shift, atVenue: _atVenue),
|
_ClockDial(shift: shift, atVenue: _atVenue),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
if (shift.status == ShiftStatus.active)
|
if (shift.status == ShiftStatus.active)
|
||||||
@@ -155,16 +159,18 @@ class _ClockScreenState extends ConsumerState<ClockScreen> {
|
|||||||
label: const Text('Clock out'),
|
label: const Text('Clock out'),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
FilledButton.icon(
|
SwipeAction(
|
||||||
onPressed: _canClockIn(shift, locationGranted)
|
// The thumb is a handle, so its icon says how to
|
||||||
? () => _clockIn(shift)
|
// work the control, not what the control does - the
|
||||||
: null,
|
// label already says 'clock in', and the check on
|
||||||
icon: const Icon(LucideIcons.logIn, size: 18),
|
// commit carries the outcome.
|
||||||
label: Text(
|
icon: LucideIcons.arrowRight,
|
||||||
_canClockIn(shift, locationGranted)
|
enabled: _canClockIn(shift, locationGranted),
|
||||||
? 'Clock in'
|
label: _canClockIn(shift, locationGranted)
|
||||||
|
? 'Swipe to clock in'
|
||||||
: 'Clock in · ${Fmt.untilStart(shift.start)}',
|
: 'Clock in · ${Fmt.untilStart(shift.start)}',
|
||||||
),
|
confirmedLabel: 'Clocked in',
|
||||||
|
onConfirm: () => _clockIn(shift),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_GeofenceCard(
|
_GeofenceCard(
|
||||||
@@ -178,7 +184,7 @@ class _ClockScreenState extends ConsumerState<ClockScreen> {
|
|||||||
shift: shift,
|
shift: shift,
|
||||||
onTap: () => context.push('/shift/${shift.id}'),
|
onTap: () => context.push('/shift/${shift.id}'),
|
||||||
),
|
),
|
||||||
],
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -195,8 +201,8 @@ class _ClockScreenState extends ConsumerState<ClockScreen> {
|
|||||||
|
|
||||||
void _clockIn(Shift shift) {
|
void _clockIn(Shift shift) {
|
||||||
// Starting and ending a shift are the two moments that matter most in the
|
// 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.
|
// app; both get a firm confirmation you can feel without looking. Clocking
|
||||||
HapticFeedback.mediumImpact();
|
// in gets its haptic from SwipeAction, at the instant the swipe commits.
|
||||||
ref.read(shiftsProvider.notifier).clockIn(shift.id);
|
ref.read(shiftsProvider.notifier).clockIn(shift.id);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Clocked in at ${Fmt.time(DateTime.now())}')),
|
SnackBar(content: Text('Clocked in at ${Fmt.time(DateTime.now())}')),
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class ComplianceHubScreen extends ConsumerWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
HeaderIconButton(
|
HeaderIconButton(
|
||||||
icon: LucideIcons.arrowLeft,
|
icon: LucideIcons.chevronLeft,
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
StatusChip(
|
StatusChip(
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
|||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: KrowPalette.sage,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
@@ -502,7 +502,7 @@ class WelcomeScreen extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: KrowPalette.sage,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../state/profile_controller.dart';
|
|||||||
import '../state/shifts_controller.dart';
|
import '../state/shifts_controller.dart';
|
||||||
import '../widgets/collapsing_header.dart';
|
import '../widgets/collapsing_header.dart';
|
||||||
import '../widgets/common.dart';
|
import '../widgets/common.dart';
|
||||||
|
import '../widgets/motion.dart';
|
||||||
import '../widgets/glass_segments.dart';
|
import '../widgets/glass_segments.dart';
|
||||||
import '../widgets/krow_wordmark.dart';
|
import '../widgets/krow_wordmark.dart';
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ class _PayScreenState extends ConsumerState<PayScreen> {
|
|||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
|
||||||
sliver: SliverList.list(
|
sliver: SliverList.list(
|
||||||
children: [
|
children: Entrance.stagger([
|
||||||
KrowCard(
|
KrowCard(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -260,7 +261,7 @@ class _PayScreenState extends ConsumerState<PayScreen> {
|
|||||||
'Amounts are pre-tax. Your take-home differs after tax, '
|
'Amounts are pre-tax. Your take-home differs after tax, '
|
||||||
'deductions and withholding from your payroll provider.',
|
'deductions and withholding from your payroll provider.',
|
||||||
),
|
),
|
||||||
],
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -516,7 +517,7 @@ class PaymentDetailScreen extends ConsumerWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
HeaderIconButton(
|
HeaderIconButton(
|
||||||
icon: LucideIcons.arrowLeft,
|
icon: LucideIcons.chevronLeft,
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
const StatusChip('Pending', tone: ChipTone.yellow),
|
const StatusChip('Pending', tone: ChipTone.yellow),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../state/compliance_controller.dart';
|
|||||||
import '../state/profile_controller.dart';
|
import '../state/profile_controller.dart';
|
||||||
import '../widgets/collapsing_header.dart';
|
import '../widgets/collapsing_header.dart';
|
||||||
import '../widgets/common.dart';
|
import '../widgets/common.dart';
|
||||||
|
import '../widgets/motion.dart';
|
||||||
import '../widgets/krow_wordmark.dart';
|
import '../widgets/krow_wordmark.dart';
|
||||||
|
|
||||||
class ProfileScreen extends ConsumerWidget {
|
class ProfileScreen extends ConsumerWidget {
|
||||||
@@ -161,7 +162,7 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
|
padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
|
||||||
sliver: SliverList.list(
|
sliver: SliverList.list(
|
||||||
children: [
|
children: Entrance.stagger([
|
||||||
StatRail(
|
StatRail(
|
||||||
stats: [
|
stats: [
|
||||||
('Shifts', '${worker.shiftsWorked}'),
|
('Shifts', '${worker.shiftsWorked}'),
|
||||||
@@ -323,7 +324,7 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
],
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class ShiftDetailScreen extends ConsumerWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
HeaderIconButton(
|
HeaderIconButton(
|
||||||
icon: LucideIcons.arrowLeft,
|
icon: LucideIcons.chevronLeft,
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
HeaderIconButton(
|
HeaderIconButton(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../core/theme.dart';
|
|||||||
import '../models/shift.dart';
|
import '../models/shift.dart';
|
||||||
import '../state/shifts_controller.dart';
|
import '../state/shifts_controller.dart';
|
||||||
import '../widgets/common.dart';
|
import '../widgets/common.dart';
|
||||||
|
import '../widgets/motion.dart';
|
||||||
import '../widgets/glass_segments.dart';
|
import '../widgets/glass_segments.dart';
|
||||||
import '../widgets/krow_wordmark.dart';
|
import '../widgets/krow_wordmark.dart';
|
||||||
import '../widgets/shift_card.dart';
|
import '../widgets/shift_card.dart';
|
||||||
@@ -165,7 +166,7 @@ class _BookedTab extends ConsumerWidget {
|
|||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
|
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
|
||||||
children: [
|
children: Entrance.stagger([
|
||||||
WeekStrip(
|
WeekStrip(
|
||||||
days: week,
|
days: week,
|
||||||
selected: selected,
|
selected: selected,
|
||||||
@@ -193,10 +194,10 @@ class _BookedTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
const InfoBanner(
|
const InfoBanner(
|
||||||
'Auto-Match only offers you shifts inside the availability blocks you '
|
'Auto-Match only offers you shifts on the days you have marked '
|
||||||
'have switched on.',
|
'yourself available.',
|
||||||
),
|
),
|
||||||
],
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,7 +243,7 @@ class _OpenTab extends ConsumerWidget {
|
|||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
|
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
|
||||||
children: [
|
children: Entrance.stagger([
|
||||||
TextField(
|
TextField(
|
||||||
onChanged: onQuery,
|
onChanged: onQuery,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
@@ -292,7 +293,7 @@ class _OpenTab extends ConsumerWidget {
|
|||||||
trailingAction: _ApplyButton(shift: shift),
|
trailingAction: _ApplyButton(shift: shift),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,7 +361,7 @@ class _HistoryTab extends ConsumerWidget {
|
|||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
|
padding: const EdgeInsets.fromLTRB(20, 18, 20, 24),
|
||||||
children: [
|
children: Entrance.stagger([
|
||||||
StatRail(
|
StatRail(
|
||||||
stats: [
|
stats: [
|
||||||
('Completed', '$completed'),
|
('Completed', '$completed'),
|
||||||
@@ -385,7 +386,7 @@ class _HistoryTab extends ConsumerWidget {
|
|||||||
onTap: () => context.push('/shift/${shift.id}'),
|
onTap: () => context.push('/shift/${shift.id}'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../state/profile_controller.dart';
|
|||||||
import '../state/shifts_controller.dart';
|
import '../state/shifts_controller.dart';
|
||||||
import '../widgets/collapsing_header.dart';
|
import '../widgets/collapsing_header.dart';
|
||||||
import '../widgets/common.dart';
|
import '../widgets/common.dart';
|
||||||
|
import '../widgets/motion.dart';
|
||||||
import '../widgets/shift_card.dart';
|
import '../widgets/shift_card.dart';
|
||||||
import '../widgets/krow_wordmark.dart';
|
import '../widgets/krow_wordmark.dart';
|
||||||
|
|
||||||
@@ -168,7 +169,7 @@ class TodayScreen extends ConsumerWidget {
|
|||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
||||||
sliver: SliverList.list(
|
sliver: SliverList.list(
|
||||||
children: [
|
children: Entrance.stagger([
|
||||||
if (!compliant) ...[
|
if (!compliant) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
KrowCard(
|
KrowCard(
|
||||||
@@ -276,7 +277,7 @@ class TodayScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
if (open.isEmpty)
|
if (open.isEmpty)
|
||||||
const InfoBanner(
|
const InfoBanner(
|
||||||
'No open shifts right now. Turn on more availability blocks '
|
'No open shifts right now. Mark more days available '
|
||||||
'and we will match you to more work.',
|
'and we will match you to more work.',
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -289,7 +290,7 @@ class TodayScreen extends ConsumerWidget {
|
|||||||
onTap: () => context.push('/shift/${shift.id}'),
|
onTap: () => context.push('/shift/${shift.id}'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,94 +4,107 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import '../models/availability.dart';
|
import '../models/availability.dart';
|
||||||
import 'session_controller.dart';
|
import 'session_controller.dart';
|
||||||
|
|
||||||
/// Availability is stored as `yyyy-mm-dd` -> the blocks switched on that day.
|
/// Availability is stored as `yyyy-mm-dd` -> what the worker said about it.
|
||||||
typedef AvailabilityMap = Map<String, Set<DayBlock>>;
|
///
|
||||||
|
/// Days the worker has not answered are simply absent, and read back as
|
||||||
|
/// unavailable: nobody is on the hook for a day they never opened.
|
||||||
|
typedef AvailabilityMap = Map<String, DayPlan>;
|
||||||
|
|
||||||
String dayKey(DateTime d) =>
|
String dayKey(DateTime d) =>
|
||||||
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
class AvailabilityController extends Notifier<AvailabilityMap> {
|
class AvailabilityController extends Notifier<AvailabilityMap> {
|
||||||
static const _key = 'availability';
|
static const _key = 'availability_v2';
|
||||||
|
|
||||||
SharedPreferences get _prefs => ref.read(prefsProvider);
|
SharedPreferences get _prefs => ref.read(prefsProvider);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AvailabilityMap build() {
|
AvailabilityMap build() {
|
||||||
final raw = _prefs.getStringList(_key) ?? const <String>[];
|
final raw = _prefs.getStringList(_key) ?? const <String>[];
|
||||||
final map = <String, Set<DayBlock>>{};
|
final map = <String, DayPlan>{};
|
||||||
for (final entry in raw) {
|
for (final entry in raw) {
|
||||||
// Stored as "2026-08-14:morning,evening".
|
// Stored as "2026-10-05:prefers:9-17", or "…:available", or
|
||||||
|
// "…:prefers:any" when no hours were given.
|
||||||
final parts = entry.split(':');
|
final parts = entry.split(':');
|
||||||
if (parts.length != 2) continue;
|
if (parts.length < 2) continue;
|
||||||
final blocks = parts[1]
|
final status = DayAvailability.values.firstWhere(
|
||||||
.split(',')
|
(s) => s.name == parts[1],
|
||||||
.where((s) => s.isNotEmpty)
|
orElse: () => DayAvailability.unavailable,
|
||||||
.map(
|
);
|
||||||
(name) => DayBlock.values.firstWhere(
|
if (status == DayAvailability.unavailable) continue;
|
||||||
(b) => b.name == name,
|
map[parts[0]] = DayPlan(
|
||||||
orElse: () => DayBlock.morning,
|
status: status,
|
||||||
),
|
window: parts.length > 2 ? _parseWindow(parts[2]) : PreferredWindow.any,
|
||||||
)
|
);
|
||||||
.toSet();
|
|
||||||
if (blocks.isNotEmpty) map[parts[0]] = blocks;
|
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static PreferredWindow _parseWindow(String raw) {
|
||||||
|
if (raw == 'any') return PreferredWindow.any;
|
||||||
|
final ends = raw.split('-');
|
||||||
|
if (ends.length != 2) return PreferredWindow.any;
|
||||||
|
return PreferredWindow(
|
||||||
|
from: int.tryParse(ends[0]),
|
||||||
|
to: int.tryParse(ends[1]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _encodeWindow(PreferredWindow w) =>
|
||||||
|
w.isAnyTime ? 'any' : '${w.from ?? ''}-${w.to ?? ''}';
|
||||||
|
|
||||||
void _persist(AvailabilityMap map) {
|
void _persist(AvailabilityMap map) {
|
||||||
_prefs.setStringList(_key, [
|
_prefs.setStringList(_key, [
|
||||||
for (final e in map.entries)
|
for (final e in map.entries)
|
||||||
if (e.value.isNotEmpty)
|
if (e.value.isSet)
|
||||||
'${e.key}:${e.value.map((b) => b.name).join(',')}',
|
'${e.key}:${e.value.status.name}:${_encodeWindow(e.value.window)}',
|
||||||
]);
|
]);
|
||||||
state = map;
|
state = map;
|
||||||
}
|
}
|
||||||
|
|
||||||
Set<DayBlock> blocksFor(DateTime day) => state[dayKey(day)] ?? <DayBlock>{};
|
AvailabilityMap get _copy => {for (final e in state.entries) e.key: e.value};
|
||||||
|
|
||||||
bool isDayOff(DateTime day) => blocksFor(day).isEmpty;
|
DayPlan planFor(DateTime day) => state[dayKey(day)] ?? const DayPlan();
|
||||||
|
|
||||||
void toggleBlock(DateTime day, DayBlock block) {
|
bool isDayOff(DateTime day) => !planFor(day).isSet;
|
||||||
final map = {
|
|
||||||
for (final e in state.entries) e.key: {...e.value},
|
void setStatus(DateTime day, DayAvailability status) {
|
||||||
};
|
final map = _copy;
|
||||||
final key = dayKey(day);
|
final key = dayKey(day);
|
||||||
final blocks = map[key] ?? <DayBlock>{};
|
if (status == DayAvailability.unavailable) {
|
||||||
if (!blocks.remove(block)) blocks.add(block);
|
|
||||||
if (blocks.isEmpty) {
|
|
||||||
map.remove(key);
|
map.remove(key);
|
||||||
} else {
|
} else {
|
||||||
map[key] = blocks;
|
// The window rides along, so a worker who flips to Unavailable and back
|
||||||
|
// does not have to set their hours a second time.
|
||||||
|
map[key] = (map[key] ?? const DayPlan()).copyWith(status: status);
|
||||||
}
|
}
|
||||||
_persist(map);
|
_persist(map);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The day toggle: all three blocks on, or the day cleared.
|
void setWindow(DateTime day, PreferredWindow window) {
|
||||||
void setDayAvailable(DateTime day, bool available) {
|
|
||||||
final map = {
|
|
||||||
for (final e in state.entries) e.key: {...e.value},
|
|
||||||
};
|
|
||||||
final key = dayKey(day);
|
final key = dayKey(day);
|
||||||
if (available) {
|
final current = state[key];
|
||||||
map[key] = DayBlock.values.toSet();
|
// A window without a status to hang it on is not a day the worker has
|
||||||
} else {
|
// answered, so there is nothing to store.
|
||||||
map.remove(key);
|
if (current == null) return;
|
||||||
}
|
final map = _copy..[key] = current.copyWith(window: window);
|
||||||
_persist(map);
|
_persist(map);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Presets across the visible week: all days, weekdays only, weekends only.
|
/// Presets across the visible week: all days, weekdays only, weekends only.
|
||||||
|
/// Days outside the preset are cleared, which is what makes the four
|
||||||
|
/// buttons read as four answers rather than four additive filters.
|
||||||
void applyPreset(
|
void applyPreset(
|
||||||
List<DateTime> week, {
|
List<DateTime> week, {
|
||||||
required bool Function(DateTime) test,
|
required bool Function(DateTime) test,
|
||||||
}) {
|
}) {
|
||||||
final map = {
|
final map = _copy;
|
||||||
for (final e in state.entries) e.key: {...e.value},
|
|
||||||
};
|
|
||||||
for (final day in week) {
|
for (final day in week) {
|
||||||
final key = dayKey(day);
|
final key = dayKey(day);
|
||||||
if (test(day)) {
|
if (test(day)) {
|
||||||
map[key] = DayBlock.values.toSet();
|
map[key] = (map[key] ?? const DayPlan()).copyWith(
|
||||||
|
status: DayAvailability.available,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
map.remove(key);
|
map.remove(key);
|
||||||
}
|
}
|
||||||
@@ -100,9 +113,7 @@ class AvailabilityController extends Notifier<AvailabilityMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void clearWeek(List<DateTime> week) {
|
void clearWeek(List<DateTime> week) {
|
||||||
final map = {
|
final map = _copy;
|
||||||
for (final e in state.entries) e.key: {...e.value},
|
|
||||||
};
|
|
||||||
for (final day in week) {
|
for (final day in week) {
|
||||||
map.remove(dayKey(day));
|
map.remove(dayKey(day));
|
||||||
}
|
}
|
||||||
@@ -115,14 +126,13 @@ final availabilityProvider =
|
|||||||
AvailabilityController.new,
|
AvailabilityController.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// How many days in the coming week have at least one block switched on.
|
/// How many days in the coming week the worker has said yes to, either way.
|
||||||
final daysSetProvider = Provider<int>((ref) {
|
final daysSetProvider = Provider<int>((ref) {
|
||||||
final map = ref.watch(availabilityProvider);
|
final map = ref.watch(availabilityProvider);
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
var count = 0;
|
var count = 0;
|
||||||
for (var i = 0; i < 7; i++) {
|
for (var i = 0; i < 7; i++) {
|
||||||
final d = now.add(Duration(days: i));
|
if (map[dayKey(now.add(Duration(days: i)))]?.isSet ?? false) count++;
|
||||||
if ((map[dayKey(d)] ?? const <DayBlock>{}).isNotEmpty) count++;
|
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import '../models/shift.dart';
|
|||||||
import '../models/timecard.dart';
|
import '../models/timecard.dart';
|
||||||
|
|
||||||
/// Demo content. Everything is anchored to "now" so the app is live whenever
|
/// Demo content. Everything is anchored to "now" so the app is live whenever
|
||||||
/// it is opened: the next shift always starts in 41 minutes.
|
/// it is opened: the next shift always starts in 20 minutes, inside the
|
||||||
|
/// clock-in window.
|
||||||
abstract final class Seed {
|
abstract final class Seed {
|
||||||
static DateTime get _now => DateTime.now();
|
static DateTime get _now => DateTime.now();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
||||||
import '../core/theme.dart';
|
import '../core/theme.dart';
|
||||||
|
import 'motion.dart';
|
||||||
|
|
||||||
/// Small uppercase section heading used above every group of cards.
|
/// Small uppercase section heading used above every group of cards.
|
||||||
class SectionLabel extends StatelessWidget {
|
class SectionLabel extends StatelessWidget {
|
||||||
@@ -149,7 +150,9 @@ class KrowCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Material(
|
return PressScale(
|
||||||
|
enabled: onTap != null,
|
||||||
|
child: Material(
|
||||||
color: dashed ? Colors.transparent : Colors.white,
|
color: dashed ? Colors.transparent : Colors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -164,6 +167,7 @@ class KrowCard extends StatelessWidget {
|
|||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +191,9 @@ class NavTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InkWell(
|
return PressScale(
|
||||||
|
enabled: onTap != null,
|
||||||
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
@@ -239,6 +245,7 @@ class NavTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
141
lib/widgets/motion.dart
Normal file
141
lib/widgets/motion.dart
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Shared motion primitives.
|
||||||
|
///
|
||||||
|
/// Both of these are deliberately drop-in: [Entrance.stagger] takes the list a
|
||||||
|
/// screen already builds and hands back the same list, and [PressScale] lives
|
||||||
|
/// inside the shared cards rather than in any screen. Adding motion to a
|
||||||
|
/// screen should not mean rewiring it.
|
||||||
|
|
||||||
|
/// Fades and lifts its child into place, once, when it first appears.
|
||||||
|
///
|
||||||
|
/// A screen that arrives fully drawn tells you nothing about how it is put
|
||||||
|
/// together. Letting the rows land in order — a short distance, quickly —
|
||||||
|
/// shows the reading order before the worker has to find it, which matters
|
||||||
|
/// most on the screens that are a long column of similar cards.
|
||||||
|
class Entrance extends StatefulWidget {
|
||||||
|
const Entrance({super.key, required this.child, this.delay = Duration.zero});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
/// How long to wait before starting. [stagger] fills this in.
|
||||||
|
final Duration delay;
|
||||||
|
|
||||||
|
/// Wraps each child with an increasing delay.
|
||||||
|
///
|
||||||
|
/// The stagger stops counting after [_maxSteps] so a long list does not end
|
||||||
|
/// up with a row that waits a second and a half to appear — past the first
|
||||||
|
/// handful the effect has already done its job.
|
||||||
|
static List<Widget> stagger(List<Widget> children, {Duration step = _step}) {
|
||||||
|
return [
|
||||||
|
for (final (i, child) in children.indexed)
|
||||||
|
Entrance(delay: step * (i > _maxSteps ? _maxSteps : i), child: child),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static const _step = Duration(milliseconds: 55);
|
||||||
|
static const _maxSteps = 6;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<Entrance> createState() => _EntranceState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EntranceState extends State<Entrance>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
late final Animation<double> _curve;
|
||||||
|
|
||||||
|
static const _travel = Duration(milliseconds: 380);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
// The delay is folded into the controller as a leading Interval rather
|
||||||
|
// than 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 shows up as a pending timer at teardown and keeps a
|
||||||
|
// disposed screen's work alive. The controller goes away with the state.
|
||||||
|
final total = widget.delay + _travel;
|
||||||
|
_controller = AnimationController(vsync: this, duration: total);
|
||||||
|
_curve = CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Interval(
|
||||||
|
widget.delay.inMicroseconds / total.inMicroseconds,
|
||||||
|
1,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _curve,
|
||||||
|
// The child is passed through untouched, so it is built once rather
|
||||||
|
// than rebuilt on every frame of the entrance.
|
||||||
|
child: widget.child,
|
||||||
|
builder: (context, child) => Opacity(
|
||||||
|
opacity: _curve.value,
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(0, (1 - _curve.value) * 14),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shrinks its child slightly while a finger is down on it.
|
||||||
|
///
|
||||||
|
/// Material's ink ripple answers "the app heard you" after the fact, and on a
|
||||||
|
/// white card on a white page it is nearly invisible. A card that gives under
|
||||||
|
/// the thumb answers during the press instead, which is the half-second that
|
||||||
|
/// actually feels unresponsive.
|
||||||
|
class PressScale extends StatefulWidget {
|
||||||
|
const PressScale({super.key, required this.child, this.enabled = true});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
/// False for a card with no tap handler, which should not pretend to react.
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PressScale> createState() => _PressScaleState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PressScaleState extends State<PressScale> {
|
||||||
|
bool _down = false;
|
||||||
|
|
||||||
|
void _set(bool down) {
|
||||||
|
if (!widget.enabled || _down == down) return;
|
||||||
|
setState(() => _down = down);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!widget.enabled) return widget.child;
|
||||||
|
|
||||||
|
return Listener(
|
||||||
|
// Listener rather than GestureDetector: this has to read the press
|
||||||
|
// without competing for the gesture, so the InkWell underneath still
|
||||||
|
// gets its tap and a scroll that starts on a card still scrolls.
|
||||||
|
onPointerDown: (_) => _set(true),
|
||||||
|
onPointerUp: (_) => _set(false),
|
||||||
|
onPointerCancel: (_) => _set(false),
|
||||||
|
child: AnimatedScale(
|
||||||
|
scale: _down ? 0.975 : 1,
|
||||||
|
duration: const Duration(milliseconds: 130),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import '../core/formatters.dart';
|
|||||||
import '../core/theme.dart';
|
import '../core/theme.dart';
|
||||||
import '../models/shift.dart';
|
import '../models/shift.dart';
|
||||||
import 'common.dart';
|
import 'common.dart';
|
||||||
|
import 'motion.dart';
|
||||||
|
|
||||||
/// The one card shape in the app. Identity on top, a hairline split, then the
|
/// 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.
|
/// money band — with a status bar welded to the bottom edge.
|
||||||
@@ -46,7 +47,9 @@ class ShiftCard extends StatelessWidget {
|
|||||||
shift.status == ShiftStatus.cancelled ||
|
shift.status == ShiftStatus.cancelled ||
|
||||||
shift.status == ShiftStatus.noShow;
|
shift.status == ShiftStatus.noShow;
|
||||||
|
|
||||||
return Material(
|
return PressScale(
|
||||||
|
enabled: onTap != null,
|
||||||
|
child: Material(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -165,6 +168,7 @@ class ShiftCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
391
lib/widgets/swipe_action.dart
Normal file
391
lib/widgets/swipe_action.dart
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
||||||
|
import '../core/theme.dart';
|
||||||
|
|
||||||
|
/// A slide-to-confirm control.
|
||||||
|
///
|
||||||
|
/// Used where a tap is too cheap for what it does. Clocking in stamps the
|
||||||
|
/// start of a paid shift and is the moment a worker is least able to undo a
|
||||||
|
/// mistake — a thumb brushing the screen while the phone comes out of a
|
||||||
|
/// pocket should not start it. A deliberate drag across the whole control
|
||||||
|
/// cannot happen by accident, and unlike a confirmation dialog it costs the
|
||||||
|
/// worker nothing when they meant it.
|
||||||
|
///
|
||||||
|
/// Shaped as a pill rather than following the 14px button radius on purpose:
|
||||||
|
/// it is the one control in the app you drag instead of tap, and looking
|
||||||
|
/// slightly unlike a button is what tells you that before you touch it. The
|
||||||
|
/// track is a deep channel that the brand blue *fills* as you cross it, so
|
||||||
|
/// the action visibly completes rather than merely moving a tile from one end
|
||||||
|
/// to the other.
|
||||||
|
class SwipeAction extends StatefulWidget {
|
||||||
|
const SwipeAction({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onConfirm,
|
||||||
|
this.confirmedLabel,
|
||||||
|
this.enabled = true,
|
||||||
|
this.background = KrowPalette.blue,
|
||||||
|
this.channel = KrowPalette.blueDeep,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
/// What the control says before it is dragged. When [enabled] is false this
|
||||||
|
/// is the place to say why — "Clock in · in 19 min".
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Shown once the swipe commits. Falls back to [label].
|
||||||
|
final String? confirmedLabel;
|
||||||
|
|
||||||
|
final VoidCallback onConfirm;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
/// The fill that chases the thumb.
|
||||||
|
final Color background;
|
||||||
|
|
||||||
|
/// The channel the fill runs through.
|
||||||
|
final Color channel;
|
||||||
|
|
||||||
|
/// Matches FilledButton's minimum height, so a swipe and a tap button can
|
||||||
|
/// sit in the same column without a step between them.
|
||||||
|
static const double height = 56;
|
||||||
|
|
||||||
|
static const double _inset = 5;
|
||||||
|
|
||||||
|
/// How far across the worker has to get. Short of the full width, because
|
||||||
|
/// the last few pixels are hard to reach without shifting grip, and nobody
|
||||||
|
/// drags to 100% of anything.
|
||||||
|
static const double _commitFraction = 0.82;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SwipeAction> createState() => _SwipeActionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SwipeActionState extends State<SwipeAction>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
// Built in initState, not as lazy `late final` initialisers: a control that
|
||||||
|
// is never dragged would otherwise create its controller inside dispose(),
|
||||||
|
// where the element is already deactivated and the TickerMode lookup throws.
|
||||||
|
late final AnimationController _settle;
|
||||||
|
|
||||||
|
/// Drives the resting hint — the sheen and the chevrons.
|
||||||
|
///
|
||||||
|
/// Runs a few cycles and stops, rather than shimmering for the whole
|
||||||
|
/// session: a control that never stops animating never lets the widget tree
|
||||||
|
/// settle, which hangs `pumpAndSettle` in any test that reaches a screen
|
||||||
|
/// holding one, and costs a frame every frame for an affordance that has
|
||||||
|
/// long since been read. It re-arms whenever the control becomes enabled or
|
||||||
|
/// a swipe falls short — the two moments a worker actually needs the hint.
|
||||||
|
late final AnimationController _hint;
|
||||||
|
|
||||||
|
static const _hintCycles = 3;
|
||||||
|
|
||||||
|
/// Where the thumb sits, in pixels from the left of its travel.
|
||||||
|
double _offset = 0;
|
||||||
|
|
||||||
|
/// Travel is only known at paint time, and the drag handlers need it.
|
||||||
|
double _travel = 0;
|
||||||
|
|
||||||
|
bool _dragging = false;
|
||||||
|
bool _done = false;
|
||||||
|
|
||||||
|
bool get _hinting => widget.enabled && !_dragging && !_done;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_settle = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 240),
|
||||||
|
);
|
||||||
|
_hint = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 2200),
|
||||||
|
);
|
||||||
|
if (_hinting) _hint.repeat(count: _hintCycles);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(SwipeAction old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
_syncHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncHint() {
|
||||||
|
if (_hinting && !_hint.isAnimating) {
|
||||||
|
_hint.repeat(count: _hintCycles);
|
||||||
|
} else if (!_hinting && _hint.isAnimating) {
|
||||||
|
_hint.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_settle.dispose();
|
||||||
|
_hint.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _snapTo(double target) {
|
||||||
|
final from = _offset;
|
||||||
|
_settle.stop();
|
||||||
|
final animation = Tween(
|
||||||
|
begin: from,
|
||||||
|
end: target,
|
||||||
|
).animate(CurvedAnimation(parent: _settle, curve: Curves.easeOutCubic));
|
||||||
|
void tick() => setState(() => _offset = animation.value);
|
||||||
|
animation.addListener(tick);
|
||||||
|
_settle.forward(from: 0).whenComplete(() => animation.removeListener(tick));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDragUpdate(DragUpdateDetails details) {
|
||||||
|
if (!widget.enabled || _done) return;
|
||||||
|
setState(() {
|
||||||
|
_dragging = true;
|
||||||
|
_offset = (_offset + details.delta.dx).clamp(0.0, _travel);
|
||||||
|
});
|
||||||
|
_syncHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDragEnd(DragEndDetails _) {
|
||||||
|
if (!widget.enabled || _done) return;
|
||||||
|
setState(() => _dragging = false);
|
||||||
|
|
||||||
|
if (_travel > 0 && _offset >= _travel * SwipeAction._commitFraction) {
|
||||||
|
_confirm();
|
||||||
|
} else {
|
||||||
|
_snapTo(0);
|
||||||
|
_syncHint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirm() {
|
||||||
|
// Held at the end rather than snapped back: the control has to look
|
||||||
|
// committed while whatever it triggered takes over the screen.
|
||||||
|
setState(() => _done = true);
|
||||||
|
_syncHint();
|
||||||
|
_snapTo(_travel);
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
widget.onConfirm();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final enabled = widget.enabled;
|
||||||
|
|
||||||
|
// The same disabled pair FilledButton uses, so a disabled swipe and a
|
||||||
|
// disabled button are the same colour.
|
||||||
|
final channel = enabled ? widget.channel : KrowPalette.disabled;
|
||||||
|
final fill = enabled ? widget.background : KrowPalette.disabled;
|
||||||
|
final onTrack = enabled ? Colors.white : const Color(0xFF949FA5);
|
||||||
|
|
||||||
|
const radius = SwipeAction.height / 2;
|
||||||
|
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
enabled: enabled,
|
||||||
|
label: widget.label,
|
||||||
|
// Assistive tech cannot drag, so the gesture has a tap-equivalent.
|
||||||
|
onTap: enabled && !_done ? _confirm : null,
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
const inset = SwipeAction._inset;
|
||||||
|
final thumb = SwipeAction.height - inset * 2;
|
||||||
|
_travel = constraints.maxWidth - thumb - inset * 2;
|
||||||
|
|
||||||
|
final progress = _travel <= 0
|
||||||
|
? 0.0
|
||||||
|
: (_offset / _travel).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: SwipeAction.height,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(radius),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(child: ColoredBox(color: channel)),
|
||||||
|
|
||||||
|
// The fill chases the thumb: the action is as done as the
|
||||||
|
// bar is full.
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: inset * 2 + thumb + _offset,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: fill,
|
||||||
|
borderRadius: BorderRadius.circular(radius),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// A light band crossing the track, so the control reads as
|
||||||
|
// something that moves before anyone touches it.
|
||||||
|
if (_hinting)
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _hint,
|
||||||
|
builder: (context, _) => _Sheen(t: _hint.value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Center(
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 1 - progress,
|
||||||
|
child: Text(
|
||||||
|
_done
|
||||||
|
? (widget.confirmedLabel ?? widget.label)
|
||||||
|
: widget.label,
|
||||||
|
style: Theme.of(context).textTheme.labelLarge
|
||||||
|
?.copyWith(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: onTrack,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Chevrons mark the end the thumb is headed for, and fade
|
||||||
|
// out as it arrives.
|
||||||
|
Positioned(
|
||||||
|
right: 18,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Opacity(
|
||||||
|
opacity: enabled ? (1 - progress) * 0.9 : 0.45,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _hint,
|
||||||
|
builder: (context, _) => _Chevrons(
|
||||||
|
t: _hint.isAnimating ? _hint.value : null,
|
||||||
|
color: onTrack,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Positioned(
|
||||||
|
left: inset + _offset,
|
||||||
|
top: inset,
|
||||||
|
child: GestureDetector(
|
||||||
|
onHorizontalDragUpdate: _onDragUpdate,
|
||||||
|
onHorizontalDragEnd: _onDragEnd,
|
||||||
|
// The thumb has to take the drag wherever it lands, even
|
||||||
|
// once it has travelled past its own starting box.
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: AnimatedScale(
|
||||||
|
scale: _dragging ? 1.06 : 1,
|
||||||
|
duration: const Duration(milliseconds: 140),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
child: Container(
|
||||||
|
width: thumb,
|
||||||
|
height: thumb,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
child: Icon(
|
||||||
|
_done ? LucideIcons.check : widget.icon,
|
||||||
|
key: ValueKey(_done),
|
||||||
|
size: 19,
|
||||||
|
color: enabled
|
||||||
|
? widget.background
|
||||||
|
: const Color(0xFF949FA5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The travelling highlight. A gradient band rather than a blurred shape, so
|
||||||
|
/// it costs nothing per frame.
|
||||||
|
class _Sheen extends StatelessWidget {
|
||||||
|
const _Sheen({required this.t});
|
||||||
|
|
||||||
|
/// 0..1 through the hint cycle.
|
||||||
|
final double t;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Most of the cycle is spent off-screen to the right, so the sweep reads
|
||||||
|
// as an occasional glint rather than a constant shimmer.
|
||||||
|
final x = -1.6 + t * 4.2;
|
||||||
|
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment(x - 0.45, 0),
|
||||||
|
end: Alignment(x + 0.45, 0),
|
||||||
|
colors: const [
|
||||||
|
Color(0x00FFFFFF),
|
||||||
|
Color(0x26FFFFFF),
|
||||||
|
Color(0x00FFFFFF),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three chevrons that brighten in sequence, pointing at the end of the run.
|
||||||
|
class _Chevrons extends StatelessWidget {
|
||||||
|
const _Chevrons({required this.t, required this.color});
|
||||||
|
|
||||||
|
/// 0..1 through the hint cycle, or null once the hint has run its course —
|
||||||
|
/// at rest they sit at a flat, quiet brightness.
|
||||||
|
final double? t;
|
||||||
|
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: List.generate(3, (i) {
|
||||||
|
// Each chevron leads the one before it by a third of a cycle.
|
||||||
|
final cycle = t;
|
||||||
|
final double lit;
|
||||||
|
if (cycle == null) {
|
||||||
|
lit = 0.0;
|
||||||
|
} else {
|
||||||
|
final phase = (cycle * 3 - i) % 3;
|
||||||
|
lit = phase < 1 ? 1 - phase : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(left: i == 0 ? 0 : 1),
|
||||||
|
child: Icon(
|
||||||
|
LucideIcons.chevronRight,
|
||||||
|
size: 16,
|
||||||
|
color: color.withValues(alpha: 0.3 + 0.55 * lit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import 'package:krow_worker_app/core/theme.dart';
|
|||||||
import 'package:krow_worker_app/widgets/common.dart';
|
import 'package:krow_worker_app/widgets/common.dart';
|
||||||
import 'package:krow_worker_app/widgets/glass_segments.dart';
|
import 'package:krow_worker_app/widgets/glass_segments.dart';
|
||||||
import 'package:krow_worker_app/widgets/krow_nav_bar.dart';
|
import 'package:krow_worker_app/widgets/krow_nav_bar.dart';
|
||||||
|
import 'package:krow_worker_app/widgets/motion.dart';
|
||||||
|
import 'package:krow_worker_app/widgets/swipe_action.dart';
|
||||||
|
|
||||||
Widget wrap(Widget child) => MaterialApp(
|
Widget wrap(Widget child) => MaterialApp(
|
||||||
theme: buildKrowTheme(),
|
theme: buildKrowTheme(),
|
||||||
@@ -192,4 +194,86 @@ void main() {
|
|||||||
expect(calls, 1);
|
expect(calls, 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('Entrance', () {
|
||||||
|
testWidgets('settles opaque and in place', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(
|
||||||
|
Column(
|
||||||
|
children: Entrance.stagger(const [Text('one'), Text('two')]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
for (final o in tester.widgetList<Opacity>(find.byType(Opacity))) {
|
||||||
|
expect(o.opacity, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('leaves no timer behind when disposed mid-flight', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(Column(children: Entrance.stagger(const [Text('one')]))),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Torn down before the entrance finishes. A delay implemented as a
|
||||||
|
// stray timer survives this and fails the test at teardown.
|
||||||
|
await tester.pump(const Duration(milliseconds: 20));
|
||||||
|
await tester.pumpWidget(wrap(const SizedBox()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SwipeAction', () {
|
||||||
|
Widget swipe({
|
||||||
|
required VoidCallback onConfirm,
|
||||||
|
bool enabled = true,
|
||||||
|
}) => wrap(
|
||||||
|
SizedBox(
|
||||||
|
width: 320,
|
||||||
|
child: SwipeAction(
|
||||||
|
icon: Icons.login,
|
||||||
|
label: 'Swipe to clock in',
|
||||||
|
enabled: enabled,
|
||||||
|
onConfirm: onConfirm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('confirms once dragged across the track', (tester) async {
|
||||||
|
var confirms = 0;
|
||||||
|
await tester.pumpWidget(swipe(onConfirm: () => confirms++));
|
||||||
|
|
||||||
|
await tester.drag(find.byIcon(Icons.login), const Offset(320, 0));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(confirms, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('snaps back when the drag stops short', (tester) async {
|
||||||
|
var confirms = 0;
|
||||||
|
await tester.pumpWidget(swipe(onConfirm: () => confirms++));
|
||||||
|
|
||||||
|
// Well under the commit fraction: a brush, not a deliberate swipe.
|
||||||
|
await tester.drag(find.byIcon(Icons.login), const Offset(60, 0));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(confirms, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ignores the drag while disabled', (tester) async {
|
||||||
|
var confirms = 0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
swipe(onConfirm: () => confirms++, enabled: false),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.drag(find.byIcon(Icons.login), const Offset(320, 0));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(confirms, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user