first commit

This commit is contained in:
2026-08-11 13:16:33 +05:30
parent 18531bf46b
commit c8350563d9
388 changed files with 69656 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,386 @@
import 'dart:io';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:miler/controllers/profile_controller.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/miler_type.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// The frosted brand surface every app bar in the app sits on.
///
/// ── Why a filter and not just a tinted box ──
///
/// A flat 7% red over white is a colour; glass is a *relationship* — it takes
/// whatever is underneath and softens it. Content scrolls under these bars, so
/// the blur has something real to work on: a card edge passing beneath the bar
/// blurs out rather than sliding under a solid lid, which is the whole visual
/// difference between "the page continues up there" and "the page stops here".
///
/// Pass as `flexibleSpace` with a transparent `backgroundColor`, so the wash
/// covers the status-bar inset too — a bar tinted only below the notch reads as
/// a stripe rather than a surface.
///
/// ── And no line under it ──
///
/// The bar closed with a hairline for as long as it was white, because a white
/// bar over a near-white page has no edge of its own and would otherwise bleed
/// into the content. The wash *is* an edge — a tinted surface against an
/// untinted one — so the rule underneath became a second boundary drawn on top
/// of the first, which is exactly the doubled-up border this app has been
/// pulling out of its cards. Glass separates by being a different surface, not
/// by being fenced.
Widget milerGlassSurface() {
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: Container(color: ColorConstants.glassRed),
),
);
}
/// ── A bottom sheet as frosted glass ──
///
/// The two sheets a rider spends the most time in — the stop's map sheet and
/// Update Status — were opaque white slabs. On the map screen that is the
/// whole problem: the sheet opens over the one thing the screen exists to
/// show, and takes 45% of it away permanently.
///
/// Frosted instead. The map stays visible as context underneath, softened
/// enough that an address printed on top of it is still the sharpest thing in
/// the frame. It is the same material the app bar already uses
/// ([milerGlassSurface]) — the sheets now belong to the same app as the bars
/// above them, which is the thing that made them look borrowed from somewhere
/// else.
///
/// ── What it costs ──
///
/// A `BackdropFilter` re-samples everything behind it every frame, and this one
/// is full-width over a map that repaints as it pans. Sigma is held at 18 —
/// the same as the app bar, tuned on a mid-range Android — rather than the 30+
/// that reads as heavier frost, because on this phone the difference between
/// them is frames, and a sheet that stutters when dragged is worse-looking than
/// one that is merely less blurry.
Widget milerGlassSheet({required Widget child, double radius = 28}) {
final shape = BorderRadius.vertical(top: Radius.circular(radius));
return DecoratedBox(
// Outside the clip: a shadow drawn inside its own ClipRRect is a shadow
// that has been clipped away.
decoration: BoxDecoration(
borderRadius: shape,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.13),
blurRadius: 26,
offset: const Offset(0, -6),
),
],
),
child: ClipRRect(
borderRadius: shape,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: DecoratedBox(
decoration: BoxDecoration(
color: ColorConstants.glassSheet,
borderRadius: shape,
// The rim, not a border: it is one pixel of light on the curve, and
// it is what separates "frosted glass" from "white box at 88%".
border: Border.all(color: ColorConstants.glassRim, width: 1),
),
child: child,
),
),
),
);
}
/// The Doormile mark, at the one size the whole app agrees on.
///
/// It no longer appears in any Dashboard app bar — see [MilerAppBar] — and now
/// belongs to the screens where the rider has not started working yet: intro,
/// sign-in, MPIN. One size across all of them so the mark does not appear to
/// resize as he moves through the flow.
///
/// ── Why this is not just a `SizedBox` around an `Image` ──
///
/// `doormile_icon.png` is a 1024×1024 app-icon export, and the artwork inside
/// it is inset: the ink spans x/y 170→853, so it fills **66.8%** of the canvas
/// and the outer third is transparent padding. With `BoxFit.contain` that
/// padding is laid out as if it were artwork, so a 34px box drew a mark only
/// ~23px wide. That is the real reason the logo read as small — most of the box
/// it was given was empty.
///
/// So the box grew *and* the dead margin is reclaimed: [_boxSize] sets the
/// footprint, and [_inkScale] scales the image up by the inverse of that fill
/// ratio so the artwork itself fills the box. Ink goes from ~23px to ~41px —
/// roughly 80% bigger — while the space the widget occupies grows by far less.
///
/// The scale is safe rather than clever: the padding is exactly symmetric (170
/// on every side), so scaling about the centre crops only transparent pixels.
/// [_inkScale] is deliberately a little under the theoretical 1.50 so the mark
/// keeps a hair of breathing room instead of touching its own bounds.
///
/// If the asset is ever re-exported tight to the artwork, drop [_inkScale] to
/// 1.0 — do not delete the box size with it.
/// ─────────────────────────────────────────────────────────────────────────
class MilerBrandMark extends StatelessWidget {
const MilerBrandMark({super.key});
/// Footprint. Fixed `.w` so font scaling never resizes the mark.
static const double _boxSize = 44;
/// 1 / 0.668, trimmed. See the class note.
static const double _inkScale = 1.42;
@override
Widget build(BuildContext context) {
return SizedBox(
width: _boxSize.w,
height: _boxSize.w,
// Excluded from semantics: the mark is decoration on a screen the rider
// is already looking at, and the page title beside it is the real label.
child: ExcludeSemantics(
child: Transform.scale(
scale: _inkScale,
child: Image.asset(
'assets/images/doormile_icon.png',
fit: BoxFit.contain,
// Crisp at 3x: the source is 1024px against a ~132px target.
filterQuality: FilterQuality.medium,
),
),
),
);
}
}
/// The rider's own face, at the size the brand mark used to occupy.
///
/// Home is the one screen where the top-left slot belongs to the rider rather
/// than to Doormile: it is his shift, his queue, his earnings underneath. So
/// the slot holds his profile photo, and when he has not set one it holds the
/// first letter of his name on a filled circle — the same fallback every
/// contact list uses, and one that still reads as *him* rather than as a
/// generic person glyph.
///
/// The photo is a local file path (set in Profile → change photo, persisted as
/// `profile_image_path`), so it is watched through [ProfileController] and the
/// bar repaints the moment he picks a new one. If the file has since been
/// deleted from disk — gallery cleanup, an OS cache sweep of a picked image —
/// [File.existsSync] catches it and the initial takes over rather than the
/// circle rendering as a broken box.
class MilerAvatar extends StatelessWidget {
/// Name to take the initial from. Falls back to [ProfileController.userName]
/// when null, so callers that already hold the name do not have to wait on
/// the controller and callers that do not can leave it off.
final String? name;
const MilerAvatar({super.key, this.name});
/// Matches [MilerBrandMark._boxSize] — this widget takes over that slot, and
/// the bar's height is built around it.
static const double _boxSize = 44;
@override
Widget build(BuildContext context) {
final ProfileController? controller = Get.isRegistered<ProfileController>()
? Get.find<ProfileController>()
: null;
if (controller == null) {
return _circle(_initialOf(name ?? ''), null);
}
return Obx(() {
final String path = controller.imagePath.value;
final String source = (name ?? '').trim().isNotEmpty
? name!
: controller.userName.value;
File? photo;
try {
if (path.isNotEmpty && File(path).existsSync()) photo = File(path);
} catch (_) {}
return _circle(_initialOf(source), photo);
});
}
/// First letter of the first word, upper-cased. 'M' for Miler when there is
/// no name yet — a blank circle would read as a loading state that never
/// resolves.
String _initialOf(String source) {
final String trimmed = source.trim();
if (trimmed.isEmpty) return 'M';
return trimmed.characters.first.toUpperCase();
}
Widget _circle(String initial, File? photo) {
return Semantics(
label: 'Your profile',
image: photo != null,
child: Container(
width: _boxSize.w,
height: _boxSize.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: photo != null
? ColorConstants.neutralLight
: ColorConstants.primary,
image: photo != null
? DecorationImage(image: FileImage(photo), fit: BoxFit.cover)
: null,
),
alignment: Alignment.center,
child: photo != null
? null
: Text(
initial,
style: TextStyle(
// Fixed .sp off a fixed box: the circle does not grow with
// the system font scale, so neither can the letter inside it.
fontSize: 19.sp,
fontWeight: FontWeight.w800,
height: 1,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
);
}
}
/// One consistent top bar for every Dashboard page.
///
/// ── No brand mark here ──
///
/// The logo used to lead every bar. It is gone from all of them: the rider
/// opens this app to work, not to be told twice a minute whose app it is, and
/// on a pushed sub-page the mark was pure repetition of what the screen he came
/// from already said. Home keeps a mark-shaped slot but fills it with the
/// rider's own face — see [MilerAvatar]. Everywhere else the title starts at
/// the margin, which is also the widest the title has ever been able to run.
///
/// The title takes the type weight — 23sp, tight tracking, near-black — on the
/// frosted brand wash every bar in the app shares. Bar height is 76 with the
/// content pushed down [_titleTopInset], so the type sits off the status bar
/// instead of against it.
///
/// ── One line, not two ──
///
/// There used to be a caption under every title: "5 stops done today", "Trip 2 ·
/// 4 bookings in queue". It was the smallest type on the screen, in the one
/// place a rider never looks twice — he reads a page title once, on arrival,
/// and after that the bar is furniture. Worse, every one of those captions
/// duplicated something the page itself already said louder and lower down: the
/// trip and its progress are spelled out on the rail, the day's tally sits on
/// the Activity summary card. Two sources for one number is how they drift.
///
/// So the bar carries the page's name and nothing else, and the page carries
/// the facts.
///
/// Pass an optional [trailing] widget (status toggle, map button, notifications
/// icon).
class MilerAppBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
final Widget? trailing;
/// Draws a back arrow before the title.
///
/// Off by default because this bar was built for the four tabs, which have
/// nothing to go back to. It has to be opt-in rather than automatic: the bar
/// sets `automaticallyImplyLeading: false`, so a page that is pushed rather
/// than tabbed to — Earnings, since it moved under Account — would otherwise
/// render with no way out at all.
final bool showBack;
const MilerAppBar({
super.key,
required this.title,
this.trailing,
this.showBack = false,
});
/// Fixed logical bar height. 64 → 76 to absorb [_titleTopInset] without
/// squeezing the title against the status bar.
static const double _barHeight = 76;
/// Pushes the type down off the status bar. The bar was flush against it,
/// which read as clipped rather than as a bar.
static const double _titleTopInset = 12;
@override
Size get preferredSize => Size.fromHeight(_barHeight);
@override
Widget build(BuildContext context) {
return AppBar(
backgroundColor: Colors.transparent,
flexibleSpace: milerGlassSurface(),
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleSpacing: 16.w,
toolbarHeight: _barHeight,
automaticallyImplyLeading: false,
title: Padding(
padding: EdgeInsets.only(top: _titleTopInset.h),
child: Row(
children: [
if (showBack) ...[
// Sized to the tap-target floor and pulled left by its own
// padding, so the arrow's *glyph* still lands on the page margin
// rather than the button's box starting there.
Padding(
padding: EdgeInsets.only(right: 4.w),
child: Semantics(
button: true,
label: 'Back',
child: InkWell(
onTap: () => Navigator.of(context).maybePop(),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
child: SizedBox(
width: 48.w,
height: 48.w,
// One back glyph app-wide — this was one of three.
child: Icon(
Icons.arrow_back_rounded,
size: 22.sp,
color: ColorConstants.slateText,
),
),
),
),
),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// One page-title size app-wide. This was 23 while pushed
// screens were 22 and 21 — a one-point difference nobody can
// name and everybody sees as the heading jumping on tab
// change.
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.pageTitleFixed,
),
],
),
),
if (trailing != null) ...[SizedBox(width: 12.w), trailing!],
],
),
),
);
}
}

View File

@@ -0,0 +1,447 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import 'package:miler/helpers/miler_router.dart';
/// The router travels with the map: every screen that draws one also asks for a
/// route, and neither is any use without the other.
export 'package:miler/helpers/miler_router.dart' show MilerRouter;
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// MAPS, WITHOUT A KEY
///
/// Every map in this app was a `GoogleMap`, and every one of them rendered
/// blank: the key the app shipped with is revoked, so the tile requests, the
/// marker layer and the Directions call all failed at once. A blank grey
/// rectangle where the route should be is indistinguishable from a broken app,
/// and no amount of Dart fixes it — the failure is entirely in credentials.
///
/// So the maps no longer need credentials.
///
/// • **Tiles** — OpenStreetMap raster tiles, served free and keyless, drawn by
/// `flutter_map`. Nothing to provision, nothing to revoke.
/// • **Routes** — OSRM's public router, also free and keyless, in place of the
/// Directions API. It returns the same thing the app used: a driving
/// geometry between two points.
///
/// ── What this costs, and what it buys ──
///
/// Both services are community-run and neither is contracted for production
/// traffic: OSM's tile policy and OSRM's demo server are fine for development
/// and a small fleet, and will rate-limit a large one. Swapping either is one
/// constant ([kMilerTileUrl]) and one function ([MilerRouter.route]) — that is
/// why they are here rather than inlined at four call sites. See the note in
/// README before shipping to a real fleet.
///
/// What it buys immediately: the map is visible again on every screen, and
/// markers stop being textures. A Google Maps marker is a `BitmapDescriptor` —
/// a bitmap the app has to *paint on a canvas* to put a number in a circle
/// (`multi_map.dart` had 100 lines doing exactly that). Here a marker is a
/// widget, so it is drawn with the same colours, fonts and radii as the rest of
/// the app, and it changes when the theme does.
/// ─────────────────────────────────────────────────────────────────────────
/// Standard OSM raster tiles. Keyless.
const String kMilerTileUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
/// Sent with every tile request. OSM's policy requires an identifiable agent —
/// an app that does not send one is the first thing they block.
const String kMilerTileAgent = 'com.doormile.partner';
/// ── The map surface ──
///
/// One widget for all four map screens, so tile source, attribution, gesture
/// rules and the "no rotation" decision are made once. A rider on a bike does
/// not want a map that spins because his thumb dragged crooked, and a rotated
/// map with north somewhere else is unreadable at a glance — which is the only
/// way this map is ever read.
class MilerMap extends StatelessWidget {
/// Optional: a map nobody drives — a static thumbnail in a sheet — needs no
/// controller, and creating one per build would leak a disposable.
final MapController? controller;
final LatLng initialCenter;
final double initialZoom;
final List<Polyline> polylines;
final List<Marker> markers;
final VoidCallback? onReady;
/// Extra layers drawn under the markers — accuracy circles, and the like.
final List<Widget> underlays;
const MilerMap({
super.key,
this.controller,
required this.initialCenter,
this.initialZoom = 14,
this.polylines = const [],
this.markers = const [],
this.underlays = const [],
this.onReady,
});
@override
Widget build(BuildContext context) {
return FlutterMap(
mapController: controller,
options: MapOptions(
initialCenter: initialCenter,
initialZoom: initialZoom,
minZoom: 3,
maxZoom: 18,
backgroundColor: ColorConstants.surfaceContainerLow,
interactionOptions: const InteractionOptions(
// Everything except rotation.
flags:
InteractiveFlag.drag |
InteractiveFlag.flingAnimation |
InteractiveFlag.pinchMove |
InteractiveFlag.pinchZoom |
InteractiveFlag.doubleTapZoom,
),
onMapReady: onReady,
),
children: [
TileLayer(
urlTemplate: kMilerTileUrl,
userAgentPackageName: kMilerTileAgent,
maxNativeZoom: 19,
// Keeps the last good tile on screen while the next zoom loads,
// instead of flashing the background through.
keepBuffer: 4,
),
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
...underlays,
if (markers.isNotEmpty) MarkerLayer(markers: markers),
const _MilerAttribution(),
],
);
}
}
/// OSM's licence requires visible credit. Kept to a hairline chip in the
/// corner: it is a legal obligation, not a piece of the interface, and the
/// rider is here for the route.
class _MilerAttribution extends StatelessWidget {
const _MilerAttribution();
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.bottomLeft,
child: Padding(
padding: const EdgeInsets.only(left: 6, bottom: 4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Text(
'© OpenStreetMap',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
),
);
}
}
// ── Camera ──────────────────────────────────────────────────────────────────
/// Animated camera moves for a [MapController].
///
/// `flutter_map`'s controller only teleports — `move` and `fitCamera` both take
/// effect on the next frame. The route screens were built around a camera that
/// eases: the line waits for the camera to land before it draws, and a cut
/// makes that choreography read as a glitch instead of as a flight. So the
/// tween lives here, over the controller's instant moves.
class MilerMapCamera {
final MapController controller;
final TickerProvider vsync;
AnimationController? _anim;
MilerMapCamera({required this.controller, required this.vsync});
void dispose() {
_anim?.dispose();
_anim = null;
}
/// Eases to the camera that frames [points], and completes when it lands.
Future<void> fit(
List<LatLng> points, {
EdgeInsets padding = const EdgeInsets.all(60),
Duration duration = const Duration(milliseconds: 650),
double maxZoom = 16.5,
}) async {
if (points.isEmpty) return;
final fit = CameraFit.coordinates(
coordinates: points,
padding: padding,
maxZoom: maxZoom,
).fit(controller.camera);
await moveTo(fit.center, fit.zoom, duration: duration);
}
/// Eases to a centre and zoom, and completes when it lands.
Future<void> moveTo(
LatLng target,
double zoom, {
Duration duration = const Duration(milliseconds: 650),
}) async {
_anim?.dispose();
final startCentre = controller.camera.center;
final startZoom = controller.camera.zoom;
if (duration == Duration.zero) {
controller.move(target, zoom);
return;
}
final anim = AnimationController(vsync: vsync, duration: duration);
_anim = anim;
final curve = CurvedAnimation(parent: anim, curve: Curves.easeInOutCubic);
final completer = Completer<void>();
void tick() {
final t = curve.value;
controller.move(
LatLng(
startCentre.latitude + (target.latitude - startCentre.latitude) * t,
startCentre.longitude +
(target.longitude - startCentre.longitude) * t,
),
startZoom + (zoom - startZoom) * t,
);
}
anim.addListener(tick);
anim.forward().whenComplete(() {
if (!completer.isCompleted) completer.complete();
});
return completer.future;
}
}
// ── Pins ────────────────────────────────────────────────────────────────────
/// A map pin, as a widget.
///
/// The stock Google teardrop came in nine fixed hues, none of which is this
/// app's maroon, so a pickup and a delivery were told apart by "red-ish" and
/// "green-ish" and by nothing else. These take [StopKindUi.accent] directly, so
/// the pin on the map is the exact colour of the chip on the card the rider
/// tapped to get here.
class MilerPin extends StatelessWidget {
final Color color;
final IconData icon;
/// Draws the white ring and shadow larger, for the stop being worked.
final bool emphasised;
const MilerPin({
super.key,
required this.color,
required this.icon,
this.emphasised = false,
});
static const double size = 40;
static const double tail = 10;
@override
Widget build(BuildContext context) {
final head = emphasised ? size : size - 6;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: head,
height: head,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.28),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Icon(icon, size: head * 0.46, color: Colors.white),
),
// The stem that makes a circle read as "this exact point" rather than
// "somewhere under here".
CustomPaint(
size: const Size(12, tail),
painter: _PinTailPainter(color),
),
],
);
}
}
/// A numbered stop on a multi-stop route. Same shape as [MilerPin], carrying
/// the stop's position in the run instead of an icon — which is what the rider
/// is matching against the list below.
class MilerNumberPin extends StatelessWidget {
final int number;
final Color color;
final bool done;
const MilerNumberPin({
super.key,
required this.number,
required this.color,
this.done = false,
});
static const double size = 34;
static const double tail = 8;
@override
Widget build(BuildContext context) {
final fill = done ? ColorConstants.acceptGreen : color;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: fill,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2.5),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.25),
blurRadius: 7,
offset: const Offset(0, 3),
),
],
),
child: done
? const Icon(Icons.check_rounded, size: 16, color: Colors.white)
: Text(
'$number',
style: const TextStyle(
fontSize: 14,
height: 1,
fontWeight: FontWeight.w800,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
CustomPaint(size: const Size(10, tail), painter: _PinTailPainter(fill)),
],
);
}
}
/// The rider's own position: a dot, not a pin. A pin says "a thing is here";
/// a dot says "you are here", and every map app in the world agrees on it.
class MilerRiderDot extends StatelessWidget {
const MilerRiderDot({super.key});
static const double size = 22;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: ColorConstants.deliveryAccent,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: ColorConstants.deliveryAccent.withValues(alpha: 0.45),
blurRadius: 10,
spreadRadius: 2,
),
],
),
);
}
}
class _PinTailPainter extends CustomPainter {
final Color color;
const _PinTailPainter(this.color);
@override
void paint(Canvas canvas, Size size) {
final path = Path()
..moveTo(0, 0)
..lineTo(size.width / 2, size.height)
..lineTo(size.width, 0)
..close();
canvas.drawPath(
path,
Paint()
..color = color
..style = PaintingStyle.fill,
);
}
@override
bool shouldRepaint(_PinTailPainter old) => old.color != color;
}
/// A marker anchored so its *point* sits on the coordinate, not its centre.
Marker milerMarker({
required LatLng point,
required Widget child,
required double width,
required double height,
}) {
return Marker(
point: point,
width: width,
height: height,
// The pin's tip is at the bottom edge of its box, so the box hangs above
// the coordinate. Google did this for us; flutter_map centres by default,
// which puts the tip half a pin south of the address.
alignment: Alignment.topCenter,
child: child,
);
}
/// Metres between two coordinates, on the great circle.
///
/// `Geolocator.distanceBetween` does this too, but it is a platform call: it
/// cannot run in a test, and the route screens ask for it once per point of a
/// several-hundred-point line.
double milerDistanceMetres(LatLng a, LatLng b) {
const earth = 6371000.0;
final dLat = _rad(b.latitude - a.latitude);
final dLng = _rad(b.longitude - a.longitude);
final h =
math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(_rad(a.latitude)) *
math.cos(_rad(b.latitude)) *
math.sin(dLng / 2) *
math.sin(dLng / 2);
return earth * 2 * math.atan2(math.sqrt(h), math.sqrt(1 - h));
}
double _rad(double deg) => deg * math.pi / 180.0;

View File

@@ -0,0 +1,278 @@
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,
),
);
},
);
}

View File

@@ -0,0 +1,336 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// ───────────────────────────────────────────────────────────────────────────
/// THE ACCOUNT SECTION'S VISUAL LANGUAGE
///
/// Account is nine screens deep — Edit Profile, Saved Addresses, Alert Sound,
/// Notifications, FAQ, Help, Contact Support, Terms, Rewards — and until now
/// each one had been drawn by hand. They disagreed about the page colour (a
/// warm surface on one, `grey.shade50` on the next, `grey.shade100` on a
/// third), about the card (18r hairline here, 16r `grey.shade100` there, a
/// 1.5px `borderStrong` on a fourth), about text colour (tokens on some pages,
/// `Colors.black87` and `grey.shade600` on others), and about whether type
/// scaled with the device at all. Tapping the bell from Account moved the rider
/// to what looked like a different product.
///
/// The fix is not to restyle nine files nine times. It is to have one card, one
/// row and one page body, and to let every screen in the section be assembled
/// from them — which is what this file is. The look is the Account page's own,
/// because that is the screen the rider sees first and the one the others have
/// to agree with.
///
/// Colour comes from [ColorConstants] only. Nothing in this file, and nothing
/// built on it, should name a hex value or a `Colors.*` swatch: the brand is
/// red, the neutrals are tokens, and a screen that invents its own grey is a
/// screen that will drift again the next time someone edits it.
/// ───────────────────────────────────────────────────────────────────────────
/// The one card. White, softly lifted, hairline edge — the Account page's.
class SettingsCard extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
const SettingsCard({super.key, required this.child, this.padding});
static BoxDecoration decoration() => BoxDecoration(
color: ColorConstants.surfaceContainerLowest,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.dividerColor),
boxShadow: [
BoxShadow(
color: ColorConstants.onSurface.withValues(alpha: 0.04),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
);
@override
Widget build(BuildContext context) {
return Container(padding: padding, decoration: decoration(), child: child);
}
}
/// A list of [SettingsRow]s, hairline-separated, sitting directly on the page.
///
/// ── No card ──
///
/// These groups used to be cards: a filled panel with a hairline border and a
/// soft shadow, one per section. On the Account page that meant five stacked
/// rectangles — Money, Account, Appearance, Support, Logout — each drawn round
/// rows that were already separated by a section heading and a 22pt gap. The
/// box was saying, a second time, what the heading and the whitespace had
/// already said.
///
/// It cost something, too. A card is a surface, and a surface has to be told
/// apart from the page behind it — so the whole Account page became a stack of
/// grey slabs, with nothing on screen that was actually a *thing* rather than a
/// container for things.
///
/// Uber's account page has no cards at all: section label, rows, hairline
/// between them, gap, next section. The list *is* the structure. That is what
/// this is now — and because every screen in this section is assembled from
/// this file, all nine of them lose the boxes together.
///
/// ── And no rules between the rows ──
///
/// With the card gone, the hairline under every row was the last thing still
/// drawing boxes: nine rules down the Account page, each separating two lines
/// that are already a title and a subtitle apart. Uber's account list has none.
/// The rows sit close, the type does the separating, and the section headings
/// are the only structure on the page.
///
/// [ruled] puts them back for the few lists that genuinely need one — a long
/// choice list of identical single-line rows, where nothing else distinguishes
/// one from the next.
///
/// Pass the rows in order; the divider is drawn here rather than by each row,
/// so a row never has to know whether it is last.
class SettingsGroup extends StatelessWidget {
final List<Widget> rows;
/// Draws a hairline between rows. Off by default.
final bool ruled;
/// Where that hairline starts, measured from the group's left edge. Clears
/// the icon column so it lines up under the text.
final double dividerIndent;
const SettingsGroup({
super.key,
required this.rows,
this.ruled = false,
double? dividerIndent,
}) : dividerIndent = dividerIndent ?? 38;
@override
Widget build(BuildContext context) {
if (!ruled) return Column(children: rows);
final List<Widget> children = [];
for (int i = 0; i < rows.length; i++) {
children.add(rows[i]);
if (i != rows.length - 1) {
children.add(
Padding(
padding: EdgeInsets.only(left: dividerIndent.w),
child: Divider(
height: 1,
thickness: 1,
color: ColorConstants.dividerColor,
),
),
);
}
}
return Column(children: children);
}
}
/// One tappable line in a [SettingsGroup]: icon, title, optional second line,
/// and a chevron unless something else is given.
///
/// [PressScale] rather than a bare tap target, because most of these rows open
/// a screen that takes ~360ms to arrive — without a press state the rider gets
/// no acknowledgement at all in that gap.
class SettingsRow extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final VoidCallback? onTap;
/// Replaces the chevron — a switch, a value, a badge.
final Widget? trailing;
/// Destructive actions (Logout). Draws in the brand red and drops the
/// chevron, since nothing is being navigated to.
final bool danger;
/// Overrides the icon tint — for a row whose icon carries state, such as the
/// filled check on the chosen alert sound.
final Color? iconColor;
const SettingsRow({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.onTap,
this.trailing,
this.danger = false,
this.iconColor,
});
@override
Widget build(BuildContext context) {
final Color fg = danger ? ColorConstants.primary : ColorConstants.onSurface;
return PressScale(
onTap: onTap,
// Announced as one button named by its title and second line, rather than
// three unlabelled text fragments and an icon.
semanticLabel: subtitle == null || subtitle!.trim().isEmpty
? title
: '$title. $subtitle',
child: Padding(
// No horizontal inset: the group is no longer inside a card, so the row
// starts on the page's own gutter and the section heading above it
// lines up with the icon rather than floating inboard of it.
padding: EdgeInsets.symmetric(vertical: 12.h),
child: Row(
children: [
Icon(icon, color: iconColor ?? fg, size: 22.sp),
SizedBox(width: 14.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: fg,
fontFamily: FontConstants.fontFamily,
),
),
if (subtitle != null && subtitle!.trim().isNotEmpty) ...[
SizedBox(height: 2.h),
Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
],
],
),
),
if (trailing != null)
trailing!
else if (!danger)
Icon(
Icons.chevron_right_rounded,
color: ColorConstants.onSurfaceVariant,
size: 20.sp,
),
],
),
),
);
}
}
/// The small caps label that names a group of cards.
class SettingsLabel extends StatelessWidget {
final String text;
const SettingsLabel(this.text, {super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: 4.h),
child: Text(
text.toUpperCase(),
style: TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.1,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
);
}
}
/// A short explanatory line under a card — the place for the sentence that
/// stops a rider guessing what a setting does.
class SettingsHint extends StatelessWidget {
final String text;
const SettingsHint(this.text, {super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(4.w, 8.h, 4.w, 0),
child: Text(
text,
style: TextStyle(
fontSize: 12.sp,
height: 1.45,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
);
}
}
/// The page body every screen in the section uses: one scroll view, one set of
/// margins, one entrance animation, and — crucially — one pull-to-refresh
/// behaviour.
///
/// ── Why the physics matter ──
///
/// Several of these pages used `BouncingScrollPhysics`, which forces the iOS
/// rubber-band on every platform. Rubber-band overscroll is *unbounded*: with
/// no `RefreshIndicator` to catch the gesture and stop it, a pull-down dragged
/// the whole page off the bottom of the screen against a blank gap and sprang
/// back having done nothing. The gesture responded, so it read as a refresh
/// that had failed. Home was fixed this way months ago; this brings the rest of
/// the app in line.
///
/// Pass [onRefresh] and the pull does what it looks like it does. Omit it and
/// the page still refuses to rubber-band into empty space.
class SettingsBody extends StatelessWidget {
final List<Widget> children;
final Future<void> Function()? onRefresh;
final EdgeInsetsGeometry? padding;
const SettingsBody({
super.key,
required this.children,
this.onRefresh,
this.padding,
});
@override
Widget build(BuildContext context) {
final Widget scroll = SingleChildScrollView(
// `AlwaysScrollable` so a short page can still be pulled, composed with
// the platform default rather than overriding it — clamped on Android,
// bouncing on iOS, bounded on both.
physics: const AlwaysScrollableScrollPhysics(),
padding: padding ?? EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 28.h),
child: Reveal(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
),
);
return SafeArea(
bottom: false,
child: onRefresh == null
? scroll
: RefreshIndicator(
onRefresh: onRefresh!,
color: ColorConstants.primary,
backgroundColor: ColorConstants.surfaceContainerLowest,
child: scroll,
),
);
}
}