405 lines
17 KiB
Dart
405 lines
17 KiB
Dart
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),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|