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

View File

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

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 '../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),
],
),
),
),
),

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