392 lines
13 KiB
Dart
392 lines
13 KiB
Dart
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),
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
}
|