fixed designs and flow

This commit is contained in:
R-Bharathraj
2026-10-05 22:14:58 +05:30
parent d76d409416
commit aef0ac1793
21 changed files with 1594 additions and 423 deletions

276
DESIGN.md Normal file
View 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`).

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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),
), ),
), ),

View File

@@ -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'),
),
],
),
),
),
],
); );
} }
} }

View File

@@ -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())}')),

View File

@@ -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(

View File

@@ -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(

View File

@@ -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),

View File

@@ -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),
], ]),
), ),
), ),
], ],

View File

@@ -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(

View File

@@ -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}'),
), ),
), ),
], ]),
); );
} }
} }

View File

@@ -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}'),
), ),
), ),
], ]),
), ),
), ),
], ],

View File

@@ -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;
}); });

View File

@@ -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();

View File

@@ -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
View 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,
),
);
}
}

View File

@@ -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 {
), ),
), ),
), ),
),
); );
} }
} }

View 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),
),
);
}),
);
}
}

View File

@@ -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);
});
});
} }