import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../core/theme.dart'; /// A slide-to-confirm control. /// /// Used where a tap is too cheap for what it does. Clocking in stamps the /// start of a paid shift and is the moment a worker is least able to undo a /// mistake — a thumb brushing the screen while the phone comes out of a /// pocket should not start it. A deliberate drag across the whole control /// cannot happen by accident, and unlike a confirmation dialog it costs the /// worker nothing when they meant it. /// /// Shaped as a pill rather than following the 14px button radius on purpose: /// it is the one control in the app you drag instead of tap, and looking /// slightly unlike a button is what tells you that before you touch it. The /// track is a deep channel that the brand blue *fills* as you cross it, so /// the action visibly completes rather than merely moving a tile from one end /// to the other. class SwipeAction extends StatefulWidget { const SwipeAction({ super.key, required this.icon, required this.label, required this.onConfirm, this.confirmedLabel, this.enabled = true, this.background = KrowPalette.blue, this.channel = KrowPalette.blueDeep, }); final IconData icon; /// What the control says before it is dragged. When [enabled] is false this /// is the place to say why — "Clock in · in 19 min". final String label; /// Shown once the swipe commits. Falls back to [label]. final String? confirmedLabel; final VoidCallback onConfirm; final bool enabled; /// The fill that chases the thumb. final Color background; /// The channel the fill runs through. final Color channel; /// Matches FilledButton's minimum height, so a swipe and a tap button can /// sit in the same column without a step between them. static const double height = 56; static const double _inset = 5; /// How far across the worker has to get. Short of the full width, because /// the last few pixels are hard to reach without shifting grip, and nobody /// drags to 100% of anything. static const double _commitFraction = 0.82; @override State createState() => _SwipeActionState(); } class _SwipeActionState extends State with TickerProviderStateMixin { // Built in initState, not as lazy `late final` initialisers: a control that // is never dragged would otherwise create its controller inside dispose(), // where the element is already deactivated and the TickerMode lookup throws. late final AnimationController _settle; /// Drives the resting hint — the sheen and the chevrons. /// /// Runs a few cycles and stops, rather than shimmering for the whole /// session: a control that never stops animating never lets the widget tree /// settle, which hangs `pumpAndSettle` in any test that reaches a screen /// holding one, and costs a frame every frame for an affordance that has /// long since been read. It re-arms whenever the control becomes enabled or /// a swipe falls short — the two moments a worker actually needs the hint. late final AnimationController _hint; static const _hintCycles = 3; /// Where the thumb sits, in pixels from the left of its travel. double _offset = 0; /// Travel is only known at paint time, and the drag handlers need it. double _travel = 0; bool _dragging = false; bool _done = false; bool get _hinting => widget.enabled && !_dragging && !_done; @override void initState() { super.initState(); _settle = AnimationController( vsync: this, duration: const Duration(milliseconds: 240), ); _hint = AnimationController( vsync: this, duration: const Duration(milliseconds: 2200), ); if (_hinting) _hint.repeat(count: _hintCycles); } @override void didUpdateWidget(SwipeAction old) { super.didUpdateWidget(old); _syncHint(); } void _syncHint() { if (_hinting && !_hint.isAnimating) { _hint.repeat(count: _hintCycles); } else if (!_hinting && _hint.isAnimating) { _hint.stop(); } } @override void dispose() { _settle.dispose(); _hint.dispose(); super.dispose(); } void _snapTo(double target) { final from = _offset; _settle.stop(); final animation = Tween( begin: from, end: target, ).animate(CurvedAnimation(parent: _settle, curve: Curves.easeOutCubic)); void tick() => setState(() => _offset = animation.value); animation.addListener(tick); _settle.forward(from: 0).whenComplete(() => animation.removeListener(tick)); } void _onDragUpdate(DragUpdateDetails details) { if (!widget.enabled || _done) return; setState(() { _dragging = true; _offset = (_offset + details.delta.dx).clamp(0.0, _travel); }); _syncHint(); } void _onDragEnd(DragEndDetails _) { if (!widget.enabled || _done) return; setState(() => _dragging = false); if (_travel > 0 && _offset >= _travel * SwipeAction._commitFraction) { _confirm(); } else { _snapTo(0); _syncHint(); } } void _confirm() { // Held at the end rather than snapped back: the control has to look // committed while whatever it triggered takes over the screen. setState(() => _done = true); _syncHint(); _snapTo(_travel); HapticFeedback.mediumImpact(); widget.onConfirm(); } @override Widget build(BuildContext context) { final enabled = widget.enabled; // The same disabled pair FilledButton uses, so a disabled swipe and a // disabled button are the same colour. final channel = enabled ? widget.channel : KrowPalette.disabled; final fill = enabled ? widget.background : KrowPalette.disabled; final onTrack = enabled ? Colors.white : const Color(0xFF949FA5); const radius = SwipeAction.height / 2; return Semantics( button: true, enabled: enabled, label: widget.label, // Assistive tech cannot drag, so the gesture has a tap-equivalent. onTap: enabled && !_done ? _confirm : null, child: LayoutBuilder( builder: (context, constraints) { const inset = SwipeAction._inset; final thumb = SwipeAction.height - inset * 2; _travel = constraints.maxWidth - thumb - inset * 2; final progress = _travel <= 0 ? 0.0 : (_offset / _travel).clamp(0.0, 1.0); return SizedBox( height: SwipeAction.height, child: ClipRRect( borderRadius: BorderRadius.circular(radius), child: Stack( children: [ Positioned.fill(child: ColoredBox(color: channel)), // The fill chases the thumb: the action is as done as the // bar is full. Positioned( left: 0, top: 0, bottom: 0, width: inset * 2 + thumb + _offset, child: DecoratedBox( decoration: BoxDecoration( color: fill, borderRadius: BorderRadius.circular(radius), ), ), ), // A light band crossing the track, so the control reads as // something that moves before anyone touches it. if (_hinting) Positioned.fill( child: IgnorePointer( child: AnimatedBuilder( animation: _hint, builder: (context, _) => _Sheen(t: _hint.value), ), ), ), Positioned.fill( child: IgnorePointer( child: Center( child: Opacity( opacity: 1 - progress, child: Text( _done ? (widget.confirmedLabel ?? widget.label) : widget.label, style: Theme.of(context).textTheme.labelLarge ?.copyWith( fontSize: 15, fontWeight: FontWeight.w600, color: onTrack, letterSpacing: 0.1, ), ), ), ), ), ), // Chevrons mark the end the thumb is headed for, and fade // out as it arrives. Positioned( right: 18, top: 0, bottom: 0, child: IgnorePointer( child: Opacity( opacity: enabled ? (1 - progress) * 0.9 : 0.45, child: AnimatedBuilder( animation: _hint, builder: (context, _) => _Chevrons( t: _hint.isAnimating ? _hint.value : null, color: onTrack, ), ), ), ), ), Positioned( left: inset + _offset, top: inset, child: GestureDetector( onHorizontalDragUpdate: _onDragUpdate, onHorizontalDragEnd: _onDragEnd, // The thumb has to take the drag wherever it lands, even // once it has travelled past its own starting box. behavior: HitTestBehavior.opaque, child: AnimatedScale( scale: _dragging ? 1.06 : 1, duration: const Duration(milliseconds: 140), curve: Curves.easeOutCubic, child: Container( width: thumb, height: thumb, decoration: const BoxDecoration( color: Colors.white, shape: BoxShape.circle, ), child: AnimatedSwitcher( duration: const Duration(milliseconds: 180), child: Icon( _done ? LucideIcons.check : widget.icon, key: ValueKey(_done), size: 19, color: enabled ? widget.background : const Color(0xFF949FA5), ), ), ), ), ), ), ], ), ), ); }, ), ); } } /// The travelling highlight. A gradient band rather than a blurred shape, so /// it costs nothing per frame. class _Sheen extends StatelessWidget { const _Sheen({required this.t}); /// 0..1 through the hint cycle. final double t; @override Widget build(BuildContext context) { // Most of the cycle is spent off-screen to the right, so the sweep reads // as an occasional glint rather than a constant shimmer. final x = -1.6 + t * 4.2; return DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment(x - 0.45, 0), end: Alignment(x + 0.45, 0), colors: const [ Color(0x00FFFFFF), Color(0x26FFFFFF), Color(0x00FFFFFF), ], ), ), ); } } /// Three chevrons that brighten in sequence, pointing at the end of the run. class _Chevrons extends StatelessWidget { const _Chevrons({required this.t, required this.color}); /// 0..1 through the hint cycle, or null once the hint has run its course — /// at rest they sit at a flat, quiet brightness. final double? t; final Color color; @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: List.generate(3, (i) { // Each chevron leads the one before it by a third of a cycle. final cycle = t; final double lit; if (cycle == null) { lit = 0.0; } else { final phase = (cycle * 3 - i) % 3; lit = phase < 1 ? 1 - phase : 0.0; } return Padding( padding: EdgeInsets.only(left: i == 0 ? 0 : 1), child: Icon( LucideIcons.chevronRight, size: 16, color: color.withValues(alpha: 0.3 + 0.55 * lit), ), ); }), ); } }