first commit
This commit is contained in:
271
lib/views/helpers/constants/Colorconstants.dart
Normal file
271
lib/views/helpers/constants/Colorconstants.dart
Normal file
@@ -0,0 +1,271 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Every colour in the app.
|
||||
class ColorConstants {
|
||||
// --- Rider Operations System Design System ---
|
||||
|
||||
/// The page itself. Every other surface is measured against this.
|
||||
static const Color surface = Color(0xFFFCF9F8);
|
||||
static const Color surfaceDim = Color(0xFFDCD9D9);
|
||||
static const Color surfaceBright = Color(0xFFFCF9F8);
|
||||
|
||||
/// The lightest container — a white card.
|
||||
static const Color surfaceContainerLowest = Color(0xFFFFFFFF);
|
||||
static const Color surfaceContainerLow = Color(0xFFF6F3F2);
|
||||
static const Color surfaceContainer = Color(0xFFF0EDED);
|
||||
static const Color surfaceContainerHigh = Color(0xFFEAE7E7);
|
||||
static const Color surfaceContainerHighest = Color(0xFFE4E2E1);
|
||||
|
||||
static const Color onSurface = Color(0xFF1B1C1C);
|
||||
|
||||
/// Body text on a surface. **Was #5C403E — a brown**, the M3 tonal derivation
|
||||
/// of the red brand seed, on 56 call sites and in the app's `TextTheme`. Its
|
||||
/// contrast was fine (9.30:1); it simply was not the same app as the slate
|
||||
/// dashboard. Now a slate of matched luminance (9.53:1), so nothing gets
|
||||
/// lighter and nothing is brown.
|
||||
static const Color onSurfaceVariant = Color(0xFF3D4654);
|
||||
|
||||
static const Color inverseSurface = Color(0xFF303030);
|
||||
static const Color inverseOnSurface = Color(0xFFFCF9F8);
|
||||
static const Color outline = Color(0xFF906F6D);
|
||||
|
||||
/// Default outline. **Was #E5BDBB — a pink**, again derived from the red seed,
|
||||
/// and it fed `ColorScheme.outlineVariant` plus every `OutlinedButton` side and
|
||||
/// `TextField` border in [AppTheme]. So every input in the app had a pink edge.
|
||||
/// Now the same slate hairline the dashboard uses for container edges.
|
||||
static const Color outlineVariant = Color(0xFFCBD5E1);
|
||||
static const Color surfaceTint = Color(0xFF960019);
|
||||
|
||||
// Primary Red
|
||||
static const Color primary = Color(0xFF960019);
|
||||
static const Color onPrimary = Color(0xFFFFFFFF);
|
||||
static const Color primaryContainer = Color(0xFFC01227);
|
||||
static const Color onPrimaryContainer = Color(0xFFFFD1CE);
|
||||
static const Color inversePrimary = Color(0xFFFFB3AF);
|
||||
|
||||
// Secondary Dark
|
||||
static const Color secondary = Color(0xFF5F5E5E);
|
||||
static const Color onSecondary = Color(0xFFFFFFFF);
|
||||
static const Color secondaryContainer = Color(0xFFE2DFDE);
|
||||
static const Color onSecondaryContainer = Color(0xFF636262);
|
||||
|
||||
// Tertiary (Info Blue)
|
||||
static const Color tertiary = Color(0xFF004E6F);
|
||||
static const Color onTertiary = Color(0xFFFFFFFF);
|
||||
static const Color tertiaryContainer = Color(0xFF006791);
|
||||
static const Color onTertiaryContainer = Color(0xFFB8E1FF);
|
||||
|
||||
// Error
|
||||
static const Color error = Color(0xFFBA1A1A);
|
||||
static const Color onError = Color(0xFFFFFFFF);
|
||||
static const Color errorContainer = Color(0xFFFFDAD6);
|
||||
static const Color onErrorContainer = Color(0xFF93000A);
|
||||
|
||||
// Success Green
|
||||
static const Color success = Color(0xFF006A46);
|
||||
static const Color onSuccess = Color(0xFFFFFFFF);
|
||||
static const Color successContainer = Color(0xFF008659);
|
||||
|
||||
/// The "go" green: Accept, accept-all, slide-to-duty, and the tick that marks
|
||||
/// a finished stop.
|
||||
///
|
||||
/// **Was #12B76A, which failed WCAG outright as a button fill.** White text on
|
||||
/// it measured **2.62:1** — under even the 3:1 floor for large text and
|
||||
/// graphics — which means the label on the single most-pressed button in the
|
||||
/// app washed out in exactly the daylight this app is used in.
|
||||
///
|
||||
/// Now 5.48:1 behind white, and ≥4.5:1 on all four surfaces the app draws on.
|
||||
static const Color acceptGreen = Color(0xFF047857);
|
||||
|
||||
/// Kept as a name for the cash figure, now that [acceptGreen] is dark enough
|
||||
/// to be read as text. Same value; the two greens collapsed into one.
|
||||
static const Color moneyGreen = acceptGreen;
|
||||
|
||||
/// Reward points — the app's third currency, after cash ([moneyGreen]) and
|
||||
/// completed work ([acceptGreen]).
|
||||
///
|
||||
/// Deliberately *not* [warning]: amber means "attention, something is
|
||||
/// unfinished" everywhere else in this app, and a points balance is good news.
|
||||
static const Color rewardAmber = Color(0xFF92400E);
|
||||
|
||||
// Warning Amber
|
||||
//
|
||||
// #B45309 → #AE5008. A three-percent darkening, for one reason: it cleared
|
||||
// 4.5:1 on white (5.02) and on the page (4.79) but landed at **4.36:1 on
|
||||
// `cardSurface`** — and the card fill is where warnings actually appear, on
|
||||
// the skip sheet and the short-payment notice. A token that passes on the
|
||||
// surface nobody uses it on has not passed.
|
||||
static const Color warning = Color(0xFFAE5008);
|
||||
static const Color onWarning = Color(0xFFFFFFFF);
|
||||
static const Color warningContainer = Color(0xFFFEF3C7);
|
||||
|
||||
// Extended Color Palette for Professional UI
|
||||
/// Captions, labels and secondary body text — the second most-used colour in
|
||||
/// the app after [slateText], on 89 call sites across every screen.
|
||||
///
|
||||
/// **Was #8E8E93, which fails WCAG AA.** It measured 3.26:1 on white, against
|
||||
/// a 4.5:1 floor — so on a phone clamped to a handlebar in daylight, every
|
||||
/// caption in the app was the one thing a rider could not read. It is now
|
||||
/// 6.00:1 on white.
|
||||
static const Color secondaryText = Color(0xFF5A6472);
|
||||
|
||||
/// ── A fill, never a text colour ──
|
||||
///
|
||||
/// #C7C7CC measures **1.68:1 on white** — below even the 3:1 floor for large
|
||||
/// text. It was being used for readable captions and hints on seven screens,
|
||||
/// including the helper line under the phone field on the first screen a new
|
||||
/// rider ever sees.
|
||||
///
|
||||
/// Renamed from `disabledText` so the name cannot invite the mistake again.
|
||||
/// A genuinely disabled *control* may be filled with this; the words on it
|
||||
/// use [secondaryText].
|
||||
static const Color disabledFill = Color(0xFFC7C7CC);
|
||||
|
||||
static const Color dividerColor = Color(0xFFE9EBF0);
|
||||
static const Color upiPurple = Color(0xFF7C3AED);
|
||||
static const Color successLight = Color(0xFFF0FFF4);
|
||||
static const Color errorLight = Color(0xFFFFF0F0);
|
||||
static const Color neutralLight = Color(0xFFF2F4F8);
|
||||
|
||||
/// Reject, and inline validation errors.
|
||||
static const Color errorRed = Color(0xFFB91C1C);
|
||||
|
||||
// ── Five tokens retired here ──
|
||||
//
|
||||
// `successGreen` #34C759 (2.22:1), `infoBlue` #007AFF (4.02:1),
|
||||
// `warningOrange` #FF9500 (2.20:1), `cardBlue` #0EA5E9 (2.77:1) and
|
||||
// `warningYellow` #FFB800 (1.73:1) were a leftover iOS-derived set living
|
||||
// alongside the tuned palette. Every one of them failed WCAG AA as text, and
|
||||
// `warningOrange` was carrying the "you skipped this, this is the retry"
|
||||
// notice *as text* — the one class of message a rider must not miss was the
|
||||
// one that disappeared in sunlight.
|
||||
//
|
||||
// They are gone rather than fixed, because the tuned equivalents already
|
||||
// exist and already pass: [warning] 5.02:1, [acceptGreen] 5.48:1,
|
||||
// [tertiary], [deliveryAccent] 5.17:1. Two palettes for one set of meanings
|
||||
// was the actual defect.
|
||||
|
||||
// ── Stop-type accents ────────────────────────────────────────────────
|
||||
// One accent per operational mode so a rider never confuses what he is
|
||||
// supposed to DO at a stop. These are the single source — `stop_type.dart`
|
||||
// reads them, nothing else should hardcode these hex values.
|
||||
static const Color pickupAccent = primary;
|
||||
// #2563EB → #2462E8, for the same reason as [warning]: 4.49:1 on
|
||||
// `cardSurface`, a hair under the floor, on the accent that names every
|
||||
// delivery leg in the app.
|
||||
static const Color deliveryAccent = Color(0xFF2462E8);
|
||||
static const Color combinedAccent = Color(0xFF5B21B6);
|
||||
|
||||
// ── Stop-type CHIP fills ─────────────────────────────────────────────
|
||||
// Darkened siblings of the accents above, for the small filled tag that names
|
||||
// a stop's mode. A tag is a mark, not a surface.
|
||||
static const Color pickupChip = Color(0xFF7F0014);
|
||||
static const Color deliveryChip = Color(0xFF1E40AF);
|
||||
static const Color combinedChip = Color(0xFF4C1D95);
|
||||
|
||||
/// Text drawn *on* a chip fill.
|
||||
static const Color onChip = Color(0xFFFFFFFF);
|
||||
|
||||
/// ── Borderless card fill ─────────────────────────────────────────────
|
||||
///
|
||||
/// For cards that carry no border and no shadow, and rely on a fill to read as
|
||||
/// a distinct surface — the pattern most large apps use for list cards.
|
||||
///
|
||||
/// Measured at a 9.4% relative-luminance drop from the page: riders reported
|
||||
/// being unable to tell cards apart when a card sat ~3% from its background,
|
||||
/// and with no border and no shadow the fill carries that alone, outdoors.
|
||||
static const Color cardSurface = Color(0xFFEDEFF4);
|
||||
|
||||
/// ── The white a surface is actually made of ─────────────────────────
|
||||
///
|
||||
/// The panel *inside* a card — the stat strip on an Activity row, the fact
|
||||
/// tiles on the verification review.
|
||||
static const Color pureSurface = Color(0xFFFFFFFF);
|
||||
|
||||
/// ── The neutral action colour ──
|
||||
///
|
||||
/// The colour Uber uses for anything that is *chrome* rather than brand: the
|
||||
/// selected tab, a switch that is on, the fill behind a chosen option.
|
||||
///
|
||||
/// Those were all the brand red at 10–14% alpha, which reads as a faint blush
|
||||
/// and is not a brand moment. The red is still the red where it means
|
||||
/// something: the primary button, the stop-type chips, a destructive action.
|
||||
///
|
||||
/// The rule Uber follows, and this now does: colour marks meaning, neutral
|
||||
/// marks state.
|
||||
static const Color emphasis = Color(0xFF0F172A);
|
||||
|
||||
/// The wash behind a selected row or tab — [emphasis] at the weight that
|
||||
/// reads on the page without becoming a surface of its own.
|
||||
static const Color emphasisWash = Color(0x140F172A);
|
||||
|
||||
/// The same wash in the brand hue, for the one place the selected state is
|
||||
/// deliberately branded rather than neutral: the bottom nav's travelling
|
||||
/// pill. Kept at [emphasisWash]'s exact 8% so swapping the hue cannot also
|
||||
/// change how heavy the pill reads.
|
||||
static const Color primaryWash = Color(0x14960019);
|
||||
|
||||
/// Text and icons drawn on a filled accent — brand red, accept green, a dark
|
||||
/// snackbar.
|
||||
static const Color onAccent = Color(0xFFFFFFFF);
|
||||
|
||||
/// ── The frosted brand wash on every app bar ───────────────────────────
|
||||
///
|
||||
/// A translucent maroon laid over the page rather than a solid slab of it.
|
||||
/// Painted behind a blur (see `milerGlassSurface`), so what shows through is
|
||||
/// the page's own colour softened and tinted.
|
||||
///
|
||||
/// Deliberately weak. At 7% the surface stays light enough for near-black
|
||||
/// type, which is what keeps a title readable through a windscreen mount in
|
||||
/// sun.
|
||||
static const Color glassRed = Color(0x12960019);
|
||||
|
||||
/// ── The fill on a frosted sheet ──
|
||||
///
|
||||
/// A sheet over a map has something worth seeing underneath it, and a solid
|
||||
/// slab throws all of it away. Frosted, the map stays legible as context while
|
||||
/// the sheet stays legible as content.
|
||||
///
|
||||
/// Deliberately high at 88%. This is not decoration: the text over it is an
|
||||
/// address and an ETA, read one-handed at a doorstep in daylight, and every
|
||||
/// point of transparency spent on the effect is contrast taken off the words.
|
||||
static const Color glassSheet = Color(0xE0FDFDFE);
|
||||
|
||||
/// The specular rim along a glass edge — what makes frost read as a pane
|
||||
/// rather than as a translucent rectangle.
|
||||
static const Color glassRim = Color(0x8CFFFFFF);
|
||||
|
||||
// ── Daylight surfaces ────────────────────────────────────────────────
|
||||
// Tuned for a phone clamped to a handlebar in direct sun. Slate-tinted grey
|
||||
// separates from white far more strongly outdoors than the old warm surface.
|
||||
static const Color daylightSurface = Color(0xFFF8FAFC);
|
||||
|
||||
/// The app's near-black body colour.
|
||||
static const Color slateText = Color(0xFF0F172A);
|
||||
|
||||
/// Card/section borders. `borderStrong` is the default for anything that
|
||||
/// must read as a distinct container in sunlight; `borderSubtle` is only
|
||||
/// for internal dividers inside an already-bordered card.
|
||||
static const Color borderStrong = Color(0xFFCBD5E1);
|
||||
static const Color borderSubtle = Color(0xFFE2E8F0);
|
||||
|
||||
// Task categories
|
||||
static Color parcelColor = Colors.amber[100]!;
|
||||
static Color groceriesColor = Colors.purple[100]!;
|
||||
static Color shoppingColor = Colors.orange[100]!;
|
||||
static Color healthColor = Colors.cyan[100]!;
|
||||
static Color handymanColor = Colors.red[100]!;
|
||||
}
|
||||
|
||||
extension ColorExtension on String {
|
||||
Color? toColor() {
|
||||
var hexColor = replaceAll("#", "");
|
||||
if (hexColor.length == 6) {
|
||||
hexColor = "FF$hexColor";
|
||||
}
|
||||
if (hexColor.length == 8) {
|
||||
return Color(int.parse("0x$hexColor"));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
136
lib/views/helpers/constants/Font_constant.dart
Normal file
136
lib/views/helpers/constants/Font_constant.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FontConstants {
|
||||
// Miler Design System: Manrope typography (variable font) — a free,
|
||||
// OFL-licensed geometric sans chosen as the closest legal stand-in for
|
||||
// Uber Move (which is proprietary and cannot be bundled).
|
||||
static const String fontFamily = 'Manrope';
|
||||
static String fontFamilyFallback = 'PlusJakartaSans';
|
||||
|
||||
// Typography scale
|
||||
static const double displayLg = 32.0;
|
||||
static const double headlineMd = 24.0;
|
||||
static const double headlineSm = 20.0;
|
||||
static const double bodyLg = 18.0;
|
||||
static const double bodyMd = 16.0;
|
||||
static const double labelBold = 14.0;
|
||||
static const double labelSm = 12.0;
|
||||
|
||||
static TextStyle displayLgStyle = const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.64,
|
||||
fontFamily: fontFamily,
|
||||
);
|
||||
|
||||
static TextStyle headlineMdStyle = const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.24,
|
||||
fontFamily: fontFamily,
|
||||
);
|
||||
|
||||
static TextStyle headlineSmStyle = const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
fontFamily: fontFamily,
|
||||
);
|
||||
|
||||
static TextStyle bodyLgStyle = const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFamily: fontFamily,
|
||||
);
|
||||
|
||||
static TextStyle bodyMdStyle = const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontFamily: fontFamily,
|
||||
);
|
||||
|
||||
static TextStyle labelBoldStyle = const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: fontFamily,
|
||||
);
|
||||
|
||||
static TextStyle labelSmStyle = const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFamily: fontFamily,
|
||||
);
|
||||
|
||||
static double getResponsiveFontSize(BuildContext context, double baseSize) {
|
||||
return baseSize;
|
||||
}
|
||||
|
||||
static double extraSmall(BuildContext context) =>
|
||||
getResponsiveFontSize(context, 10);
|
||||
static double small(BuildContext context) =>
|
||||
getResponsiveFontSize(context, labelSm);
|
||||
static double medium(BuildContext context) =>
|
||||
getResponsiveFontSize(context, bodyMd);
|
||||
static double regular(BuildContext context) =>
|
||||
getResponsiveFontSize(context, bodyLg);
|
||||
static double large(BuildContext context) =>
|
||||
getResponsiveFontSize(context, headlineSm);
|
||||
static double xLarge(BuildContext context) =>
|
||||
getResponsiveFontSize(context, headlineMd);
|
||||
static double xxLarge(BuildContext context) =>
|
||||
getResponsiveFontSize(context, 28);
|
||||
static double xxxLarge(BuildContext context) =>
|
||||
getResponsiveFontSize(context, 30);
|
||||
static double huge(BuildContext context) =>
|
||||
getResponsiveFontSize(context, displayLg);
|
||||
}
|
||||
|
||||
class ReusableTextWidget extends StatelessWidget {
|
||||
final String text;
|
||||
final double? fontSize;
|
||||
final double? textHeight;
|
||||
final String? fontFamily;
|
||||
final FontWeight? fontWeight;
|
||||
final FontStyle? fontStyle;
|
||||
final Color? color;
|
||||
final TextAlign? textAlign;
|
||||
final int? maxLines;
|
||||
final TextDecoration? isUnderText;
|
||||
|
||||
const ReusableTextWidget({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.fontSize,
|
||||
this.textHeight,
|
||||
this.fontFamily,
|
||||
this.fontWeight,
|
||||
this.fontStyle,
|
||||
this.color,
|
||||
this.textAlign,
|
||||
this.maxLines,
|
||||
this.isUnderText,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
softWrap: true,
|
||||
style: TextStyle(
|
||||
fontSize: fontSize ?? FontConstants.medium(context),
|
||||
decoration: isUnderText,
|
||||
fontFamily: fontFamily ?? FontConstants.fontFamily,
|
||||
decorationColor: color,
|
||||
fontWeight: fontWeight ?? FontWeight.normal,
|
||||
fontStyle: fontStyle ?? FontStyle.normal,
|
||||
color: color ?? Colors.grey.shade900,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
decorationStyle: TextDecorationStyle.solid,
|
||||
decorationThickness: 1,
|
||||
height: textHeight,
|
||||
),
|
||||
maxLines: maxLines,
|
||||
textAlign: textAlign ?? TextAlign.start,
|
||||
);
|
||||
}
|
||||
}
|
||||
90
lib/views/helpers/constants/apiconstants.dart
Normal file
90
lib/views/helpers/constants/apiconstants.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
class ApiConstants {
|
||||
static String mainDev = "dev";
|
||||
static String mainRoute = "live";
|
||||
|
||||
// ── There is no maps key any more ──
|
||||
//
|
||||
// A `googleMapsApiKey` used to live here, feeding `flutter_polyline_points`
|
||||
// and both native projects. It was revoked, so every tile request and every
|
||||
// Directions call came back `REQUEST_DENIED` and the app looked broken rather
|
||||
// than unconfigured.
|
||||
//
|
||||
// The maps no longer need one: tiles come from OpenStreetMap and routes from
|
||||
// OSRM, both keyless. See `lib/views/helpers/widgets/miler_map.dart` and
|
||||
// `lib/helpers/miler_router.dart` — including the note on what those free
|
||||
// services are and are not suitable for.
|
||||
|
||||
//Collection Queue - v2
|
||||
static String pickupQueueDev =
|
||||
"https://jupiter.doormile.app/$mainDev/api/v2/pickups/getpickupqueues";
|
||||
static String pickupQueueLive =
|
||||
"https://jupiter.doormile.app/$mainRoute/api/v2/pickups/getpickupqueues";
|
||||
|
||||
//Current Collection - v1
|
||||
static String currentPickupDev =
|
||||
"https://jupiter.doormile.app/$mainDev/api/v1/pickups/getpickups";
|
||||
static String currentPickupLive =
|
||||
"https://jupiter.doormile.app/$mainRoute/api/v1/pickups/getpickups";
|
||||
|
||||
//Current Collection V3 - v3 (date-bounded)
|
||||
static String currentPickupV3Dev =
|
||||
"https://jupiter.doormile.app/$mainDev/api/v3/pickups/getpickups";
|
||||
static String currentPickupV3Live =
|
||||
"https://jupiter.doormile.app/$mainRoute/api/v3/pickups/getpickups";
|
||||
|
||||
//Update Collection - v1
|
||||
static String updatePickupDev =
|
||||
"https://queue.workolik.com/live/api/v1/pickups/updatepickup";
|
||||
static String updatePickupLive =
|
||||
"https://queue.workolik.com/live/api/v1/pickups/updatepickup";
|
||||
|
||||
//Get Rider Log - v1
|
||||
static String getRiderLogDev =
|
||||
"https://jupiter.doormile.app/$mainDev/api/v1/partners/getriderlog";
|
||||
static String getRiderLogLive =
|
||||
"https://jupiter.doormile.app/$mainRoute/api/v1/partners/getriderlog";
|
||||
|
||||
//Create Rider Log - v2
|
||||
static String createRiderLogDev =
|
||||
"https://queue.workolik.com/live/api/v2/partners/createriderlog";
|
||||
static String createRiderLogLive =
|
||||
"https://queue.workolik.com/live/api/v2/partners/createriderlog";
|
||||
|
||||
//Update Rider Log - v1
|
||||
static String updateRiderLogDev =
|
||||
"https://jupiter.doormile.app/$mainDev/api/v1/partners/updateriderlog";
|
||||
static String updateRiderLogLive =
|
||||
"https://jupiter.doormile.app/$mainRoute/api/v1/partners/updateriderlog";
|
||||
|
||||
//Get Rider Count - v1
|
||||
static String getRiderCountDev =
|
||||
"https://jupiter.doormile.app/$mainDev/api/v1/partners/getridercount";
|
||||
static String getRiderCountLive =
|
||||
"https://jupiter.doormile.app/$mainRoute/api/v1/partners/getridercount";
|
||||
|
||||
//Create Break Rider Log - v2
|
||||
static String createBreakRiderLogDev =
|
||||
"https://queue.workolik.com/live/api/v2/partners/createbreaklog";
|
||||
static String createBreakRiderLogLive =
|
||||
"https://queue.workolik.com/live/api/v2/partners/createbreaklog";
|
||||
|
||||
//Update Break Rider Log - v2
|
||||
static String updateBreakRiderLogDev =
|
||||
"https://queue.workolik.com/live/api/v2/partners/updatebreaklog";
|
||||
static String updateBreakRiderLogLive =
|
||||
"https://queue.workolik.com/live/api/v2/partners/updatebreaklog";
|
||||
|
||||
//Create Collection Log - v2
|
||||
static String createPickupLogDev =
|
||||
"https://queue.workolik.com/live/api/v2/pickups/createpickuplog";
|
||||
static String createPickupLogLive =
|
||||
"https://queue.workolik.com/live/api/v2/pickups/createpickuplog";
|
||||
|
||||
//Summary API - v2
|
||||
static String summaryApiLive =
|
||||
'https://jupiter.doormile.app/$mainRoute/api/v2/partners';
|
||||
|
||||
//Summary Rider Weekly KMs - v1
|
||||
static String summaryriderkmLive =
|
||||
'https://jupiter.doormile.app/$mainRoute/api/v1/partners';
|
||||
}
|
||||
206
lib/views/helpers/constants/app_theme.dart
Normal file
206
lib/views/helpers/constants/app_theme.dart
Normal file
@@ -0,0 +1,206 @@
|
||||
import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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';
|
||||
|
||||
/// Centralized app theme (single source of truth for colors, type, and
|
||||
/// component styling). Screens should lean on this instead of re-specifying
|
||||
/// `ColorConstants.*` + `FontConstants.fontFamily` on every widget.
|
||||
///
|
||||
/// Everything below maps to the existing red/maroon brand via [ColorConstants]
|
||||
/// — no colors changed, just consolidated and extended with component themes
|
||||
/// (buttons, inputs, snackbars, sheets, dividers) so inline styling can be
|
||||
/// retired incrementally.
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
static const Color _primary = ColorConstants.primary;
|
||||
|
||||
/// The app's one theme.
|
||||
static ThemeData get light => _build();
|
||||
|
||||
static ThemeData _build() => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
// The floor for page motion. Named routes and any route pushed without
|
||||
// going through `openScreen`/`openSheet` still slide horizontally with
|
||||
// the parallax and edge-swipe-back, instead of Android's default
|
||||
// fade-through zoom — which, on the mid-range phones riders carry,
|
||||
// reads as the new screen simply appearing.
|
||||
pageTransitionsTheme: const PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||
},
|
||||
),
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontFamilyFallback: const ['Roboto', 'sans-serif'],
|
||||
textTheme: ThemeData.light().textTheme.apply(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
primaryTextTheme: ThemeData.light().primaryTextTheme.apply(
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
colorScheme: const ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
primary: ColorConstants.primary,
|
||||
onPrimary: ColorConstants.onPrimary,
|
||||
primaryContainer: ColorConstants.primaryContainer,
|
||||
onPrimaryContainer: ColorConstants.onPrimaryContainer,
|
||||
secondary: ColorConstants.secondary,
|
||||
onSecondary: ColorConstants.onSecondary,
|
||||
secondaryContainer: ColorConstants.secondaryContainer,
|
||||
onSecondaryContainer: ColorConstants.onSurface,
|
||||
tertiary: ColorConstants.tertiary,
|
||||
onTertiary: ColorConstants.onTertiary,
|
||||
tertiaryContainer: ColorConstants.tertiaryContainer,
|
||||
onTertiaryContainer: ColorConstants.onPrimary,
|
||||
error: ColorConstants.error,
|
||||
onError: ColorConstants.onError,
|
||||
errorContainer: ColorConstants.errorContainer,
|
||||
onErrorContainer: ColorConstants.onSurface,
|
||||
surface: ColorConstants.surface,
|
||||
onSurface: ColorConstants.onSurface,
|
||||
surfaceContainerHighest: ColorConstants.surfaceContainerHighest,
|
||||
outline: ColorConstants.outline,
|
||||
outlineVariant: ColorConstants.outlineVariant,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: ColorConstants.pureSurface,
|
||||
elevation: 0,
|
||||
foregroundColor: ColorConstants.onSurface,
|
||||
systemOverlayStyle: SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarBrightness: Brightness.light,
|
||||
),
|
||||
),
|
||||
scaffoldBackgroundColor: ColorConstants.surface,
|
||||
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
||||
color: _primary,
|
||||
linearTrackColor: ColorConstants.surfaceContainerHigh,
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: ColorConstants.dividerColor,
|
||||
thickness: 1,
|
||||
space: 1,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
side: const BorderSide(color: ColorConstants.outlineVariant, width: 1),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _primary,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: _primary.withValues(alpha: 0.35),
|
||||
disabledForegroundColor: Colors.white.withValues(alpha: 0.9),
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 18),
|
||||
minimumSize: const Size(double.infinity, ButtonSizes.primary),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(ButtonSizes.radius),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: _primary,
|
||||
side: const BorderSide(color: _primary, width: 1.4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
// ── Why this line exists ──
|
||||
//
|
||||
// `ElevatedButton` above pins its height to 56. `OutlinedButton`
|
||||
// used to pin nothing, so it fell back to Material's default 40dp
|
||||
// floor and sized itself from its own padding instead. That is
|
||||
// invisible until the two appear together — which is exactly where
|
||||
// they always appear, as the Cancel/Confirm pair of a dialog. Each
|
||||
// of those rows overrode padding on both buttons and assumed that
|
||||
// settled the height; it did not, because `styleFrom` only replaces
|
||||
// the keys you hand it. Cancel came out 44–49 and Confirm came out
|
||||
// 56, side by side, in three separate dialogs.
|
||||
//
|
||||
// Matching the height here means a plain `OutlinedButton` and a
|
||||
// plain `ElevatedButton` agree by default, and nobody has to know
|
||||
// about the merge order to get a straight row.
|
||||
//
|
||||
// Deliberately `64` wide and not `double.infinity`: outlined
|
||||
// buttons are the ones that share a row or hug their label, so
|
||||
// forcing full width would be wrong more often than right. A
|
||||
// button that must be shorter than 56 has to say so explicitly
|
||||
// with `minimumSize: Size.zero` — see [SecondaryButton].
|
||||
minimumSize: const Size(64, ButtonSizes.primary),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(ButtonSizes.radius),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: _primary,
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: ColorConstants.onSurface,
|
||||
contentTextStyle: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
),
|
||||
bottomSheetTheme: BottomSheetThemeData(
|
||||
backgroundColor: ColorConstants.pureSurface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: ColorConstants.pureSurface,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
borderSide: const BorderSide(color: ColorConstants.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
borderSide: const BorderSide(color: ColorConstants.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
borderSide: const BorderSide(color: _primary, width: 2),
|
||||
),
|
||||
labelStyle: const TextStyle(
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
171
lib/views/helpers/constants/design_constants.dart
Normal file
171
lib/views/helpers/constants/design_constants.dart
Normal file
@@ -0,0 +1,171 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Miler Professional Design System Constants
|
||||
/// Centralized design tokens for spacing, radius, shadows, and layout
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// SHARED RADII AND ELEVATION
|
||||
///
|
||||
/// ── Why this used to be much bigger ──
|
||||
///
|
||||
/// This class shipped 43 members: a nine-step spacing scale, eleven `EdgeInsets`
|
||||
/// presets, six radii, five shadows, a `cardDecoration` helper and three named
|
||||
/// border radii. An audit found **six of them used, 17 times in total.** The
|
||||
/// spacing scale had never been referenced once; every screen writes `16.w` or
|
||||
/// `12.h` inline, which is both more readable at the call site and correct for
|
||||
/// this app, because those need ScreenUtil's per-device scaling and a `const
|
||||
/// double` cannot have it.
|
||||
///
|
||||
/// The 37 dead members were not harmless. A token nobody uses still has to be
|
||||
/// read, still looks authoritative, and is the reason someone reaches for
|
||||
/// `Colors.grey.shade100` instead — a design system that is mostly aspirational
|
||||
/// teaches people to ignore it. So the unused members are gone and what is left
|
||||
/// is only what the app actually reaches for.
|
||||
///
|
||||
/// The audit metric that flagged this file was itself misleading, which is worth
|
||||
/// recording: 13 files import `design_constants.dart` and only 5 mention
|
||||
/// `DesignConstants`. The other 8 import it for [ButtonSizes], which lives in
|
||||
/// this same file and is used 72 times. Colocating a thriving class with a dead
|
||||
/// one hid both facts.
|
||||
///
|
||||
/// **Adding to this class:** don't, until a second call site needs the value.
|
||||
/// Two uses make a token; one use is a local constant.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class DesignConstants {
|
||||
DesignConstants._();
|
||||
|
||||
// ── Motion ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The app was using 35 distinct durations. No shared vocabulary means every
|
||||
// transition is a fresh guess, and some of the guesses sat directly in the
|
||||
// rider's path between two stops — an 800ms entrance and several 500ms
|
||||
// cross-fades on screens he passes through 40 times a shift.
|
||||
//
|
||||
// Motion on a driver app is a tax paid all day. It earns its place only when
|
||||
// it explains a state change; anything else is time spent standing still.
|
||||
|
||||
/// Press and release. Fast enough to feel like the surface responded rather
|
||||
/// than animated.
|
||||
static const Duration motionPress = Duration(milliseconds: 120);
|
||||
|
||||
/// A state change in place: a chip selecting, a tick landing, a row opening.
|
||||
static const Duration motionState = Duration(milliseconds: 200);
|
||||
|
||||
/// A page or a sheet arriving. The ceiling for anything on the critical path.
|
||||
static const Duration motionPage = Duration(milliseconds: 300);
|
||||
|
||||
/// Celebration, and only celebration — once per completed route, never on a
|
||||
/// screen the rider is trying to get through.
|
||||
static const Duration motionCelebrate = Duration(milliseconds: 600);
|
||||
|
||||
// ── Border radius ─────────────────────────────────────────────────────
|
||||
//
|
||||
// ── Three, because 26 communicated nothing ──
|
||||
//
|
||||
// An audit counted 26 distinct radii in use against the four declared here,
|
||||
// and found the most common value in the app was 14 — `ButtonSizes.radius`,
|
||||
// a *button* token, being applied to cards and sheets. Radius is one of the
|
||||
// strongest cues that two things belong to the same family; at 26 values it
|
||||
// says nothing, and two elements side by side at 12 and 14 read as a
|
||||
// rendering bug rather than a decision.
|
||||
//
|
||||
// `radius2xl` (20) is retired: it was the third-most-used value and had no
|
||||
// job the container radius could not do.
|
||||
|
||||
/// **Inner** — inputs, chips, small tiles, panels *inside* a container.
|
||||
static const double radiusLg = 12.0;
|
||||
|
||||
/// **Container** — cards, sheets, and anything that is a surface in its own
|
||||
/// right. Buttons use this too, so a button and the card it sits on agree.
|
||||
static const double radiusXl = 16.0;
|
||||
|
||||
/// **Pill** — fully rounded: pills, circular tracks, avatars.
|
||||
static const double radiusFull = 999.0;
|
||||
|
||||
// ── Elevation ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Only two survive, and both are used sparingly. Most cards in the app are
|
||||
// now borderless fills with no shadow at all — see
|
||||
// `ColorConstants.cardSurface` — so elevation is the exception, not the
|
||||
// default it once was.
|
||||
|
||||
/// A card lifted just off the page.
|
||||
static const List<BoxShadow> shadowSm = [
|
||||
BoxShadow(color: Color(0x1A000000), blurRadius: 4, offset: Offset(0, 2)),
|
||||
];
|
||||
|
||||
/// Something floating over content: a menu, a dragged element.
|
||||
static const List<BoxShadow> shadowLg = [
|
||||
BoxShadow(color: Color(0x2A000000), blurRadius: 12, offset: Offset(0, 8)),
|
||||
];
|
||||
}
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// BUTTON SIZING — one scale for the whole app.
|
||||
///
|
||||
/// Buttons had drifted to a dozen different heights (44, 48, 52, 54, 56, 58)
|
||||
/// across the pickup flow, Home, auth and the sheets. On a screen the rider
|
||||
/// uses 30–50 times a day that reads as sloppiness, and worse, it breaks the
|
||||
/// muscle memory that lets him hit a control without looking: if "the green
|
||||
/// one at the bottom" is a different size on every screen, he has to aim.
|
||||
///
|
||||
/// Three sizes, and only three. Every button in the app must pick one.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class ButtonSizes {
|
||||
ButtonSizes._();
|
||||
|
||||
/// **Primary** — the one action a screen exists for. Full-width CTAs:
|
||||
/// Accept Trip, Start Pickup, Confirm, Navigate.
|
||||
static const double primary = 56.0;
|
||||
|
||||
/// **Secondary** — supporting actions that sit beside or below a primary:
|
||||
/// Reject, Skip, Details, filter chips with a tap target.
|
||||
static const double secondary = 48.0;
|
||||
|
||||
/// **Compact** — dense rows where several actions share a line, e.g. the
|
||||
/// per-stop Accept/Reject pair inside a trip card. Still above the 44dp
|
||||
/// accessibility floor.
|
||||
static const double compact = 44.0;
|
||||
|
||||
/// Square icon-only buttons (call, overflow) match [primary] so they line up
|
||||
/// with the CTA they sit next to.
|
||||
static const double icon = 56.0;
|
||||
|
||||
/// Corner radius, shared by every button size so they read as one family.
|
||||
///
|
||||
/// 14 → 16 so it matches [DesignConstants.radiusXl]. At 14 a button sitting
|
||||
/// on a 16pt card had a visibly tighter corner than the card holding it,
|
||||
/// which is the kind of two-point difference nobody can name and everybody
|
||||
/// sees.
|
||||
static const double radius = DesignConstants.radiusXl;
|
||||
|
||||
/// Absolute floor for any tappable target, per accessibility guidance.
|
||||
static const double minTapTarget = 44.0;
|
||||
}
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// DESIGN TRIAL — full addresses on stop cards
|
||||
///
|
||||
/// Turn on to see every stop card carrying its **complete** address rather than
|
||||
/// the trimmed one, over [kFullAddressMaxLines] lines instead of two.
|
||||
///
|
||||
/// Two separate things are suppressed normally, and both are lifted together
|
||||
/// here or the trial would not show what it claims to:
|
||||
///
|
||||
/// • **Home shortens the text.** `Trip.shortAddress` strips the tail every stop
|
||||
/// on the route shares — the city, the state, the pincode — because repeating
|
||||
/// "Coimbatore, Tamil Nadu 641006" on all six stops tells the rider nothing
|
||||
/// about which one he is looking at. With the trial on, the raw address is
|
||||
/// used instead. Bookings already shows the raw value.
|
||||
///
|
||||
/// • **Both screens cap the lines.** Two, so a card's height does not depend on
|
||||
/// how verbose a customer was — a list of cards that step unevenly down the
|
||||
/// screen is harder to scan and fits fewer stops.
|
||||
///
|
||||
/// Both caps exist for measured reasons, so this is a switch rather than a
|
||||
/// deletion. **Set to `false` to restore the shipped behaviour.**
|
||||
const bool kFullAddressTrial = true;
|
||||
|
||||
/// Line cap while [kFullAddressTrial] is on. Four fits a full Indian address at
|
||||
/// 13sp in a stop card's column without letting one pathological record run the
|
||||
/// card off the screen.
|
||||
const int kFullAddressMaxLines = 4;
|
||||
198
lib/views/helpers/constants/miler_type.dart
Normal file
198
lib/views/helpers/constants/miler_type.dart
Normal file
@@ -0,0 +1,198 @@
|
||||
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';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE TYPE SCALE
|
||||
///
|
||||
/// ── Why this exists ──
|
||||
///
|
||||
/// An audit counted **51 distinct font sizes** across the app and **448 sites
|
||||
/// repeating `fontFamily: FontConstants.fontFamily` by hand**. Sizes in use ran
|
||||
/// 9, 9.5, 10, 10.5, 11, 11.5, 12, 12.5, 13, 13.5, 14, 14.5, 15, 16, 16.5, 17,
|
||||
/// 18, 19, 20, 21, 22, 23, 24, 26, 27, 28, 30, 46.
|
||||
///
|
||||
/// That is not carelessness. There was simply nothing to look up: every screen
|
||||
/// had to invent its own typography, so screens drifted the moment they were
|
||||
/// written and a reviewer had nothing to compare a pull request against. It is
|
||||
/// why the page title is 23sp on three tabs, 22sp on two flow screens and 21sp
|
||||
/// on another.
|
||||
///
|
||||
/// ── Why not `Theme.of(context).textTheme` ──
|
||||
///
|
||||
/// The same reason [ColorConstants] is static: the app reads type on hundreds
|
||||
/// of call sites that have no `BuildContext` anywhere near them, and `AppTheme`
|
||||
/// already proves the point — it builds a full `TextTheme` that **nothing in
|
||||
/// the app reads**. A scale nobody can reach is not a scale.
|
||||
///
|
||||
/// So these are plain static getters, in the shape the app already reaches for.
|
||||
///
|
||||
/// ── The eight roles ──
|
||||
///
|
||||
/// | role | size | where |
|
||||
/// |---------------|------|-------|
|
||||
/// | [hero] | 28 | the one thing a screen is about — the next stop's name |
|
||||
/// | [pageTitle] | 22 | app bar titles, sheet titles |
|
||||
/// | [cardTitle] | 19 | a stop name in a list, a section heading |
|
||||
/// | [body] | 16 | addresses, running copy, list-row titles |
|
||||
/// | [caption] | 15 | supporting copy under a title |
|
||||
/// | [label] | 13 | metadata, timestamps, chip text |
|
||||
/// | [micro] | 12.5 | the smallest readable size in the app |
|
||||
/// | [eyebrow] | 11 | letterspaced uppercase group names |
|
||||
///
|
||||
/// [figure] and [buttonLabel] are roles rather than sizes — a figure is a
|
||||
/// number read at a glance and takes the size the context demands, a button
|
||||
/// label is fixed by [ButtonSizes].
|
||||
///
|
||||
/// ── Rules ──
|
||||
///
|
||||
/// * **Same role = same size.** Do not add a size; add a role, and only when a
|
||||
/// second screen needs it.
|
||||
/// * **No half points** unless the half point is doing real work. `12.5` earns
|
||||
/// its place as the floor; `13.5`, `14.5` and `16.5` did not and are gone.
|
||||
/// * Every style already carries the family, so **no call site should name it**.
|
||||
/// * Colour is a separate axis. Pass `.on(...)` to recolour, or use the named
|
||||
/// helpers, so a role never hardcodes a token.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class MilerType {
|
||||
MilerType._();
|
||||
|
||||
static const String _family = FontConstants.fontFamily;
|
||||
|
||||
/// The one thing a screen is about. One per screen, or none.
|
||||
static TextStyle get hero => TextStyle(
|
||||
fontSize: 28.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.9,
|
||||
height: 1.08,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: _family,
|
||||
);
|
||||
|
||||
/// App bar titles and sheet headings.
|
||||
static TextStyle get pageTitle => TextStyle(
|
||||
fontSize: 22.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.6,
|
||||
height: 1.15,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: _family,
|
||||
);
|
||||
|
||||
/// A stop name in a list, a section heading inside a screen.
|
||||
static TextStyle get cardTitle => TextStyle(
|
||||
fontSize: 19.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.4,
|
||||
height: 1.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: _family,
|
||||
);
|
||||
|
||||
/// Addresses, running copy, the title line of a compact row.
|
||||
static TextStyle get body => TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
height: 1.35,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: _family,
|
||||
);
|
||||
|
||||
/// Supporting copy under a title. Grey by default — this is the role that
|
||||
/// explains, and explanation is secondary to the thing being explained.
|
||||
static TextStyle get caption => TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.4,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: _family,
|
||||
);
|
||||
|
||||
/// Metadata, timestamps, chip text, the second line of a row.
|
||||
static TextStyle get label => TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.35,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: _family,
|
||||
);
|
||||
|
||||
/// The floor. Nothing in this app may be smaller than this and still be
|
||||
/// expected to be read outdoors.
|
||||
static TextStyle get micro => TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.3,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: _family,
|
||||
);
|
||||
|
||||
/// Letterspaced uppercase group names. The quietest way to name a group, and
|
||||
/// the reason most groups in this app need no outline.
|
||||
static TextStyle get eyebrow => TextStyle(
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.2,
|
||||
height: 1.3,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: _family,
|
||||
);
|
||||
|
||||
/// [pageTitle] without ScreenUtil.
|
||||
///
|
||||
/// App bars are built eagerly as a `PreferredSizeWidget` argument, sometimes
|
||||
/// before `ScreenUtilInit` has run — and `toolbarHeight` is a fixed logical
|
||||
/// value anyway, so a title that scaled with the device inside a bar that did
|
||||
/// not would only find new ways to clip. This is the same role at the same
|
||||
/// number, resolved without `.sp`.
|
||||
static const TextStyle pageTitleFixed = TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.6,
|
||||
height: 1.15,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: _family,
|
||||
);
|
||||
|
||||
/// A number read at a glance: a cash amount, a distance, a duration.
|
||||
///
|
||||
/// Sized by its context rather than fixed — a figure in a stat column is not
|
||||
/// the same size as one in a hero — so this takes the size and supplies only
|
||||
/// what makes a figure a figure: heavy, tight, and tabular so a column of
|
||||
/// them lines up.
|
||||
static TextStyle figure(double size, {Color? color}) => TextStyle(
|
||||
fontSize: size.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
height: 1.1,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: color ?? ColorConstants.slateText,
|
||||
fontFamily: _family,
|
||||
);
|
||||
|
||||
/// The label inside a button. Sized against the button's own height, so a
|
||||
/// compact button does not shout like a primary one.
|
||||
static TextStyle buttonLabel(double height) => TextStyle(
|
||||
fontSize: (height >= 56 ? 16 : height >= 48 ? 15 : 14).sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.1,
|
||||
color: ColorConstants.onAccent,
|
||||
fontFamily: _family,
|
||||
);
|
||||
}
|
||||
|
||||
/// Recolour and reweight a role without leaving the scale.
|
||||
///
|
||||
/// `MilerType.body.on(ColorConstants.secondaryText)` keeps the size, family,
|
||||
/// tracking and line height of the role and changes only what is asked for —
|
||||
/// which is the whole point of having roles.
|
||||
extension MilerTypeTint on TextStyle {
|
||||
TextStyle on(Color color) => copyWith(color: color);
|
||||
|
||||
TextStyle get bold => copyWith(fontWeight: FontWeight.w800);
|
||||
|
||||
TextStyle get semibold => copyWith(fontWeight: FontWeight.w700);
|
||||
}
|
||||
19
lib/views/helpers/constants/mqtt_constants.dart
Normal file
19
lib/views/helpers/constants/mqtt_constants.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
class MqttConstants {
|
||||
static const String brokerHost = '66.116.225.226'; // Updated with VPS IP
|
||||
static const int brokerPort = 1883;
|
||||
static const String username = 'admin';
|
||||
static const String passwordString = 'Package@321#'; // Provided by user
|
||||
|
||||
// Topic Structure
|
||||
static const String topicRiderStatus = 'doormile/riders/{riderId}/status';
|
||||
static const String topicRiderProfile = 'doormile/riders/{riderId}/profile';
|
||||
static const String topicRiderLocation = 'doormile/riders/{riderId}/location';
|
||||
static const String topicRiderTelemetry =
|
||||
'doormile/riders/{riderId}/telemetry';
|
||||
static const String topicRiderLogs = 'doormile/riders/{riderId}/logs';
|
||||
|
||||
// Status Values
|
||||
static const String statusOnline = 'Online';
|
||||
static const String statusOffline = 'Offline';
|
||||
static const String statusIdle = 'Idle';
|
||||
}
|
||||
1687
lib/views/helpers/widgets/app_widgets.dart
Normal file
1687
lib/views/helpers/widgets/app_widgets.dart
Normal file
File diff suppressed because it is too large
Load Diff
386
lib/views/helpers/widgets/miler_app_bar.dart
Normal file
386
lib/views/helpers/widgets/miler_app_bar.dart
Normal 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!],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
447
lib/views/helpers/widgets/miler_map.dart
Normal file
447
lib/views/helpers/widgets/miler_map.dart
Normal 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;
|
||||
278
lib/views/helpers/widgets/page_transitions.dart
Normal file
278
lib/views/helpers/widgets/page_transitions.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
336
lib/views/helpers/widgets/settings_ui.dart
Normal file
336
lib/views/helpers/widgets/settings_ui.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user