Files
doormile_milderapp/lib/views/helpers/widgets/slide_action.dart
2026-09-09 12:55:23 +05:30

405 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> 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<MilerSlideAction> createState() => _MilerSlideActionState();
}
class _MilerSlideActionState extends State<MilerSlideAction>
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<void> _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),
],
),
),
);
}
}