production

This commit is contained in:
2026-08-28 11:13:15 +05:30
parent d7348e253f
commit 5723d373b2
162 changed files with 17924 additions and 7026 deletions

View File

@@ -0,0 +1,404 @@
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.w800,
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),
],
),
),
);
}
}