Files
doormile_milderapp/lib/views/helpers/widgets/page_transitions.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

294 lines
13 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/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// ───────────────────────────────────────────────────────────────────────────
/// PAGE MOTION — one vocabulary for every screen you can navigate into
///
/// Before this file the app had three different answers to "what happens when
/// I tap a row": `Get.to` with GetX's default transition, a bare
/// `MaterialPageRoute` (which on Android is the fade-through zoom, and reads as
/// a hard cut on mid-range hardware), and a `pushReplacement` with no
/// transition intent at all. Tapping *Edit Profile* looked nothing like tapping
/// *Open map*, and neither looked like anything.
///
/// Two motions, and only two, from here on:
///
/// • [MilerSlideRoute] — right → left. For the *work*: a stop's map, the
/// multi-stop route, the next step of onboarding. These are places the
/// rider goes on his way somewhere else, so they behave like pages — the
/// one below parallaxes, and a left-edge swipe goes back.
///
/// • [MilerSheetRoute] — bottom → top, and back down the same way. For
/// anything laid *over* what the rider was doing: every screen under
/// Account, plus stop verification and payment collection. It rises, it
/// covers, it drops away.
///
/// ── Why Account is all sheets ──
///
/// These screens are leaves. You open Notifications, Saved Address, FAQ or
/// Terms to do one thing, and then you are done with it — there is nowhere
/// onward to go, which is why they all close with an × rather than a back
/// arrow. A cross that dismissed a page sideways contradicted itself: the icon
/// promised a layer dropping away and the motion showed a page being left
/// behind. Now the icon and the direction say the same thing, which is what
/// Uber's account screens do.
///
/// Both routes share the same expo-out entrance — fast off the mark, long soft
/// landing, motion that arrives rather than stops. They differ on the way out,
/// and deliberately: see [kMilerExitCurve] and [kMilerSheetExitCurve].
/// ───────────────────────────────────────────────────────────────────────────
/// Fast start, long settle. The thing that makes a push feel "smooth" rather
/// than "fast" — most of the distance is covered early, so the eye reads it as
/// responsive, and the tail is slow enough that it never appears to snap.
const Curve kMilerEnterCurve = Cubic(0.16, 1.0, 0.30, 1.0);
/// Leaving a *screen* — a map, a stop, something you were working inside.
/// The rider's decision is already made, so this gets out of the way.
const Curve kMilerExitCurve = Cubic(0.4, 0.0, 0.7, 0.2);
/// Leaving a *sheet*, which is a different gesture entirely.
///
/// A task screen dropping back down is the app putting something away, and the
/// eye follows it the whole way — so it is eased at both ends rather than
/// snatched off the screen. [kMilerExitCurve] was written for a page sliding
/// sideways out of view, where nobody is watching the tail; used on a
/// full-height sheet it read as the page being deleted rather than closed.
const Curve kMilerSheetExitCurve = Cubic(0.33, 0.0, 0.15, 1.0);
// ── The 300ms ceiling ──
//
// 360/400/380 were within a hair of each other and all above the ceiling the
// motion scale sets for anything on the rider's critical path. He crosses these
// transitions 40 times a shift; 60–100ms each way is time spent watching the
// app instead of working. The curves are unchanged — they are what make the
// motion read as considered — only the duration comes down.
const Duration kMilerPushIn = DesignConstants.motionPage;
const Duration kMilerPushOut = Duration(milliseconds: 240);
const Duration kMilerSheetIn = DesignConstants.motionPage;
/// Deliberately close to the entrance, and much slower than the 290ms it was.
///
/// Dismissal had been treated as dead time to be minimised — the standard
/// instinct, and wrong here. Tapping × sent a full-height page down the screen
/// in under a third of a second, which does not read as "closing", it reads as
/// the screen vanishing. Going and coming should take about the same time,
/// because they are the same distance.
const Duration kMilerSheetOut = Duration(milliseconds: 240);
/// Right → left drill-down, with the iOS parallax on the page underneath and
/// an interactive left-edge back-swipe.
///
/// Cupertino's own 500ms is a beat too slow for a screen a rider opens forty
/// times a shift, so the duration is pulled in; the curve stays Cupertino's
/// because it is what the back-gesture physics are written against.
class MilerSlideRoute<T> extends CupertinoPageRoute<T> {
/// Off for screens whose own content wants horizontal drags — a full-bleed
/// Google Map, most of all. There, an edge swipe meant to nudge the map would
/// throw the rider out of live navigation, so the back arrow is the only way
/// out and that is the correct trade.
final bool swipeToGoBack;
MilerSlideRoute({
required super.builder,
this.swipeToGoBack = true,
super.settings,
super.maintainState,
});
@override
Duration get transitionDuration => kMilerPushIn;
@override
Duration get reverseTransitionDuration => kMilerPushOut;
@override
bool get popGestureEnabled => swipeToGoBack && super.popGestureEnabled;
}
/// Bottom → top task screen, Uber-style.
///
/// The incoming page rises the full height of the screen on the expo-out curve
/// while the page it covers eases back a few percent — the small counter-motion
/// is what stops a full-screen slab from reading as a jump-cut. When a further
/// screen is pushed on top of *this* one, it slides up and out of the way by
/// the same small amount, so a two-deep task flow still parallaxes.
class MilerSheetRoute<T> extends PageRouteBuilder<T> {
MilerSheetRoute({
required WidgetBuilder builder,
super.settings,
super.maintainState,
}) : super(
pageBuilder: (context, animation, secondaryAnimation) =>
builder(context),
transitionDuration: kMilerSheetIn,
reverseTransitionDuration: kMilerSheetOut,
// A task screen is a modal presentation: Flutter uses this to keep
// the page below from running its own outgoing slide, which would
// fight the upward motion.
fullscreenDialog: true,
transitionsBuilder: _buildSheetTransition,
);
static Widget _buildSheetTransition(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final Animation<Offset> enter =
Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero).animate(
CurvedAnimation(
parent: animation,
curve: kMilerEnterCurve,
reverseCurve: kMilerSheetExitCurve,
),
);
// Pushed over by yet another screen: retreat slightly upward instead of
// sitting frozen under the new page.
final Animation<Offset> recede =
Tween<Offset>(begin: Offset.zero, end: const Offset(0, -0.06)).animate(
CurvedAnimation(
parent: secondaryAnimation,
curve: kMilerEnterCurve,
reverseCurve: kMilerSheetExitCurve,
),
);
return SlideTransition(
position: recede,
child: SlideTransition(position: enter, child: child),
);
}
}
/// ── Call sites ──────────────────────────────────────────────────────────────
///
/// `openScreen(context, const FaqPage())` reads at the tap site as "go deeper",
/// `openSheet(context, const Profile())` as "do a thing and come back". Both
/// return the popped result, so they drop straight into the places that were
/// awaiting a `Navigator.push`.
/// Right → left. The default for anything you navigate *into*.
///
/// Pass `swipeToGoBack: false` when the destination is a map or anything else
/// that handles its own horizontal drags.
Future<T?> openScreen<T>(
BuildContext context,
Widget page, {
bool swipeToGoBack = true,
}) {
return Navigator.of(context).push<T>(
MilerSlideRoute<T>(builder: (_) => page, swipeToGoBack: swipeToGoBack),
);
}
/// Bottom → top. For task screens you complete or abandon.
Future<T?> openSheet<T>(BuildContext context, Widget page) {
return Navigator.of(
context,
).push<T>(MilerSheetRoute<T>(builder: (_) => page));
}
/// Bottom → top, replacing the current screen — for the end of a flow, where
/// going "back" to the screen you just finished would be wrong.
Future<T?> replaceWithSheet<T, TO>(BuildContext context, Widget page) {
return Navigator.of(
context,
).pushReplacement<T, TO>(MilerSheetRoute<T>(builder: (_) => page));
}
/// ───────────────────────────────────────────────────────────────────────────
/// THE SMALLER SURFACES — dialogs and bottom sheets
///
/// A page is not the only thing that arrives. The app asks for confirmation in
/// dialogs and offers choices in sheets far more often than it pushes a screen,
/// and both were using framework defaults that predate everything above.
/// ───────────────────────────────────────────────────────────────────────────
/// Bottom sheets: Flutter's default is 250ms in / 200ms out on
/// `decelerate` — quick enough to register as a pop rather than a rise, which
/// is especially wrong for the sheets a rider reads (skip reasons, stop
/// details, payment) before deciding something. Same curve as the pages, so a
/// sheet and a pushed screen feel like the same hand moved them.
///
/// Pass as `sheetAnimationStyle:` to `showModalBottomSheet`.
const AnimationStyle kMilerSheetStyle = AnimationStyle(
duration: Duration(milliseconds: 340),
curve: kMilerEnterCurve,
reverseDuration: Duration(milliseconds: 300),
reverseCurve: kMilerSheetExitCurve,
);
/// For the tall ones — a sheet that covers three-quarters of the screen is
/// closer to a page than to a picker, and travelling that far in 340ms reads as
/// a slam. Distance wants time: the same curve, given room to decelerate.
const AnimationStyle kMilerLargeSheetStyle = AnimationStyle(
duration: Duration(milliseconds: 430),
curve: kMilerEnterCurve,
reverseDuration: Duration(milliseconds: 360),
reverseCurve: kMilerSheetExitCurve,
);
/// Confirmation dialogs that grow into place instead of blinking on.
///
/// Material's `showDialog` is a 150ms straight fade with no movement at all —
/// the dialog is simply there, at full size, before the eye has found it. This
/// keeps the fade but adds the small scale-up that tells you *where* it came
/// from, and gives the barrier the same clock so the screen dims with it rather
/// than a beat ahead.
///
/// Drop-in for `showDialog`: same `context`/`builder`/`barrierDismissible`.
Future<T?> showAppDialog<T>({
required BuildContext context,
required WidgetBuilder builder,
bool barrierDismissible = true,
Color barrierColor = const Color(0x8A000000),
bool useRootNavigator = true,
}) {
return showGeneralDialog<T>(
context: context,
barrierDismissible: barrierDismissible,
barrierLabel: barrierDismissible
? MaterialLocalizations.of(context).modalBarrierDismissLabel
: null,
barrierColor: barrierColor,
useRootNavigator: useRootNavigator,
transitionDuration: const Duration(milliseconds: 260),
// ── Every dialog gets a Material, whether its caller remembered one ──
//
// `showGeneralDialog` hands the builder a bare route with no Material in
// scope, unlike `showDialog`, which wraps its child in one. A caller that
// returns a `Container` with a `Text` in it — which several do — therefore
// painted Flutter's **missing-Material debug marker**: the yellow double
// underline under every word. It looked like a styling bug in the dialog
// and was actually the framework saying "there is no Material here".
//
// `MaterialType.transparency` adds the ancestor and paints nothing, so a
// dialog that draws its own surface is unchanged and one that forgot stops
// being underlined.
pageBuilder: (context, _, _) => Material(
type: MaterialType.transparency,
child: Builder(builder: builder),
),
transitionBuilder: (context, animation, _, child) {
// `drive` rather than a `CurvedAnimation`: this builder runs every frame
// of the transition, and a CurvedAnimation built here would be a new
// listener-holding object each time. A CurveTween is a pure function and
// reads the same in reverse, so dismissal is the arrival played backwards.
final Animation<double> curved = animation.drive(
CurveTween(curve: Curves.easeOutCubic),
);
return FadeTransition(
opacity: curved,
child: ScaleTransition(
scale: curved.drive(Tween<double>(begin: 0.94, end: 1.0)),
child: child,
),
);
},
);
}