diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..f8d14e9 --- /dev/null +++ b/DESIGN.md @@ -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`). diff --git a/README.md b/README.md index da6756e..9429657 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Motion is used to explain what changed, not to decorate. The rules: changes ahead of the thing causing it. - **Numbers grow.** Progress bars and chart bars tween to new values, so an uploaded document or a switched pay range is something you watch happen. -- **State changes animate together.** Availability blocks, week-strip days and +- **State changes animate together.** Availability options, week-strip days and the clock dial move every part of themselves in one gesture rather than flipping several properties at once. - **Consequential actions are felt.** Clocking in and out give a medium impact; diff --git a/lib/core/theme.dart b/lib/core/theme.dart index 7051b6a..53f9312 100644 --- a/lib/core/theme.dart +++ b/lib/core/theme.dart @@ -1,8 +1,13 @@ import 'package:flutter/material.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 -/// 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 { static const blue = Color(0xFF3752A8); static const blueDeep = Color(0xFF22366F); @@ -13,6 +18,18 @@ abstract final class KrowPalette { static const ink = Color(0xFF26353E); static const line = Color(0xFFCFDDD3); 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() { @@ -36,9 +53,9 @@ ThemeData buildKrowTheme() { useMaterial3: true, colorScheme: scheme, textTheme: text, - scaffoldBackgroundColor: KrowPalette.sage, + scaffoldBackgroundColor: Colors.white, appBarTheme: AppBarTheme( - backgroundColor: KrowPalette.sage, + backgroundColor: Colors.white, surfaceTintColor: Colors.transparent, foregroundColor: KrowPalette.ink, elevation: 0, @@ -58,6 +75,14 @@ ThemeData buildKrowTheme() { 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( color: KrowPalette.line, thickness: 1, @@ -68,7 +93,7 @@ ThemeData buildKrowTheme() { minimumSize: const Size.fromHeight(52), backgroundColor: KrowPalette.blue, foregroundColor: Colors.white, - disabledBackgroundColor: const Color(0xFFD5E2D9), + disabledBackgroundColor: KrowPalette.disabled, disabledForegroundColor: const Color(0xFF949FA5), textStyle: GoogleFonts.poppins( fontSize: 15, diff --git a/lib/models/availability.dart b/lib/models/availability.dart index e41c0e2..01500b4 100644 --- a/lib/models/availability.dart +++ b/lib/models/availability.dart @@ -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) { - DayBlock.morning => 'Morning', - DayBlock.afternoon => 'Afternoon', - DayBlock.evening => 'Evening', + DayAvailability.available => 'Available', + DayAvailability.prefers => 'Prefers to work', + DayAvailability.unavailable => 'Unavailable', }; - String get window => switch (this) { - DayBlock.morning => '04:00 - 12:00', - DayBlock.afternoon => '12:00 - 18:00', - DayBlock.evening => '18:00 - 24:00', + /// The line under the option, in the picker. + String get blurb => switch (this) { + DayAvailability.available => 'Can be scheduled any time', + DayAvailability.prefers => 'Wants shifts — optionally within a time window', + DayAvailability.unavailable => 'Marked off (vacation, sick, personal)', }; - int get hours => switch (this) { - DayBlock.morning => 8, - DayBlock.afternoon => 6, - DayBlock.evening => 6, + /// The line under the date, once the day is set. + String get summary => switch (this) { + DayAvailability.available => 'Can be scheduled any time', + 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); +} diff --git a/lib/screens/auth_screens.dart b/lib/screens/auth_screens.dart index 4636bd7..830eae2 100644 --- a/lib/screens/auth_screens.dart +++ b/lib/screens/auth_screens.dart @@ -249,7 +249,7 @@ class _OtpScreenState extends ConsumerState { hintStyle: const TextStyle( fontSize: 30, letterSpacing: 14, - color: Color(0xFFC4D4C9), + color: KrowPalette.edge, ), ), ), @@ -382,7 +382,7 @@ class VerifiedScreen extends ConsumerWidget { Container( width: double.infinity, decoration: const BoxDecoration( - color: KrowPalette.sage, + color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), child: SafeArea( @@ -603,7 +603,7 @@ class _PermissionsScreenState extends ConsumerState { final permission = _permissions[_step]; return Scaffold( - backgroundColor: KrowPalette.sage, + backgroundColor: Colors.white, body: Stack( children: [ // The app itself, already built, sitting behind the ask. @@ -681,7 +681,7 @@ class _PermissionSheet extends StatelessWidget { width: 40, height: 4, decoration: BoxDecoration( - color: const Color(0xFFCFDDD4), + color: KrowPalette.edge, borderRadius: BorderRadius.circular(2), ), ), diff --git a/lib/screens/availability_screen.dart b/lib/screens/availability_screen.dart index aff9bf9..e221c6d 100644 --- a/lib/screens/availability_screen.dart +++ b/lib/screens/availability_screen.dart @@ -9,7 +9,8 @@ import '../state/availability_controller.dart'; import '../widgets/common.dart'; import '../widgets/week_strip.dart'; -/// Auto-Match only offers shifts inside the blocks switched on here. +/// What the worker tells Auto-Match about each day of the coming week: +/// available, actively wanting work, or marked off. class AvailabilityScreen extends ConsumerStatefulWidget { const AvailabilityScreen({super.key}); @@ -30,11 +31,10 @@ class _AvailabilityScreenState extends ConsumerState { final controller = ref.watch(availabilityProvider.notifier); ref.watch(availabilityProvider); - final blocks = controller.blocksFor(_selected); - final hours = blocks.fold(0, (sum, b) => sum + b.hours); + final plan = controller.planFor(_selected); final marked = { for (var i = 0; i < _week.length; i++) - if (controller.blocksFor(_week[i]).isNotEmpty) i, + if (!controller.isDayOff(_week[i])) i, }; return Scaffold( @@ -49,7 +49,7 @@ class _AvailabilityScreenState extends ConsumerState { Row( children: [ HeaderIconButton( - icon: LucideIcons.arrowLeft, + icon: LucideIcons.chevronLeft, onPressed: () => Navigator.of(context).pop(), ), const SizedBox(width: 12), @@ -115,67 +115,63 @@ class _AvailabilityScreenState extends ConsumerState { KrowCard( padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - Fmt.dayShort(_selected), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - letterSpacing: -0.5, - ), - ), - const SizedBox(height: 2), - Text( - blocks.isEmpty - ? 'Marked as a day off' - : 'Available · $hours hrs', - style: TextStyle( - fontSize: 12.5, - fontWeight: FontWeight.w500, - color: blocks.isEmpty - ? KrowPalette.mute - : KrowPalette.blue, - ), - ), - ], - ), - ), - Switch( - value: blocks.isNotEmpty, - onChanged: (v) => - controller.setDayAvailable(_selected, v), - ), - ], + Text( + Fmt.dayShort(_selected), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 2), + Text( + plan.status.summary, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w500, + color: plan.isSet + ? KrowPalette.blue + : KrowPalette.mute, + ), ), const SizedBox(height: 14), - for (final block in DayBlock.values) - Padding( - padding: const EdgeInsets.only(bottom: 9), - child: _BlockRow( - block: block, - on: blocks.contains(block), - onTap: () => - controller.toggleBlock(_selected, block), - ), + for (final status in DayAvailability.values) ...[ + _StatusOption( + status: status, + selected: plan.status == status, + onTap: () => controller.setStatus(_selected, status), ), + // The window belongs to the option it qualifies, so it + // 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), - InfoBanner( - blocks.isEmpty - ? 'With every block off, Auto-Match will not send you ' - 'anything on this day.' - : 'Saved. Auto-Match will only offer you shifts inside ' - 'these blocks.', - icon: blocks.isEmpty ? null : LucideIcons.check, - ), + InfoBanner(switch (plan.status) { + DayAvailability.unavailable => + 'Auto-Match will not send you anything on this day.', + DayAvailability.available => + 'Saved. Auto-Match can offer you any shift on this day.', + DayAvailability.prefers => + 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,116 +218,266 @@ class _Preset extends StatelessWidget { } } -class _BlockRow extends StatelessWidget { - const _BlockRow({required this.block, required this.on, required this.onTap}); +/// One of the three answers, as a selectable card. +class _StatusOption extends StatelessWidget { + const _StatusOption({ + required this.status, + required this.selected, + required this.onTap, + }); - final DayBlock block; - final bool on; + final DayAvailability status; + final bool selected; final VoidCallback onTap; - IconData get _icon => switch (block) { - DayBlock.morning => LucideIcons.sunrise, - DayBlock.afternoon => LucideIcons.sun, - DayBlock.evening => LucideIcons.moon, - }; - @override Widget build(BuildContext context) { - const duration = Duration(milliseconds: 260); + const duration = Duration(milliseconds: 220); const curve = Curves.easeOutCubic; - return GestureDetector( - onTap: onTap, - // Every part of the row animates together, so switching a block on is a - // single motion rather than four things changing at once. - child: AnimatedContainer( - duration: duration, - curve: curve, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: on ? KrowPalette.blueTint : const Color(0xFFE5F0E8), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: on - ? KrowPalette.blue.withValues(alpha: 0.3) - : Colors.transparent, + return Semantics( + inMutuallyExclusiveGroup: true, + selected: selected, + button: true, + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: duration, + curve: curve, + padding: const EdgeInsets.fromLTRB(14, 13, 14, 13), + decoration: BoxDecoration( + color: selected ? KrowPalette.blueTint : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: selected ? KrowPalette.blue : KrowPalette.line, + width: selected ? 1.5 : 1, + ), ), - ), - child: Row( - children: [ - AnimatedContainer( - duration: duration, - curve: curve, - width: 38, - height: 38, - decoration: BoxDecoration( - color: on ? KrowPalette.blue : Colors.white, - borderRadius: BorderRadius.circular(11), - border: Border.all( - color: on ? KrowPalette.blue : KrowPalette.line, - ), - ), - child: Icon( - _icon, - size: 18, - color: on ? Colors.white : const Color(0xFF949FA5), - ), - ), - const SizedBox(width: 13), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AnimatedDefaultTextStyle( - duration: duration, - curve: curve, - style: TextStyle( - fontSize: 14.5, - fontWeight: FontWeight.w600, - color: on ? KrowPalette.ink : KrowPalette.mute, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Radio(selected: selected), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AnimatedDefaultTextStyle( + duration: duration, + curve: curve, + style: TextStyle( + fontSize: 14.5, + fontWeight: FontWeight.w600, + color: selected ? KrowPalette.blue : KrowPalette.ink, + ), + child: Text(status.label), ), - child: Text(block.label), - ), - AnimatedDefaultTextStyle( - duration: duration, - curve: curve, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - color: on ? KrowPalette.blue : const Color(0xFF949FA5), + const SizedBox(height: 2), + Text( + status.blurb, + style: const TextStyle( + fontSize: 11.5, + height: 1.35, + fontWeight: FontWeight.w500, + 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 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 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( + 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( + value: null, + child: Text('Any time'), + ), + for (var h = first; h < 24; h++) + DropdownMenuItem( + value: h, + child: Text('${h.toString().padLeft(2, '0')}:00'), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/clock_screen.dart b/lib/screens/clock_screen.dart index 2de1285..340219c 100644 --- a/lib/screens/clock_screen.dart +++ b/lib/screens/clock_screen.dart @@ -14,9 +14,11 @@ import '../state/shifts_controller.dart'; import '../state/timecards_controller.dart'; import '../widgets/collapsing_header.dart'; import '../widgets/common.dart'; +import '../widgets/motion.dart'; import '../widgets/glass_segments.dart'; import '../widgets/krow_wordmark.dart'; import '../widgets/shift_card.dart'; +import '../widgets/swipe_action.dart'; import 'timecard_screens.dart'; /// Clock in and out. The button unlocks 30 minutes before the shift starts, @@ -122,7 +124,9 @@ class _ClockScreenState extends ConsumerState { SliverPadding( padding: const EdgeInsets.fromLTRB(20, 18, 20, 28), sliver: SliverList.list( - children: timecardItems(ref.watch(timecardsProvider)), + children: Entrance.stagger( + timecardItems(ref.watch(timecardsProvider)), + ), ), ) else if (shift == null) @@ -142,7 +146,7 @@ class _ClockScreenState extends ConsumerState { SliverPadding( padding: const EdgeInsets.fromLTRB(20, 28, 20, 24), sliver: SliverList.list( - children: [ + children: Entrance.stagger([ _ClockDial(shift: shift, atVenue: _atVenue), const SizedBox(height: 22), if (shift.status == ShiftStatus.active) @@ -155,16 +159,18 @@ class _ClockScreenState extends ConsumerState { label: const Text('Clock out'), ) else - FilledButton.icon( - onPressed: _canClockIn(shift, locationGranted) - ? () => _clockIn(shift) - : null, - icon: const Icon(LucideIcons.logIn, size: 18), - label: Text( - _canClockIn(shift, locationGranted) - ? 'Clock in' - : 'Clock in · ${Fmt.untilStart(shift.start)}', - ), + SwipeAction( + // The thumb is a handle, so its icon says how to + // work the control, not what the control does - the + // label already says 'clock in', and the check on + // commit carries the outcome. + icon: LucideIcons.arrowRight, + enabled: _canClockIn(shift, locationGranted), + label: _canClockIn(shift, locationGranted) + ? 'Swipe to clock in' + : 'Clock in · ${Fmt.untilStart(shift.start)}', + confirmedLabel: 'Clocked in', + onConfirm: () => _clockIn(shift), ), const SizedBox(height: 12), _GeofenceCard( @@ -178,7 +184,7 @@ class _ClockScreenState extends ConsumerState { shift: shift, onTap: () => context.push('/shift/${shift.id}'), ), - ], + ]), ), ), ], @@ -195,8 +201,8 @@ class _ClockScreenState extends ConsumerState { void _clockIn(Shift shift) { // Starting and ending a shift are the two moments that matter most in the - // app; both get a firm confirmation you can feel without looking. - HapticFeedback.mediumImpact(); + // app; both get a firm confirmation you can feel without looking. Clocking + // in gets its haptic from SwipeAction, at the instant the swipe commits. ref.read(shiftsProvider.notifier).clockIn(shift.id); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Clocked in at ${Fmt.time(DateTime.now())}')), diff --git a/lib/screens/compliance_screens.dart b/lib/screens/compliance_screens.dart index ac480ad..c93c04f 100644 --- a/lib/screens/compliance_screens.dart +++ b/lib/screens/compliance_screens.dart @@ -48,7 +48,7 @@ class ComplianceHubScreen extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ HeaderIconButton( - icon: LucideIcons.arrowLeft, + icon: LucideIcons.chevronLeft, onPressed: () => context.pop(), ), StatusChip( diff --git a/lib/screens/onboarding_screens.dart b/lib/screens/onboarding_screens.dart index 85c1741..cba95a6 100644 --- a/lib/screens/onboarding_screens.dart +++ b/lib/screens/onboarding_screens.dart @@ -349,7 +349,7 @@ class _OnboardingScreenState extends ConsumerState { Container( width: double.infinity, decoration: const BoxDecoration( - color: KrowPalette.sage, + color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), child: SafeArea( @@ -502,7 +502,7 @@ class WelcomeScreen extends StatelessWidget { Container( width: double.infinity, decoration: const BoxDecoration( - color: KrowPalette.sage, + color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), child: SafeArea( diff --git a/lib/screens/pay_screens.dart b/lib/screens/pay_screens.dart index 29f576e..8686712 100644 --- a/lib/screens/pay_screens.dart +++ b/lib/screens/pay_screens.dart @@ -11,6 +11,7 @@ import '../state/profile_controller.dart'; import '../state/shifts_controller.dart'; import '../widgets/collapsing_header.dart'; import '../widgets/common.dart'; +import '../widgets/motion.dart'; import '../widgets/glass_segments.dart'; import '../widgets/krow_wordmark.dart'; @@ -127,7 +128,7 @@ class _PayScreenState extends ConsumerState { SliverPadding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 28), sliver: SliverList.list( - children: [ + children: Entrance.stagger([ KrowCard( padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), child: Column( @@ -260,7 +261,7 @@ class _PayScreenState extends ConsumerState { 'Amounts are pre-tax. Your take-home differs after tax, ' 'deductions and withholding from your payroll provider.', ), - ], + ]), ), ), ], @@ -516,7 +517,7 @@ class PaymentDetailScreen extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ HeaderIconButton( - icon: LucideIcons.arrowLeft, + icon: LucideIcons.chevronLeft, onPressed: () => context.pop(), ), const StatusChip('Pending', tone: ChipTone.yellow), diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 11f0854..68d1d46 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -8,6 +8,7 @@ import '../state/compliance_controller.dart'; import '../state/profile_controller.dart'; import '../widgets/collapsing_header.dart'; import '../widgets/common.dart'; +import '../widgets/motion.dart'; import '../widgets/krow_wordmark.dart'; class ProfileScreen extends ConsumerWidget { @@ -161,7 +162,7 @@ class ProfileScreen extends ConsumerWidget { SliverPadding( padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), sliver: SliverList.list( - children: [ + children: Entrance.stagger([ StatRail( stats: [ ('Shifts', '${worker.shiftsWorked}'), @@ -323,7 +324,7 @@ class ProfileScreen extends ConsumerWidget { ], ), const SizedBox(height: 28), - ], + ]), ), ), ], diff --git a/lib/screens/shift_detail_screen.dart b/lib/screens/shift_detail_screen.dart index 36169f7..a0b5468 100644 --- a/lib/screens/shift_detail_screen.dart +++ b/lib/screens/shift_detail_screen.dart @@ -39,7 +39,7 @@ class ShiftDetailScreen extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ HeaderIconButton( - icon: LucideIcons.arrowLeft, + icon: LucideIcons.chevronLeft, onPressed: () => context.pop(), ), HeaderIconButton( diff --git a/lib/screens/shifts_screen.dart b/lib/screens/shifts_screen.dart index a5911fd..5e584ee 100644 --- a/lib/screens/shifts_screen.dart +++ b/lib/screens/shifts_screen.dart @@ -9,6 +9,7 @@ import '../core/theme.dart'; import '../models/shift.dart'; import '../state/shifts_controller.dart'; import '../widgets/common.dart'; +import '../widgets/motion.dart'; import '../widgets/glass_segments.dart'; import '../widgets/krow_wordmark.dart'; import '../widgets/shift_card.dart'; @@ -165,7 +166,7 @@ class _BookedTab extends ConsumerWidget { return ListView( padding: const EdgeInsets.fromLTRB(20, 18, 20, 24), - children: [ + children: Entrance.stagger([ WeekStrip( days: week, selected: selected, @@ -193,10 +194,10 @@ class _BookedTab extends ConsumerWidget { ), const SizedBox(height: 6), const InfoBanner( - 'Auto-Match only offers you shifts inside the availability blocks you ' - 'have switched on.', + 'Auto-Match only offers you shifts on the days you have marked ' + 'yourself available.', ), - ], + ]), ); } } @@ -242,7 +243,7 @@ class _OpenTab extends ConsumerWidget { return ListView( padding: const EdgeInsets.fromLTRB(20, 18, 20, 24), - children: [ + children: Entrance.stagger([ TextField( onChanged: onQuery, decoration: const InputDecoration( @@ -292,7 +293,7 @@ class _OpenTab extends ConsumerWidget { trailingAction: _ApplyButton(shift: shift), ), ), - ], + ]), ); } } @@ -360,7 +361,7 @@ class _HistoryTab extends ConsumerWidget { return ListView( padding: const EdgeInsets.fromLTRB(20, 18, 20, 24), - children: [ + children: Entrance.stagger([ StatRail( stats: [ ('Completed', '$completed'), @@ -385,7 +386,7 @@ class _HistoryTab extends ConsumerWidget { onTap: () => context.push('/shift/${shift.id}'), ), ), - ], + ]), ); } } diff --git a/lib/screens/today_screen.dart b/lib/screens/today_screen.dart index f80db5c..915cb1f 100644 --- a/lib/screens/today_screen.dart +++ b/lib/screens/today_screen.dart @@ -12,6 +12,7 @@ import '../state/profile_controller.dart'; import '../state/shifts_controller.dart'; import '../widgets/collapsing_header.dart'; import '../widgets/common.dart'; +import '../widgets/motion.dart'; import '../widgets/shift_card.dart'; import '../widgets/krow_wordmark.dart'; @@ -168,7 +169,7 @@ class TodayScreen extends ConsumerWidget { SliverPadding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), sliver: SliverList.list( - children: [ + children: Entrance.stagger([ if (!compliant) ...[ const SizedBox(height: 16), KrowCard( @@ -276,7 +277,7 @@ class TodayScreen extends ConsumerWidget { ), if (open.isEmpty) 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.', ) else @@ -289,7 +290,7 @@ class TodayScreen extends ConsumerWidget { onTap: () => context.push('/shift/${shift.id}'), ), ), - ], + ]), ), ), ], diff --git a/lib/state/availability_controller.dart b/lib/state/availability_controller.dart index 1076900..1448396 100644 --- a/lib/state/availability_controller.dart +++ b/lib/state/availability_controller.dart @@ -4,94 +4,107 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/availability.dart'; import 'session_controller.dart'; -/// Availability is stored as `yyyy-mm-dd` -> the blocks switched on that day. -typedef AvailabilityMap = Map>; +/// Availability is stored as `yyyy-mm-dd` -> what the worker said about it. +/// +/// 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 dayKey(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; class AvailabilityController extends Notifier { - static const _key = 'availability'; + static const _key = 'availability_v2'; SharedPreferences get _prefs => ref.read(prefsProvider); @override AvailabilityMap build() { final raw = _prefs.getStringList(_key) ?? const []; - final map = >{}; + final map = {}; 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(':'); - if (parts.length != 2) continue; - final blocks = parts[1] - .split(',') - .where((s) => s.isNotEmpty) - .map( - (name) => DayBlock.values.firstWhere( - (b) => b.name == name, - orElse: () => DayBlock.morning, - ), - ) - .toSet(); - if (blocks.isNotEmpty) map[parts[0]] = blocks; + if (parts.length < 2) continue; + final status = DayAvailability.values.firstWhere( + (s) => s.name == parts[1], + orElse: () => DayAvailability.unavailable, + ); + if (status == DayAvailability.unavailable) continue; + map[parts[0]] = DayPlan( + status: status, + window: parts.length > 2 ? _parseWindow(parts[2]) : PreferredWindow.any, + ); } 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) { _prefs.setStringList(_key, [ for (final e in map.entries) - if (e.value.isNotEmpty) - '${e.key}:${e.value.map((b) => b.name).join(',')}', + if (e.value.isSet) + '${e.key}:${e.value.status.name}:${_encodeWindow(e.value.window)}', ]); state = map; } - Set blocksFor(DateTime day) => state[dayKey(day)] ?? {}; + 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) { - final map = { - for (final e in state.entries) e.key: {...e.value}, - }; + bool isDayOff(DateTime day) => !planFor(day).isSet; + + void setStatus(DateTime day, DayAvailability status) { + final map = _copy; final key = dayKey(day); - final blocks = map[key] ?? {}; - if (!blocks.remove(block)) blocks.add(block); - if (blocks.isEmpty) { + if (status == DayAvailability.unavailable) { map.remove(key); } 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); } - /// The day toggle: all three blocks on, or the day cleared. - void setDayAvailable(DateTime day, bool available) { - final map = { - for (final e in state.entries) e.key: {...e.value}, - }; + void setWindow(DateTime day, PreferredWindow window) { final key = dayKey(day); - if (available) { - map[key] = DayBlock.values.toSet(); - } else { - map.remove(key); - } + final current = state[key]; + // A window without a status to hang it on is not a day the worker has + // answered, so there is nothing to store. + if (current == null) return; + final map = _copy..[key] = current.copyWith(window: window); _persist(map); } /// 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( List week, { required bool Function(DateTime) test, }) { - final map = { - for (final e in state.entries) e.key: {...e.value}, - }; + final map = _copy; for (final day in week) { final key = dayKey(day); if (test(day)) { - map[key] = DayBlock.values.toSet(); + map[key] = (map[key] ?? const DayPlan()).copyWith( + status: DayAvailability.available, + ); } else { map.remove(key); } @@ -100,9 +113,7 @@ class AvailabilityController extends Notifier { } void clearWeek(List week) { - final map = { - for (final e in state.entries) e.key: {...e.value}, - }; + final map = _copy; for (final day in week) { map.remove(dayKey(day)); } @@ -115,14 +126,13 @@ final availabilityProvider = 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((ref) { final map = ref.watch(availabilityProvider); final now = DateTime.now(); var count = 0; for (var i = 0; i < 7; i++) { - final d = now.add(Duration(days: i)); - if ((map[dayKey(d)] ?? const {}).isNotEmpty) count++; + if (map[dayKey(now.add(Duration(days: i)))]?.isSet ?? false) count++; } return count; }); diff --git a/lib/state/seed.dart b/lib/state/seed.dart index 86dc9ae..3af7a6f 100644 --- a/lib/state/seed.dart +++ b/lib/state/seed.dart @@ -3,7 +3,8 @@ import '../models/shift.dart'; import '../models/timecard.dart'; /// Demo content. Everything is anchored to "now" so the app is live whenever -/// it is opened: the next shift always starts in 41 minutes. +/// it is opened: the next shift always starts in 20 minutes, inside the +/// clock-in window. abstract final class Seed { static DateTime get _now => DateTime.now(); diff --git a/lib/widgets/common.dart b/lib/widgets/common.dart index c7d70e2..0a4d9ce 100644 --- a/lib/widgets/common.dart +++ b/lib/widgets/common.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../core/theme.dart'; +import 'motion.dart'; /// Small uppercase section heading used above every group of cards. class SectionLabel extends StatelessWidget { @@ -149,19 +150,22 @@ class KrowCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Material( - color: dashed ? Colors.transparent : Colors.white, - borderRadius: BorderRadius.circular(16), - child: InkWell( - onTap: onTap, + return PressScale( + enabled: onTap != null, + child: Material( + color: dashed ? Colors.transparent : Colors.white, borderRadius: BorderRadius.circular(16), - child: Container( - padding: padding, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: KrowPalette.line), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: padding, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: KrowPalette.line), + ), + child: child, ), - child: child, ), ), ); @@ -187,56 +191,59 @@ class NavTile extends StatelessWidget { @override Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: KrowPalette.blueTint, - borderRadius: BorderRadius.circular(11), + return PressScale( + enabled: onTap != null, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: KrowPalette.blueTint, + borderRadius: BorderRadius.circular(11), + ), + child: Icon(icon, size: 18, color: KrowPalette.blue), ), - child: Icon(icon, size: 18, color: KrowPalette.blue), - ), - const SizedBox(width: 13), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 14.5, - fontWeight: FontWeight.w600, - ), - ), - if (subtitle != null) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - subtitle!, - style: const TextStyle( - fontSize: 12, - height: 1.35, - color: KrowPalette.mute, - ), + const SizedBox(width: 13), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 14.5, + fontWeight: FontWeight.w600, ), ), - ], + if (subtitle != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + subtitle!, + style: const TextStyle( + fontSize: 12, + height: 1.35, + color: KrowPalette.mute, + ), + ), + ), + ], + ), ), - ), - if (badge != null) ...[const SizedBox(width: 8), badge!], - const SizedBox(width: 8), - const Icon( - LucideIcons.chevronRight, - size: 17, - color: Color(0xFF949FA5), - ), - ], + if (badge != null) ...[const SizedBox(width: 8), badge!], + const SizedBox(width: 8), + const Icon( + LucideIcons.chevronRight, + size: 17, + color: Color(0xFF949FA5), + ), + ], + ), ), ), ); diff --git a/lib/widgets/motion.dart b/lib/widgets/motion.dart new file mode 100644 index 0000000..cc158cf --- /dev/null +++ b/lib/widgets/motion.dart @@ -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 stagger(List 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 createState() => _EntranceState(); +} + +class _EntranceState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _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 createState() => _PressScaleState(); +} + +class _PressScaleState extends State { + 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, + ), + ); + } +} diff --git a/lib/widgets/shift_card.dart b/lib/widgets/shift_card.dart index b894258..92ccf55 100644 --- a/lib/widgets/shift_card.dart +++ b/lib/widgets/shift_card.dart @@ -5,6 +5,7 @@ import '../core/formatters.dart'; import '../core/theme.dart'; import '../models/shift.dart'; import 'common.dart'; +import 'motion.dart'; /// The one card shape in the app. Identity on top, a hairline split, then the /// money band — with a status bar welded to the bottom edge. @@ -46,122 +47,125 @@ class ShiftCard extends StatelessWidget { shift.status == ShiftStatus.cancelled || shift.status == ShiftStatus.noShow; - return Material( - color: Colors.white, - borderRadius: BorderRadius.circular(18), - child: InkWell( - onTap: onTap, + return PressScale( + enabled: onTap != null, + child: Material( + color: Colors.white, borderRadius: BorderRadius.circular(18), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18), - border: Border.all(color: KrowPalette.line), - ), - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - BusinessBadge(shift.logoLetter), - const SizedBox(width: 13), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - shift.role, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - letterSpacing: -0.5, - height: 1.15, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all(color: KrowPalette.line), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BusinessBadge(shift.logoLetter), + const SizedBox(width: 13), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + shift.role, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + height: 1.15, + ), ), - ), - const SizedBox(height: 3), - Text( - shift.venue, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12.5, - color: KrowPalette.mute, + const SizedBox(height: 3), + Text( + shift.venue, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12.5, + color: KrowPalette.mute, + ), ), - ), - ], + ], + ), ), - ), - const SizedBox(width: 8), - StatusChip(statusLabel, tone: statusTone), - ], - ), - const SizedBox(height: 12), - Wrap( - spacing: 7, - runSpacing: 7, - children: [ - if (showDate) + const SizedBox(width: 8), + StatusChip(statusLabel, tone: statusTone), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 7, + runSpacing: 7, + children: [ + if (showDate) + MetaPill( + LucideIcons.calendar, + Fmt.dayShort(shift.start), + ), MetaPill( - LucideIcons.calendar, - Fmt.dayShort(shift.start), + LucideIcons.clock, + Fmt.window(shift.start, shift.end), ), - MetaPill( - LucideIcons.clock, - Fmt.window(shift.start, shift.end), - ), - MetaPill( - LucideIcons.mapPin, - '${shift.distanceMiles} mi', - ), - ], - ), - ], + MetaPill( + LucideIcons.mapPin, + '${shift.distanceMiles} mi', + ), + ], + ), + ], + ), ), - ), - const Divider(height: 1), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( - children: [ - Expanded( - child: Text( - unpaid - ? 'No pay generated' - : '${Fmt.money(shift.rate)}/hr · ' - '${Fmt.duration(shift.scheduledDuration)}', - style: const TextStyle( - fontSize: 11.5, - fontWeight: FontWeight.w600, - letterSpacing: 0.2, - color: Color(0xFF949FA5), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + Expanded( + child: Text( + unpaid + ? 'No pay generated' + : '${Fmt.money(shift.rate)}/hr · ' + '${Fmt.duration(shift.scheduledDuration)}', + style: const TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + color: Color(0xFF949FA5), + ), ), ), - ), - if (trailingAction != null) - trailingAction! - else - Text( - Fmt.money(shift.pay), - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - letterSpacing: -0.7, - color: unpaid - ? const Color(0xFF949FA5) - : KrowPalette.ink, + if (trailingAction != null) + trailingAction! + else + Text( + Fmt.money(shift.pay), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.7, + color: unpaid + ? const Color(0xFF949FA5) + : KrowPalette.ink, + ), ), - ), - ], + ], + ), ), - ), - Container(height: 4, color: _railColor), - ], + Container(height: 4, color: _railColor), + ], + ), ), ), ), diff --git a/lib/widgets/swipe_action.dart b/lib/widgets/swipe_action.dart new file mode 100644 index 0000000..351ec3b --- /dev/null +++ b/lib/widgets/swipe_action.dart @@ -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 createState() => _SwipeActionState(); +} + +class _SwipeActionState extends State + 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), + ), + ); + }), + ); + } +} diff --git a/test/widgets_test.dart b/test/widgets_test.dart index 1cfe3e7..9b01b04 100644 --- a/test/widgets_test.dart +++ b/test/widgets_test.dart @@ -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/glass_segments.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( theme: buildKrowTheme(), @@ -192,4 +194,86 @@ void main() { 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(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); + }); + }); }