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 extends CupertinoPageRoute { /// 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 extends PageRouteBuilder { 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 animation, Animation secondaryAnimation, Widget child, ) { final Animation enter = Tween(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 recede = Tween(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 openScreen( BuildContext context, Widget page, { bool swipeToGoBack = true, }) { return Navigator.of(context).push( MilerSlideRoute(builder: (_) => page, swipeToGoBack: swipeToGoBack), ); } /// Bottom → top. For task screens you complete or abandon. Future openSheet(BuildContext context, Widget page) { return Navigator.of( context, ).push(MilerSheetRoute(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 replaceWithSheet(BuildContext context, Widget page) { return Navigator.of( context, ).pushReplacement(MilerSheetRoute(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 showAppDialog({ required BuildContext context, required WidgetBuilder builder, bool barrierDismissible = true, Color barrierColor = const Color(0x8A000000), bool useRootNavigator = true, }) { return showGeneralDialog( 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 curved = animation.drive( CurveTween(curve: Curves.easeOutCubic), ); return FadeTransition( opacity: curved, child: ScaleTransition( scale: curved.drive(Tween(begin: 0.94, end: 1.0)), child: child, ), ); }, ); }