fixed designs and flow
This commit is contained in:
@@ -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
141
lib/widgets/motion.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shared motion primitives.
|
||||
///
|
||||
/// Both of these are deliberately drop-in: [Entrance.stagger] takes the list a
|
||||
/// screen already builds and hands back the same list, and [PressScale] lives
|
||||
/// inside the shared cards rather than in any screen. Adding motion to a
|
||||
/// screen should not mean rewiring it.
|
||||
|
||||
/// Fades and lifts its child into place, once, when it first appears.
|
||||
///
|
||||
/// A screen that arrives fully drawn tells you nothing about how it is put
|
||||
/// together. Letting the rows land in order — a short distance, quickly —
|
||||
/// shows the reading order before the worker has to find it, which matters
|
||||
/// most on the screens that are a long column of similar cards.
|
||||
class Entrance extends StatefulWidget {
|
||||
const Entrance({super.key, required this.child, this.delay = Duration.zero});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// How long to wait before starting. [stagger] fills this in.
|
||||
final Duration delay;
|
||||
|
||||
/// Wraps each child with an increasing delay.
|
||||
///
|
||||
/// The stagger stops counting after [_maxSteps] so a long list does not end
|
||||
/// up with a row that waits a second and a half to appear — past the first
|
||||
/// handful the effect has already done its job.
|
||||
static List<Widget> stagger(List<Widget> children, {Duration step = _step}) {
|
||||
return [
|
||||
for (final (i, child) in children.indexed)
|
||||
Entrance(delay: step * (i > _maxSteps ? _maxSteps : i), child: child),
|
||||
];
|
||||
}
|
||||
|
||||
static const _step = Duration(milliseconds: 55);
|
||||
static const _maxSteps = 6;
|
||||
|
||||
@override
|
||||
State<Entrance> createState() => _EntranceState();
|
||||
}
|
||||
|
||||
class _EntranceState extends State<Entrance>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Animation<double> _curve;
|
||||
|
||||
static const _travel = Duration(milliseconds: 380);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// The delay is folded into the controller as a leading Interval rather
|
||||
// than run as a Future.delayed. A standalone timer outlives the widget it
|
||||
// was armed for — a `mounted` guard protects the callback but not the
|
||||
// timer, which then shows up as a pending timer at teardown and keeps a
|
||||
// disposed screen's work alive. The controller goes away with the state.
|
||||
final total = widget.delay + _travel;
|
||||
_controller = AnimationController(vsync: this, duration: total);
|
||||
_curve = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Interval(
|
||||
widget.delay.inMicroseconds / total.inMicroseconds,
|
||||
1,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _curve,
|
||||
// The child is passed through untouched, so it is built once rather
|
||||
// than rebuilt on every frame of the entrance.
|
||||
child: widget.child,
|
||||
builder: (context, child) => Opacity(
|
||||
opacity: _curve.value,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, (1 - _curve.value) * 14),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shrinks its child slightly while a finger is down on it.
|
||||
///
|
||||
/// Material's ink ripple answers "the app heard you" after the fact, and on a
|
||||
/// white card on a white page it is nearly invisible. A card that gives under
|
||||
/// the thumb answers during the press instead, which is the half-second that
|
||||
/// actually feels unresponsive.
|
||||
class PressScale extends StatefulWidget {
|
||||
const PressScale({super.key, required this.child, this.enabled = true});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// False for a card with no tap handler, which should not pretend to react.
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
State<PressScale> createState() => _PressScaleState();
|
||||
}
|
||||
|
||||
class _PressScaleState extends State<PressScale> {
|
||||
bool _down = false;
|
||||
|
||||
void _set(bool down) {
|
||||
if (!widget.enabled || _down == down) return;
|
||||
setState(() => _down = down);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.enabled) return widget.child;
|
||||
|
||||
return Listener(
|
||||
// Listener rather than GestureDetector: this has to read the press
|
||||
// without competing for the gesture, so the InkWell underneath still
|
||||
// gets its tap and a scroll that starts on a card still scrolls.
|
||||
onPointerDown: (_) => _set(true),
|
||||
onPointerUp: (_) => _set(false),
|
||||
onPointerCancel: (_) => _set(false),
|
||||
child: AnimatedScale(
|
||||
scale: _down ? 0.975 : 1,
|
||||
duration: const Duration(milliseconds: 130),
|
||||
curve: Curves.easeOut,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import '../core/formatters.dart';
|
||||
import '../core/theme.dart';
|
||||
import '../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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
391
lib/widgets/swipe_action.dart
Normal file
391
lib/widgets/swipe_action.dart
Normal file
@@ -0,0 +1,391 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../core/theme.dart';
|
||||
|
||||
/// A slide-to-confirm control.
|
||||
///
|
||||
/// Used where a tap is too cheap for what it does. Clocking in stamps the
|
||||
/// start of a paid shift and is the moment a worker is least able to undo a
|
||||
/// mistake — a thumb brushing the screen while the phone comes out of a
|
||||
/// pocket should not start it. A deliberate drag across the whole control
|
||||
/// cannot happen by accident, and unlike a confirmation dialog it costs the
|
||||
/// worker nothing when they meant it.
|
||||
///
|
||||
/// Shaped as a pill rather than following the 14px button radius on purpose:
|
||||
/// it is the one control in the app you drag instead of tap, and looking
|
||||
/// slightly unlike a button is what tells you that before you touch it. The
|
||||
/// track is a deep channel that the brand blue *fills* as you cross it, so
|
||||
/// the action visibly completes rather than merely moving a tile from one end
|
||||
/// to the other.
|
||||
class SwipeAction extends StatefulWidget {
|
||||
const SwipeAction({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onConfirm,
|
||||
this.confirmedLabel,
|
||||
this.enabled = true,
|
||||
this.background = KrowPalette.blue,
|
||||
this.channel = KrowPalette.blueDeep,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
|
||||
/// What the control says before it is dragged. When [enabled] is false this
|
||||
/// is the place to say why — "Clock in · in 19 min".
|
||||
final String label;
|
||||
|
||||
/// Shown once the swipe commits. Falls back to [label].
|
||||
final String? confirmedLabel;
|
||||
|
||||
final VoidCallback onConfirm;
|
||||
final bool enabled;
|
||||
|
||||
/// The fill that chases the thumb.
|
||||
final Color background;
|
||||
|
||||
/// The channel the fill runs through.
|
||||
final Color channel;
|
||||
|
||||
/// Matches FilledButton's minimum height, so a swipe and a tap button can
|
||||
/// sit in the same column without a step between them.
|
||||
static const double height = 56;
|
||||
|
||||
static const double _inset = 5;
|
||||
|
||||
/// How far across the worker has to get. Short of the full width, because
|
||||
/// the last few pixels are hard to reach without shifting grip, and nobody
|
||||
/// drags to 100% of anything.
|
||||
static const double _commitFraction = 0.82;
|
||||
|
||||
@override
|
||||
State<SwipeAction> createState() => _SwipeActionState();
|
||||
}
|
||||
|
||||
class _SwipeActionState extends State<SwipeAction>
|
||||
with TickerProviderStateMixin {
|
||||
// Built in initState, not as lazy `late final` initialisers: a control that
|
||||
// is never dragged would otherwise create its controller inside dispose(),
|
||||
// where the element is already deactivated and the TickerMode lookup throws.
|
||||
late final AnimationController _settle;
|
||||
|
||||
/// Drives the resting hint — the sheen and the chevrons.
|
||||
///
|
||||
/// Runs a few cycles and stops, rather than shimmering for the whole
|
||||
/// session: a control that never stops animating never lets the widget tree
|
||||
/// settle, which hangs `pumpAndSettle` in any test that reaches a screen
|
||||
/// holding one, and costs a frame every frame for an affordance that has
|
||||
/// long since been read. It re-arms whenever the control becomes enabled or
|
||||
/// a swipe falls short — the two moments a worker actually needs the hint.
|
||||
late final AnimationController _hint;
|
||||
|
||||
static const _hintCycles = 3;
|
||||
|
||||
/// Where the thumb sits, in pixels from the left of its travel.
|
||||
double _offset = 0;
|
||||
|
||||
/// Travel is only known at paint time, and the drag handlers need it.
|
||||
double _travel = 0;
|
||||
|
||||
bool _dragging = false;
|
||||
bool _done = false;
|
||||
|
||||
bool get _hinting => widget.enabled && !_dragging && !_done;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_settle = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 240),
|
||||
);
|
||||
_hint = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2200),
|
||||
);
|
||||
if (_hinting) _hint.repeat(count: _hintCycles);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SwipeAction old) {
|
||||
super.didUpdateWidget(old);
|
||||
_syncHint();
|
||||
}
|
||||
|
||||
void _syncHint() {
|
||||
if (_hinting && !_hint.isAnimating) {
|
||||
_hint.repeat(count: _hintCycles);
|
||||
} else if (!_hinting && _hint.isAnimating) {
|
||||
_hint.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_settle.dispose();
|
||||
_hint.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _snapTo(double target) {
|
||||
final from = _offset;
|
||||
_settle.stop();
|
||||
final animation = Tween(
|
||||
begin: from,
|
||||
end: target,
|
||||
).animate(CurvedAnimation(parent: _settle, curve: Curves.easeOutCubic));
|
||||
void tick() => setState(() => _offset = animation.value);
|
||||
animation.addListener(tick);
|
||||
_settle.forward(from: 0).whenComplete(() => animation.removeListener(tick));
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails details) {
|
||||
if (!widget.enabled || _done) return;
|
||||
setState(() {
|
||||
_dragging = true;
|
||||
_offset = (_offset + details.delta.dx).clamp(0.0, _travel);
|
||||
});
|
||||
_syncHint();
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails _) {
|
||||
if (!widget.enabled || _done) return;
|
||||
setState(() => _dragging = false);
|
||||
|
||||
if (_travel > 0 && _offset >= _travel * SwipeAction._commitFraction) {
|
||||
_confirm();
|
||||
} else {
|
||||
_snapTo(0);
|
||||
_syncHint();
|
||||
}
|
||||
}
|
||||
|
||||
void _confirm() {
|
||||
// Held at the end rather than snapped back: the control has to look
|
||||
// committed while whatever it triggered takes over the screen.
|
||||
setState(() => _done = true);
|
||||
_syncHint();
|
||||
_snapTo(_travel);
|
||||
HapticFeedback.mediumImpact();
|
||||
widget.onConfirm();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = widget.enabled;
|
||||
|
||||
// The same disabled pair FilledButton uses, so a disabled swipe and a
|
||||
// disabled button are the same colour.
|
||||
final channel = enabled ? widget.channel : KrowPalette.disabled;
|
||||
final fill = enabled ? widget.background : KrowPalette.disabled;
|
||||
final onTrack = enabled ? Colors.white : const Color(0xFF949FA5);
|
||||
|
||||
const radius = SwipeAction.height / 2;
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled: enabled,
|
||||
label: widget.label,
|
||||
// Assistive tech cannot drag, so the gesture has a tap-equivalent.
|
||||
onTap: enabled && !_done ? _confirm : null,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const inset = SwipeAction._inset;
|
||||
final thumb = SwipeAction.height - inset * 2;
|
||||
_travel = constraints.maxWidth - thumb - inset * 2;
|
||||
|
||||
final progress = _travel <= 0
|
||||
? 0.0
|
||||
: (_offset / _travel).clamp(0.0, 1.0);
|
||||
|
||||
return SizedBox(
|
||||
height: SwipeAction.height,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: ColoredBox(color: channel)),
|
||||
|
||||
// The fill chases the thumb: the action is as done as the
|
||||
// bar is full.
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: inset * 2 + thumb + _offset,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: fill,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// A light band crossing the track, so the control reads as
|
||||
// something that moves before anyone touches it.
|
||||
if (_hinting)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: AnimatedBuilder(
|
||||
animation: _hint,
|
||||
builder: (context, _) => _Sheen(t: _hint.value),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Center(
|
||||
child: Opacity(
|
||||
opacity: 1 - progress,
|
||||
child: Text(
|
||||
_done
|
||||
? (widget.confirmedLabel ?? widget.label)
|
||||
: widget.label,
|
||||
style: Theme.of(context).textTheme.labelLarge
|
||||
?.copyWith(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: onTrack,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Chevrons mark the end the thumb is headed for, and fade
|
||||
// out as it arrives.
|
||||
Positioned(
|
||||
right: 18,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: IgnorePointer(
|
||||
child: Opacity(
|
||||
opacity: enabled ? (1 - progress) * 0.9 : 0.45,
|
||||
child: AnimatedBuilder(
|
||||
animation: _hint,
|
||||
builder: (context, _) => _Chevrons(
|
||||
t: _hint.isAnimating ? _hint.value : null,
|
||||
color: onTrack,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
left: inset + _offset,
|
||||
top: inset,
|
||||
child: GestureDetector(
|
||||
onHorizontalDragUpdate: _onDragUpdate,
|
||||
onHorizontalDragEnd: _onDragEnd,
|
||||
// The thumb has to take the drag wherever it lands, even
|
||||
// once it has travelled past its own starting box.
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AnimatedScale(
|
||||
scale: _dragging ? 1.06 : 1,
|
||||
duration: const Duration(milliseconds: 140),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: Container(
|
||||
width: thumb,
|
||||
height: thumb,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: Icon(
|
||||
_done ? LucideIcons.check : widget.icon,
|
||||
key: ValueKey(_done),
|
||||
size: 19,
|
||||
color: enabled
|
||||
? widget.background
|
||||
: const Color(0xFF949FA5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The travelling highlight. A gradient band rather than a blurred shape, so
|
||||
/// it costs nothing per frame.
|
||||
class _Sheen extends StatelessWidget {
|
||||
const _Sheen({required this.t});
|
||||
|
||||
/// 0..1 through the hint cycle.
|
||||
final double t;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Most of the cycle is spent off-screen to the right, so the sweep reads
|
||||
// as an occasional glint rather than a constant shimmer.
|
||||
final x = -1.6 + t * 4.2;
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment(x - 0.45, 0),
|
||||
end: Alignment(x + 0.45, 0),
|
||||
colors: const [
|
||||
Color(0x00FFFFFF),
|
||||
Color(0x26FFFFFF),
|
||||
Color(0x00FFFFFF),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Three chevrons that brighten in sequence, pointing at the end of the run.
|
||||
class _Chevrons extends StatelessWidget {
|
||||
const _Chevrons({required this.t, required this.color});
|
||||
|
||||
/// 0..1 through the hint cycle, or null once the hint has run its course —
|
||||
/// at rest they sit at a flat, quiet brightness.
|
||||
final double? t;
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (i) {
|
||||
// Each chevron leads the one before it by a third of a cycle.
|
||||
final cycle = t;
|
||||
final double lit;
|
||||
if (cycle == null) {
|
||||
lit = 0.0;
|
||||
} else {
|
||||
final phase = (cycle * 3 - i) % 3;
|
||||
lit = phase < 1 ? 1 - phase : 0.0;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: i == 0 ? 0 : 1),
|
||||
child: Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 16,
|
||||
color: color.withValues(alpha: 0.3 + 0.55 * lit),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user