import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; /// ───────────────────────────────────────────────────────────────────────── /// SLIDE TO COMMIT /// /// The delivery leg's one control, at both of its moments: setting off, and /// handing over. Both are writes the rider cannot take back — one puts the load /// on the road under his name, the other closes the order — and this screen is /// held while walking, so a tap target at the foot of it is how either gets /// recorded by a knuckle. /// /// ``` /// ┌──────────────────────────────────────────────┐ /// │ (▸) Slide to start ride │ solid accent /// └──────────────────────────────────────────────┘ /// ``` /// /// ── A drag-only control is a control some riders cannot use ── /// /// A bare `GestureDetector` announces nothing to TalkBack, and a glove on a /// cold morning will not register a drag. So the same act is reachable by /// double tap through [Semantics] — the deliberateness is what the gesture is /// for, and a screen reader's double tap is every bit as deliberate as a slide. /// ───────────────────────────────────────────────────────────────────────── class MilerSlideAction extends StatefulWidget { final String label; final IconData icon; final Color color; final Future Function() onCommit; const MilerSlideAction({ super.key, required this.label, required this.icon, required this.color, required this.onCommit, }); /// The control's height. /// /// ── 56 → 72 ── /// /// It was [ButtonSizes.primary], the app's ordinary button height, and this /// is not an ordinary button. It is the only control on a screen held against /// a handlebar, operated with a thumb that is often gloved and always moving /// between glances at traffic — and it is a *drag*, which needs a target deep /// enough that a thumb travelling horizontally does not slide off the top of /// it. 72 gives a 64pt thumb, which is a third larger than the 48pt /// accessibility floor for a tap and about right for a drag. /// /// It costs 16pt of a sheet whose other content just got shorter — the ETA /// lost a line and the rail lost its captions — so the sheet is no taller /// than it was. static double get height => 72.h; @override State createState() => _MilerSlideActionState(); } class _MilerSlideActionState extends State with TickerProviderStateMixin { /// How far along the track the thumb has been dragged, 0..1. double _progress = 0; /// True while the thumb is under a finger. The spring back is animated; the /// drag itself must not be, or the thumb lags the thumb-print. bool _dragging = false; /// True once [onCommit] has fired, until it returns. The track holds at the /// far end rather than springing back, so the rider can see that the gesture /// took before anything else happens. bool _committed = false; /// ── The hint ── /// /// A drag-only control has one problem a button does not: nothing about a /// still image says it slides. This is the answer every app that ships one /// uses — a shimmer travelling left to right along the track, and three /// chevrons brightening in turn ahead of the thumb. It runs only while the /// control is idle: once a finger is on it the rider has worked out what it /// is, and an animation under his own thumb is noise. late final AnimationController _hint = AnimationController( vsync: this, duration: const Duration(milliseconds: 1800), ); /// How many laps the hint runs before it stops. /// /// ── Why it is not `repeat()` ── /// /// It was, and an endless one is wrong twice. It is a permanent repaint on a /// sheet the rider is reading — the whole track re-shaded every frame, for as /// long as it is on screen — and its job is done long before that: three /// passes is enough for anyone to see that the thing moves. After that it is /// motion in the corner of the eye of someone about to make a decision. /// /// It also makes the control **settle**. An animation that never ends is an /// animation `pumpAndSettle` waits for forever, so every widget test that /// opened a sheet carrying one hung — which is the framework saying out loud /// that nothing on screen is ever at rest. static const int _hintLaps = 3; int _lapsRun = 0; void _runHint() { _lapsRun = 0; _hint ..reset() ..forward(); } /// The fraction of the track that counts as slid. Short of the end on /// purpose: a track that has to be driven into its own wall reads as stuck. static const double _commitAt = 0.72; double get _thumb => MilerSlideAction.height - 8.h; @override void initState() { super.initState(); _hint.addStatusListener((status) { if (status != AnimationStatus.completed) return; if (++_lapsRun >= _hintLaps || !mounted) return; _hint ..reset() ..forward(); }); _runHint(); } Future _commit() async { HapticFeedback.mediumImpact(); setState(() { _progress = 1; _committed = true; }); _hint.stop(); await widget.onCommit(); if (mounted) { setState(() { _progress = 0; _committed = false; }); // The control is back at the start, so the hint has something to say // again — a rider who slid once and came back to a second stop meets the // same affordance he did the first time. _runHint(); } } void _settle() { setState(() => _dragging = false); if (_progress >= _commitAt) { _commit(); } else { setState(() => _progress = 0); if (!_hint.isAnimating) _runHint(); } } @override void dispose() { _hint.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Semantics( button: true, label: widget.label.replaceFirst('Slide to ', ''), hint: 'Slide, or double tap, to confirm', excludeSemantics: true, onTap: _commit, child: LayoutBuilder( builder: (context, box) { final travel = (box.maxWidth - _thumb - 8.w).clamp( 1.0, double.infinity, ); return GestureDetector( onHorizontalDragStart: (_) { HapticFeedback.selectionClick(); _hint.stop(); }, onHorizontalDragUpdate: (d) => setState(() { _dragging = true; _progress = (_progress + d.delta.dx / travel).clamp(0.0, 1.0); }), onHorizontalDragEnd: (_) => _settle(), onHorizontalDragCancel: _settle, child: AnimatedBuilder( animation: _hint, builder: (context, _) => Container( height: MilerSlideAction.height, padding: EdgeInsets.all(4.w), decoration: BoxDecoration( // ── The track is the action, not a slot the action sits in ── // // It was the accent at 14% — a pale wash carrying a bright // thumb. That reads as a *disabled* button on the one screen // where the control has to be found without being looked for: // this sheet is glanced at over a handlebar, and the shape the // rider is scanning for is the same solid slab of colour that // every other primary action in the app is. // // So the track carries the accent solid and the thumb inverts // to white — the shape and the weight of a primary button, // with a disc on it that says the button travels. color: widget.color, // Fully round, and the thumb with it: a rounded-rectangle // thumb inside a rounded-rectangle track reads as a button // that has been pushed to one side. A disc in a capsule reads // as something that travels, which is the one thing this // control has to say before it is touched. borderRadius: BorderRadius.circular( DesignConstants.radiusFull, ), // A hairline of the accent's own light along the rim. It is // what makes the track read as a *groove* the thumb sits in // rather than as a flat slab with a disc drawn on it — the // one cue that says the two parts are different objects. border: Border.all( color: Colors.white.withValues(alpha: 0.16), ), boxShadow: [ BoxShadow( color: widget.color.withValues(alpha: 0.30), blurRadius: 20, offset: const Offset(0, 8), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular( DesignConstants.radiusFull, ), child: Stack( alignment: Alignment.center, children: [ // ── The shimmer ── // // A band of lighter colour sweeping the length of the // track. It is the only thing on the sheet that moves // horizontally, which is precisely the gesture being // asked for. if (!_dragging && !_committed) Positioned.fill(child: _Shimmer(t: _hint.value)), // ── The chevrons and the label are one object ── // // They were laid out independently — chevrons pinned // after the thumb, label centred on what was left — and // on a long label the two overlapped: `› Slide to confirm // pickup` with two of the three arrows underneath the // word. // // One row, sized to itself and centred in the room to the // right of the thumb. The arrows lead into the word // instead of colliding with it, the pair stays balanced // whatever the label says, and both fade together as the // thumb arrives — rather than one of them hanging on // while the other has gone. Padding( padding: EdgeInsets.only(left: _thumb, right: 12.w), child: Center( child: Opacity( opacity: (1 - _progress * 2.0).clamp(0.0, 1.0), child: Row( mainAxisSize: MainAxisSize.min, children: [ // Nobody has to be told what an arrow pointing // right means, and three of them brightening in // turn is one arrow travelling. for (var i = 0; i < 3; i++) Icon( LucideIcons.chevronRight, size: 19.sp, color: ColorConstants.onAccent.withValues( alpha: _dragging || _committed ? 0.30 : _chevron(i), ), ), SizedBox(width: 8.w), Flexible( child: Text( widget.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 16.sp, fontWeight: FontWeight.w900, letterSpacing: -0.3, color: ColorConstants.onAccent, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ), ), ), Align( alignment: Alignment.centerLeft, child: AnimatedContainer( // Instant under the finger, eased on the way back — // and a spring on the way home, which is what makes a // committed slide feel like it landed rather than // like it was reset. duration: _dragging ? Duration.zero : DesignConstants.motionState, curve: Curves.easeOutCubic, margin: EdgeInsets.only(left: travel * _progress), width: _thumb, height: _thumb, decoration: BoxDecoration( color: ColorConstants.onAccent, shape: BoxShape.circle, boxShadow: const [ BoxShadow( color: Color(0x2E000000), blurRadius: 10, offset: Offset(0, 3), ), ], ), child: AnimatedSwitcher( duration: DesignConstants.motionPress, child: Icon( // The tick is the receipt. A thumb that arrives // still wearing the "go" glyph looks like it has // not finished. _committed ? LucideIcons.check : widget.icon, key: ValueKey(_committed), color: widget.color, size: 26.sp, ), ), ), ), ], ), ), ), ), ); }, ), ); } /// How lit chevron [i] is on this frame — a wave running left to right, so /// the three read as one arrow travelling rather than three blinking. double _chevron(int i) { final phase = (_hint.value * 3 - i) % 3; final lit = phase < 1 ? 1 - phase : 0.0; return 0.28 + 0.62 * lit; } } /// A band of light sweeping the length of the track. /// /// Painted rather than composed from widgets: it is a gradient whose stops /// move, and a `LinearGradient` rebuilt per frame is one object where a stack /// of animated boxes would be several. class _Shimmer extends StatelessWidget { /// 0 → 1, the sweep's position over one cycle. final double t; const _Shimmer({required this.t}); @override Widget build(BuildContext context) { // Runs from off the left edge to off the right, so the band is never // parked at either end waiting for the cycle to restart. final centre = -0.4 + t * 1.8; return DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, stops: [ (centre - 0.22).clamp(0.0, 1.0), centre.clamp(0.0, 1.0), (centre + 0.22).clamp(0.0, 1.0), ], colors: [ Colors.white.withValues(alpha: 0), Colors.white.withValues(alpha: 0.16), Colors.white.withValues(alpha: 0), ], ), ), ); } }