279 lines
13 KiB
Dart
279 lines
13 KiB
Dart
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),
|
||
pageBuilder: (context, _, _) => 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,
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|