Miler rider app: surface system, visible design language, backend lifecycle
Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,10 +2,13 @@ import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import 'miler_app_bar.dart' show milerGlassSurface;
|
||||
import 'package:miler/Models/stop_status.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_type.dart';
|
||||
|
||||
import '../constants/Colorconstants.dart';
|
||||
@@ -114,11 +117,28 @@ class PrimaryButton extends StatelessWidget {
|
||||
height: height,
|
||||
child: ElevatedButton(
|
||||
onPressed: loading ? null : onPressed,
|
||||
// ── A blocked primary goes neutral, it does not go pale ──
|
||||
//
|
||||
// This drew the brand at 50% with its label at 80% white: still a
|
||||
// saturated pill, still shaped like the thing you press, just dimmer.
|
||||
// A rider reads that as the button and taps it, and nothing happens —
|
||||
// which is the exact failure `stop_verify_chrome_test` pins on the
|
||||
// other screen that has a blocked step, where the fix was to draw no
|
||||
// coloured button at all.
|
||||
//
|
||||
// Neutral fill with the page's secondary ink says *not yet* instead of
|
||||
// *try me*, and it clears AA at 5.45:1 where white-on-washed-red did
|
||||
// not come close. `loading` keeps the brand, because a button mid-work
|
||||
// is disabled for a different reason and must not look unavailable.
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
foregroundColor: foreground,
|
||||
disabledBackgroundColor: color.withValues(alpha: 0.5),
|
||||
disabledForegroundColor: foreground.withValues(alpha: 0.8),
|
||||
disabledBackgroundColor: loading
|
||||
? color
|
||||
: ColorConstants.neutralLight,
|
||||
disabledForegroundColor: loading
|
||||
? foreground
|
||||
: ColorConstants.secondaryText,
|
||||
elevation: 0,
|
||||
minimumSize: Size(double.infinity, height),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -149,7 +169,7 @@ class PrimaryButton extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -212,7 +232,7 @@ class SecondaryButton extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: c,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -258,7 +278,7 @@ class StatusChip extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -328,7 +348,7 @@ class SectionHeader extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -344,7 +364,7 @@ class SectionHeader extends StatelessWidget {
|
||||
action!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.primary,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -404,7 +424,7 @@ class InfoBanner extends StatelessWidget {
|
||||
const InfoBanner({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.icon = Icons.info_outline_rounded,
|
||||
this.icon = LucideIcons.info,
|
||||
Color? color,
|
||||
}) : _colorOverride = color;
|
||||
|
||||
@@ -463,6 +483,15 @@ class MilerEmptyState extends StatelessWidget {
|
||||
/// different sentences — but the chrome is one thing now.
|
||||
final Widget? illustration;
|
||||
|
||||
/// Edge of the square the [illustration] is drawn in.
|
||||
///
|
||||
/// 180 is right for a screen whose empty state *is* the screen — an empty
|
||||
/// booking queue on a fresh install. It is too much for an empty state that
|
||||
/// is a label on one branch of a populated tab: on Activity the animation was
|
||||
/// the largest object in the app on the one screen with nothing to say, and
|
||||
/// it pushed the sentence answering the rider's question toward the fold.
|
||||
final double illustrationSize;
|
||||
|
||||
/// Anything that belongs under the CTA: a live-listening indicator, a debug
|
||||
/// line. Kept as a slot so a screen does not have to abandon the component to
|
||||
/// add one row.
|
||||
@@ -490,6 +519,7 @@ class MilerEmptyState extends StatelessWidget {
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
this.illustration,
|
||||
this.illustrationSize = 180,
|
||||
this.footer,
|
||||
}) : _accentOverride = accent;
|
||||
|
||||
@@ -505,36 +535,40 @@ class MilerEmptyState extends StatelessWidget {
|
||||
// sizing and the spacing below it stay the component's, so two
|
||||
// screens with different animations still read as one system.
|
||||
if (illustration != null)
|
||||
SizedBox(width: 180, height: 180, child: illustration)
|
||||
SizedBox(
|
||||
width: illustrationSize,
|
||||
height: illustrationSize,
|
||||
child: illustration,
|
||||
)
|
||||
else
|
||||
// Layered halo: a faint outer ring + a slightly stronger inner
|
||||
// disc holding the icon.
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.05),
|
||||
shape: BoxShape.circle,
|
||||
// Layered halo: a faint outer ring + a slightly stronger inner
|
||||
// disc holding the icon.
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.05),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 84,
|
||||
height: 84,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
Container(
|
||||
width: 84,
|
||||
height: 84,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 40,
|
||||
color: accent.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 40,
|
||||
color: accent.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
title,
|
||||
@@ -719,7 +753,8 @@ class _TopBarClose extends StatelessWidget {
|
||||
height: 48,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Icon(icon, size: 24, color: ColorConstants.slateText),
|
||||
// On the brand ground — see [milerPageBar].
|
||||
child: Icon(icon, size: 24, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -758,19 +793,33 @@ PreferredSizeWidget milerPageBar(
|
||||
/// presented modally. Only this gets the cross.
|
||||
bool isModal = false,
|
||||
}) {
|
||||
final IconData backIcon = isModal
|
||||
? Icons.close_rounded
|
||||
: Icons.arrow_back_rounded;
|
||||
final IconData backIcon = isModal ? LucideIcons.x : LucideIcons.arrowLeft;
|
||||
final bool hasControlRow = onBack != null || (actions?.isNotEmpty ?? false);
|
||||
|
||||
return AppBar(
|
||||
// The frosted brand wash every bar in the app shares — see
|
||||
// [milerGlassSurface]. Replaces the flat white, so a pushed page and a tab
|
||||
// read as the same surface at the top.
|
||||
backgroundColor: Colors.transparent,
|
||||
flexibleSpace: milerGlassSurface(),
|
||||
// ── One ground, every bar in the app ──
|
||||
//
|
||||
// This wore the 7% frosted brand wash flattened against white while Home
|
||||
// and the four tabs are solid brand. So pushing Edit Profile, Saved
|
||||
// Addresses, Alert Sound or Notifications changed the colour of the top of
|
||||
// the phone AND the colour of the status-bar glyphs, which reads as leaving
|
||||
// the app rather than opening a page inside it.
|
||||
//
|
||||
// Solid [ColorConstants.primary], and everything on it inverts. The wash
|
||||
// survives as `milerGlassSurface()` for the sheets, which sit over content
|
||||
// and still want it.
|
||||
backgroundColor: ColorConstants.primary,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
foregroundColor: ColorConstants.slateText,
|
||||
foregroundColor: Colors.white,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
actionsIconTheme: const IconThemeData(color: Colors.white),
|
||||
// Dark glyphs on maroon is the one contrast failure a rider cannot work
|
||||
// around by tilting the phone. Matches [MilerAppBar] and Home's header.
|
||||
systemOverlayStyle: const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarBrightness: Brightness.dark,
|
||||
),
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
// Everything lives in `title` so the control row and the heading can be
|
||||
@@ -807,7 +856,7 @@ PreferredSizeWidget milerPageBar(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MilerType.pageTitleFixed,
|
||||
style: MilerType.pageTitleFixed.copyWith(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -885,6 +934,113 @@ class _RevealState extends State<Reveal> with SingleTickerProviderStateMixin {
|
||||
Duration staggerDelay(int index, {int stepMs = 45, int maxMs = 320}) =>
|
||||
Duration(milliseconds: (index * stepMs).clamp(0, maxMs));
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// GLASS CARD — the one surface every list card in the app is drawn on.
|
||||
///
|
||||
/// A translucent pane, a soft wide shadow, and **no border**.
|
||||
///
|
||||
/// ── Why no border ──
|
||||
///
|
||||
/// A border, a fill and a shadow are three devices doing one job, and the
|
||||
/// border is the one that competes with the content: six bordered rectangles
|
||||
/// down a phone screen read as a form to be filled in, not a route to be
|
||||
/// ridden. Every large logistics app the rider already uses — Uber, Swiggy,
|
||||
/// Amazon Shopper — separates list cards with elevation alone, and it survives
|
||||
/// what a hairline does not: a scratched screen in direct sun, where a 1px
|
||||
/// #E2E8F0 line is simply not there.
|
||||
///
|
||||
/// ── Why one widget rather than a decoration constant ──
|
||||
///
|
||||
/// Two screens draw these cards (Home's route and the work tab's jobs) and they
|
||||
/// drifted apart every single time the recipe was a constant each copied. The
|
||||
/// press feedback belongs here too: a card that opens a sheet ~340ms later
|
||||
/// needs to acknowledge the tap, and that acknowledgement should not be
|
||||
/// something each caller remembers to add.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class GlassCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// Opens whatever this card is about. Null makes it a plain surface.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// What a screen reader calls the card.
|
||||
final String? semanticLabel;
|
||||
final String? semanticHint;
|
||||
|
||||
/// A wash over the pane, for the one card on a screen that is *live*.
|
||||
/// Deliberately a whisper — see [ColorConstants.glassCardLive].
|
||||
final Color? tint;
|
||||
|
||||
/// Dims the whole card without changing a single colour inside it, for a
|
||||
/// card that is no longer actionable.
|
||||
final double opacity;
|
||||
|
||||
final double radius;
|
||||
|
||||
const GlassCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.margin,
|
||||
this.padding,
|
||||
this.onTap,
|
||||
this.semanticLabel,
|
||||
this.semanticHint,
|
||||
this.tint,
|
||||
this.opacity = 1,
|
||||
this.radius = 22,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final surface = Container(
|
||||
margin: margin,
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.glassCard,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
// ── The rim is what a budget LCD can actually render ──
|
||||
//
|
||||
// On a calibrated panel the slate shadow separates the pane from the
|
||||
// ground; on the LCD this app ships to, shadows crush to nothing and
|
||||
// the cards read as printed on the page — reported twice from the
|
||||
// device. The one-pixel rim is the same trick the frosted sheets use
|
||||
// ([milerGlassSheet]'s glassRim): a line of light on the curve costs
|
||||
// nothing at any brightness and says "edge" where blur cannot.
|
||||
border: Border.all(color: ColorConstants.borderSubtle),
|
||||
boxShadow: DesignConstants.shadowGlass,
|
||||
),
|
||||
// The tint rides *inside* the pane rather than replacing it, so a live
|
||||
// card is the same glass everything else is, warmed — not a different
|
||||
// material.
|
||||
child: tint == null
|
||||
? child
|
||||
: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: tint,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
final tappable = onTap == null
|
||||
? surface
|
||||
: PressScale(
|
||||
onTap: onTap,
|
||||
// No `semanticLabel` unless the caller asks for one: PressScale
|
||||
// *excludes* its descendants' semantics when it labels itself, and
|
||||
// these cards carry buttons a reader has to be able to reach.
|
||||
semanticLabel: semanticLabel,
|
||||
semanticHint: semanticHint,
|
||||
child: surface,
|
||||
);
|
||||
|
||||
return opacity == 1 ? tappable : Opacity(opacity: opacity, child: tappable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Subtle press feedback — scales the [child] down slightly while held, then
|
||||
/// springs back. Use it to make cards and custom buttons feel tappable.
|
||||
class PressScale extends StatefulWidget {
|
||||
@@ -1068,11 +1224,21 @@ class SkeletonList extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MilerShimmer(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
for (int i = 0; i < count; i++) skeletonCard(),
|
||||
],
|
||||
// ── Clipped, not overflowed ──
|
||||
//
|
||||
// A bare `Column` of five cards is ~820pt tall, and the body of a pushed
|
||||
// page on the shortest phone the app supports is ~768. So the one screen
|
||||
// whose whole job is to say "this is coming" greeted the rider with a
|
||||
// black-and-yellow overflow band. A skeleton is a placeholder: it should
|
||||
// fill what it is given and stop, never demand a height.
|
||||
child: SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
for (int i = 0; i < count; i++) skeletonCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1243,7 +1409,7 @@ class MapPlaceholder extends StatelessWidget {
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.place_rounded,
|
||||
LucideIcons.mapPin,
|
||||
size: 30,
|
||||
color: ColorConstants.onSurfaceVariant.withValues(alpha: 0.35),
|
||||
),
|
||||
@@ -1354,7 +1520,7 @@ class MilerButton extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.1,
|
||||
color: fg,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -1505,10 +1671,7 @@ class MilerCodeBox {
|
||||
|
||||
/// The digit.
|
||||
static TextStyle digit(Color accent, {bool hasError = false}) =>
|
||||
MilerType.figure(
|
||||
20,
|
||||
color: hasError ? ColorConstants.errorRed : accent,
|
||||
);
|
||||
MilerType.figure(20, color: hasError ? ColorConstants.errorRed : accent);
|
||||
}
|
||||
|
||||
/// ───────────────────────────────────────────────────────────────────────────
|
||||
@@ -1554,7 +1717,7 @@ class AppFeedback {
|
||||
}) => _show(
|
||||
context,
|
||||
message,
|
||||
icon: Icons.error_outline_rounded,
|
||||
icon: LucideIcons.circleAlert,
|
||||
fill: ColorConstants.errorRed,
|
||||
duration: duration,
|
||||
onRetry: onRetry,
|
||||
@@ -1565,7 +1728,7 @@ class AppFeedback {
|
||||
static void success(BuildContext context, String message) => _show(
|
||||
context,
|
||||
message,
|
||||
icon: Icons.check_circle_rounded,
|
||||
icon: LucideIcons.circleCheck,
|
||||
fill: ColorConstants.acceptGreen,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
@@ -1575,7 +1738,7 @@ class AppFeedback {
|
||||
static void info(BuildContext context, String message) => _show(
|
||||
context,
|
||||
message,
|
||||
icon: Icons.info_outline_rounded,
|
||||
icon: LucideIcons.info,
|
||||
fill: ColorConstants.slateText,
|
||||
duration: const Duration(seconds: 3),
|
||||
);
|
||||
@@ -1677,7 +1840,7 @@ class ErrorRetry extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MilerEmptyState(
|
||||
icon: Icons.wifi_off_rounded,
|
||||
icon: LucideIcons.wifiOff,
|
||||
title: title,
|
||||
message: message,
|
||||
actionLabel: 'Try again',
|
||||
@@ -1685,3 +1848,182 @@ class ErrorRetry extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// WHERE THIS PARCEL CAME FROM, AND WHICH ONE IT IS
|
||||
///
|
||||
/// `[ Kitchen 1 ] Bag DG-1042`
|
||||
///
|
||||
/// Driven by the data, not by which line the rider is on: it renders whatever
|
||||
/// of the two facts the stop actually carries, and collapses to nothing when it
|
||||
/// carries neither. A parcel booking has no kitchen and no bag label to send —
|
||||
/// everything on that route came from the one hub the rider started at — so a
|
||||
/// parcel card is unchanged without the widget having to ask which tenant it is
|
||||
/// on. If a parcel payload ever does name a source, showing it is the right
|
||||
/// answer anyway.
|
||||
///
|
||||
/// On a meal run both facts earn their space. A rider part-way through a
|
||||
/// two-kitchen day is carrying two loads that look identical in the box, and
|
||||
/// the failure that costs the client money is not a missed stop — it is the
|
||||
/// right door with the wrong bag, which no OTP, photo or signature catches,
|
||||
/// because the rider is confident and the customer has no way to know. A
|
||||
/// printed label on the card he is already reading is the cheapest thing that
|
||||
/// does.
|
||||
///
|
||||
/// The source is a pill and the bag is plain text, deliberately: the pill is
|
||||
/// scannable down a list, which is what grouping needs, while the label is
|
||||
/// checked once against something physically in his hand.
|
||||
///
|
||||
/// Sized in raw logical pixels like everything else in this file — see the note
|
||||
/// on [MilerEmptyState]; the two stop cards that host it are already
|
||||
/// ScreenUtil-scaled around it.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class StopSourceLine extends StatelessWidget {
|
||||
final String source;
|
||||
final String bagLabel;
|
||||
|
||||
/// Dims both when the stop is not actionable, matching the card around it.
|
||||
final bool muted;
|
||||
|
||||
/// Space above the line. Zero when a caller has already placed it in a row of
|
||||
/// its own and is spacing it itself.
|
||||
final double topGap;
|
||||
|
||||
const StopSourceLine({
|
||||
super.key,
|
||||
required this.source,
|
||||
required this.bagLabel,
|
||||
this.muted = false,
|
||||
this.topGap = 6,
|
||||
});
|
||||
|
||||
bool get _isEmpty => source.isEmpty && bagLabel.isEmpty;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final accent = muted
|
||||
? ColorConstants.secondaryText
|
||||
: ColorConstants.serviceAccent;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: topGap),
|
||||
child: Row(
|
||||
children: [
|
||||
if (source.isNotEmpty)
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.09),
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusFull,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.store, size: 12, color: accent),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
source,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.1,
|
||||
color: accent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (source.isNotEmpty && bagLabel.isNotEmpty)
|
||||
const SizedBox(width: 8),
|
||||
if (bagLabel.isNotEmpty)
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Bag $bagLabel',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
// Tabular, because these are read as codes and a column of
|
||||
// them down a list should line up.
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// WHERE THIS ORDER HAS GOT TO
|
||||
///
|
||||
/// `⏱ Assigned` · `✓ Accepted` · `🚲 In progress` · `✔ Completed`
|
||||
///
|
||||
/// One tag, on every card that shows an order, on every screen. The status
|
||||
/// vocabulary already lives in one place — [StopStatusX.label] — and this is
|
||||
/// the matching one place for how it looks, so Home, Bookings and Activity
|
||||
/// cannot end up drawing the same backend state three different ways, which is
|
||||
/// exactly what happened to the words before they were centralised.
|
||||
///
|
||||
/// Icon **and** colour, never colour alone: these are read outdoors, in
|
||||
/// sunlight, on a scratched screen, by somebody who is in a hurry.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class OrderStatusTag extends StatelessWidget {
|
||||
final StopStatus status;
|
||||
|
||||
/// Dims it to match a card that is not actionable.
|
||||
final bool muted;
|
||||
|
||||
/// Sentence-case label. Sizing is raw logical pixels, like the rest of this
|
||||
/// file — the cards hosting it are ScreenUtil-scaled around it.
|
||||
const OrderStatusTag({super.key, required this.status, this.muted = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = muted ? ColorConstants.secondaryText : status.color;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(status.icon, size: 12, color: color),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
status.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.1,
|
||||
color: color,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
193
lib/views/helpers/widgets/load_state_view.dart
Normal file
193
lib/views/helpers/widgets/load_state_view.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import 'package:miler/data/load_state.dart';
|
||||
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';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// ONE ANSWER PER STATE, WRITTEN ONCE
|
||||
///
|
||||
/// Every screen rendered its own version of "nothing here": Home had a trip
|
||||
/// empty state, Bookings had a tick in a circle, Activity had a Lottie, and
|
||||
/// each of them decided independently what a *failure* looked like — usually by
|
||||
/// rendering the empty one, which tells a rider to wait for work that is
|
||||
/// actually a dead request.
|
||||
///
|
||||
/// This is the mapping from [LoadState] to what the rider sees, in one place,
|
||||
/// so the four screens cannot disagree about what a 429 means.
|
||||
///
|
||||
/// ── The distinction that matters ──
|
||||
///
|
||||
/// [LoadUnavailable] is not an error and not an empty day. The rider's line of
|
||||
/// work has no endpoint behind it, so neither waiting nor retrying will change
|
||||
/// anything — and offering him a Retry button would be a lie told once per tap.
|
||||
/// It gets its own state, with no retry, saying what is actually true.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class LoadStateView<T> extends StatelessWidget {
|
||||
final LoadState<T> state;
|
||||
|
||||
/// Drawn when there is data. Receives the value and whether a refresh is
|
||||
/// running behind it — a pull-to-refresh must not blank its own list.
|
||||
final Widget Function(T value, bool refreshing) builder;
|
||||
|
||||
/// What "nothing today" looks like on this screen. Screens keep their own,
|
||||
/// because an empty route and an empty history are different sentences.
|
||||
final Widget empty;
|
||||
|
||||
/// Re-runs the load. Null on screens with nothing to retry with.
|
||||
final Future<void> Function()? onRetry;
|
||||
|
||||
/// Shown instead of a spinner on first load, where a screen has a skeleton
|
||||
/// that matches its real layout.
|
||||
final Widget? loading;
|
||||
|
||||
const LoadStateView({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.builder,
|
||||
required this.empty,
|
||||
this.onRetry,
|
||||
this.loading,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => switch (state) {
|
||||
LoadLoading<T>() =>
|
||||
loading ??
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 48.h),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
LoadData<T>(:final value, :final refreshing) => builder(value, refreshing),
|
||||
LoadEmpty<T>() => empty,
|
||||
LoadUnavailable<T>(:final reason) => _Message(
|
||||
icon: LucideIcons.cloudOff,
|
||||
tint: ColorConstants.secondaryText,
|
||||
title: 'Not available yet',
|
||||
body: reason,
|
||||
// Deliberately no retry. Nothing the rider does changes this, and a
|
||||
// button that cannot work is worse than no button.
|
||||
onRetry: null,
|
||||
),
|
||||
LoadFailure<T>(:final kind, :final message) => _Message(
|
||||
icon: switch (kind) {
|
||||
LoadFailureKind.offline => LucideIcons.wifiOff,
|
||||
LoadFailureKind.unauthorized => LucideIcons.lock,
|
||||
LoadFailureKind.rateLimited => LucideIcons.hourglass,
|
||||
LoadFailureKind.server => LucideIcons.circleAlert,
|
||||
},
|
||||
tint: kind == LoadFailureKind.offline
|
||||
? ColorConstants.secondaryText
|
||||
: ColorConstants.warning,
|
||||
title: switch (kind) {
|
||||
LoadFailureKind.offline => 'No connection',
|
||||
LoadFailureKind.unauthorized => 'Signed out',
|
||||
LoadFailureKind.rateLimited => 'Too many requests',
|
||||
LoadFailureKind.server => "Couldn't load your work",
|
||||
},
|
||||
body: switch (kind) {
|
||||
LoadFailureKind.offline =>
|
||||
'Your work will appear as soon as you are back online.',
|
||||
LoadFailureKind.unauthorized =>
|
||||
'Your session ended. Sign in again to carry on.',
|
||||
LoadFailureKind.rateLimited => 'Give it a moment and try again.',
|
||||
// The server's own sentence where it gave one — it is more useful than
|
||||
// anything this app can invent about a failure it did not cause.
|
||||
LoadFailureKind.server =>
|
||||
message.isEmpty ? 'The hub could not be reached.' : message,
|
||||
},
|
||||
onRetry: kind.isRetryable ? onRetry : null,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// A glyph, a sentence and — only where it can help — one action.
|
||||
class _Message extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color tint;
|
||||
final String title;
|
||||
final String body;
|
||||
final Future<void> Function()? onRetry;
|
||||
|
||||
const _Message({
|
||||
required this.icon,
|
||||
required this.tint,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(32.w, 44.h, 32.w, 32.h),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 60.w,
|
||||
height: 60.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: tint.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 27.sp, color: tint),
|
||||
),
|
||||
SizedBox(height: 18.h),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 17.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
body,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
height: 1.45,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
SizedBox(height: 20.h),
|
||||
SizedBox(
|
||||
height: ButtonSizes.secondary,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: Icon(LucideIcons.rotateCw, size: 18.sp),
|
||||
label: Text(
|
||||
'Try again',
|
||||
style: TextStyle(
|
||||
fontSize: 14.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: ColorConstants.primary,
|
||||
side: BorderSide(color: ColorConstants.borderStrong),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(ButtonSizes.radius),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,13 @@ import 'dart:io';
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter/services.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_surface.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';
|
||||
@@ -180,7 +183,15 @@ class MilerAvatar extends StatelessWidget {
|
||||
/// the controller and callers that do not can leave it off.
|
||||
final String? name;
|
||||
|
||||
const MilerAvatar({super.key, this.name});
|
||||
/// Drawn against the brand ground rather than against a surface.
|
||||
///
|
||||
/// The fallback circle is brand-filled with a white letter, which is exactly
|
||||
/// invisible on Home's branded header. On brand the fill and the letter swap —
|
||||
/// a white disc carrying a maroon initial — and a photo picks up a white ring
|
||||
/// so it reads as a portrait on the ground rather than a hole cut in it.
|
||||
final bool onBrand;
|
||||
|
||||
const MilerAvatar({super.key, this.name, this.onBrand = false});
|
||||
|
||||
/// Matches [MilerBrandMark._boxSize] — this widget takes over that slot, and
|
||||
/// the bar's height is built around it.
|
||||
@@ -231,10 +242,18 @@ class MilerAvatar extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
color: photo != null
|
||||
? ColorConstants.neutralLight
|
||||
: onBrand
|
||||
? ColorConstants.onAccent
|
||||
: ColorConstants.primary,
|
||||
image: photo != null
|
||||
? DecorationImage(image: FileImage(photo), fit: BoxFit.cover)
|
||||
: null,
|
||||
border: onBrand && photo != null
|
||||
? Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.35),
|
||||
width: 1.5,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: photo != null
|
||||
@@ -245,9 +264,11 @@ class MilerAvatar extends StatelessWidget {
|
||||
// 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,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1,
|
||||
color: Colors.white,
|
||||
color: onBrand
|
||||
? ColorConstants.primary
|
||||
: ColorConstants.onAccent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -321,8 +342,31 @@ class MilerAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
flexibleSpace: milerGlassSurface(),
|
||||
// ── One bar, one ground, across every tab ──
|
||||
//
|
||||
// The four tabs wore a 7% frosted brand wash flattened against white,
|
||||
// while Home wore solid brand. That is two chromes in one app: switching
|
||||
// from Home to Bookings changed the colour of the top 76pt of the phone
|
||||
// and the colour of the status-bar glyphs with it, which reads as
|
||||
// arriving in a different application rather than a different page.
|
||||
//
|
||||
// Solid [ColorConstants.primary] everywhere, and everything on it
|
||||
// inverts — title, back arrow, trailing controls, status-bar glyphs.
|
||||
// White on #960019 clears AA at every size used here.
|
||||
backgroundColor: ColorConstants.primary,
|
||||
// The status bar belongs to the bar under it. Dark glyphs on maroon is
|
||||
// the one contrast failure a rider cannot work around by tilting the
|
||||
// phone. Matches the value Home's own header sets.
|
||||
systemOverlayStyle: const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarBrightness: Brightness.dark,
|
||||
),
|
||||
// Everything drawn inside inherits white, so a trailing control does not
|
||||
// have to be told twice.
|
||||
foregroundColor: Colors.white,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
actionsIconTheme: const IconThemeData(color: Colors.white),
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: false,
|
||||
@@ -344,15 +388,17 @@ class MilerAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
label: 'Back',
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.of(context).maybePop(),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
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,
|
||||
LucideIcons.arrowLeft,
|
||||
size: 22.sp,
|
||||
color: ColorConstants.slateText,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -372,7 +418,9 @@ class MilerAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MilerType.pageTitleFixed,
|
||||
style: MilerType.pageTitleFixed.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -384,3 +432,76 @@ class MilerAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's content, as a sheet tucked under the brand header.
|
||||
///
|
||||
/// ── Why the corners ──
|
||||
///
|
||||
/// [MilerAppBar] paints a solid maroon band and the page began flush under it,
|
||||
/// so the two met on a hard horizontal line the full width of the phone. That
|
||||
/// line is what made every tab read as *a coloured strip above a form*: the bar
|
||||
/// looked bolted on rather than like the top of the screen the content lives
|
||||
/// in.
|
||||
///
|
||||
/// Rounding the content's top corners and letting the brand show through behind
|
||||
/// them turns the two into one object — a header the page is *inside*. It is
|
||||
/// the shape every consumer app the rider uses has settled on, and it costs one
|
||||
/// clip and a colour behind it.
|
||||
///
|
||||
/// ── How to use it ──
|
||||
///
|
||||
/// The scaffold's own background becomes [ColorConstants.primary] so the brand
|
||||
/// is what shows in the two corners; this widget paints the real page colour
|
||||
/// and clips to it. Nothing inside needs to know.
|
||||
///
|
||||
/// ```dart
|
||||
/// Scaffold(
|
||||
/// backgroundColor: ColorConstants.primary,
|
||||
/// appBar: const MilerAppBar(title: 'Activity'),
|
||||
/// body: MilerSheet(child: …),
|
||||
/// )
|
||||
/// ```
|
||||
///
|
||||
/// **Clip, not just a rounded box.** The child is a scrolling list on three of
|
||||
/// the four tabs, and a `BoxDecoration` alone would let rows paint over the
|
||||
/// corners on the first pixel of a scroll — which reads as the radius flicking
|
||||
/// off and on.
|
||||
class MilerSheet extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
/// The page colour under the clip. Defaults to the app's paper.
|
||||
final Color? color;
|
||||
|
||||
const MilerSheet({super.key, required this.child, this.color});
|
||||
|
||||
/// Big enough to read as a shape rather than as a softened edge, small enough
|
||||
/// that a list's first row does not look pushed in. Matches the sheet radius
|
||||
/// the bottom sheets already use.
|
||||
static const double radius = 22;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(radius.r)),
|
||||
child: ColoredBox(
|
||||
// ── Layer 1: this IS the working surface ──
|
||||
//
|
||||
// Tried as the canvas and reverted, with the reason recorded because
|
||||
// it is the trap in this whole exercise: Home, Deliveries and Activity
|
||||
// deliberately have **no cards** — the route is a spine and the rows
|
||||
// sit directly on the page. Painting that page with the canvas tone
|
||||
// therefore produced a uniformly grey screen with nothing on it to
|
||||
// separate *from*, which is the same flatness in a different colour.
|
||||
//
|
||||
// A darker ground only helps where something white actually stands on
|
||||
// it: the Deliveries NOW card, the record page's folded groups, every
|
||||
// modal sheet. Those all sit on [MilerSurface.canvas] and now separate
|
||||
// at 1.290:1 instead of 1.124.
|
||||
//
|
||||
// So this stays white and *is* the working layer. See [MilerSurface].
|
||||
color: color ?? MilerSurface.working,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
@@ -148,7 +149,7 @@ class _MilerAttribution extends StatelessWidget {
|
||||
'© OpenStreetMap',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -338,13 +339,13 @@ class MilerNumberPin extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
child: done
|
||||
? const Icon(Icons.check_rounded, size: 16, color: Colors.white)
|
||||
? const Icon(LucideIcons.check, size: 16, color: Colors.white)
|
||||
: Text(
|
||||
'$number',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -445,3 +446,148 @@ double milerDistanceMetres(LatLng a, LatLng b) {
|
||||
}
|
||||
|
||||
double _rad(double deg) => deg * math.pi / 180.0;
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// ONE LEG, DRAWN HONESTLY
|
||||
///
|
||||
/// The small orientation maps on the stop detail and pickup preview sheets
|
||||
/// each hand-rolled the same picture and both told the same two lies:
|
||||
///
|
||||
/// • **A straight line pretending to be the ride.** The polyline was just
|
||||
/// `[rider, stop]`, drawn solid — geometry no road follows, in the same
|
||||
/// stroke the real map screen uses for an actual route.
|
||||
/// • **A journey with one end.** The camera parked on the destination at a
|
||||
/// fixed zoom, so a rider 4 km out watched the line exit the frame into
|
||||
/// nothing — a pin, a stroke, and no second point anywhere on screen.
|
||||
///
|
||||
/// This widget is that picture done once, properly. It asks OSRM for the road
|
||||
/// (same [MilerRouter] the map screen trusts), fits the camera to BOTH ends
|
||||
/// with room to breathe, and always draws both endpoints — the destination
|
||||
/// pin and the rider's dot. While the route is in flight, or when the router
|
||||
/// is unreachable, the leg shows as a DASHED direct line: dashes are the map
|
||||
/// vocabulary for "as the crow flies", so the fallback stops impersonating a
|
||||
/// road. Non-interactive by design — orientation, not navigation.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class MilerLegMap extends StatefulWidget {
|
||||
/// The destination — the one end a leg always has.
|
||||
final LatLng to;
|
||||
|
||||
/// Where the rider is. Null draws the destination alone, centred.
|
||||
final LatLng? from;
|
||||
|
||||
/// The destination pin's accent and glyph — pass the stop's own.
|
||||
final Color accent;
|
||||
final IconData icon;
|
||||
|
||||
const MilerLegMap({
|
||||
super.key,
|
||||
required this.to,
|
||||
required this.accent,
|
||||
required this.icon,
|
||||
this.from,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MilerLegMap> createState() => _MilerLegMapState();
|
||||
}
|
||||
|
||||
class _MilerLegMapState extends State<MilerLegMap> {
|
||||
final MapController _controller = MapController();
|
||||
List<LatLng> _road = const [];
|
||||
bool _ready = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchRoad();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MilerLegMap old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.from != widget.from || old.to != widget.to) {
|
||||
_road = const [];
|
||||
_fetchRoad();
|
||||
_fit();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchRoad() async {
|
||||
final from = widget.from;
|
||||
if (from == null) return;
|
||||
final road = await MilerRouter.route(from, widget.to);
|
||||
if (!mounted || road.isEmpty) return;
|
||||
setState(() => _road = road);
|
||||
// The road can bulge outside the straight-line bounds; refit around it.
|
||||
_fit();
|
||||
}
|
||||
|
||||
void _fit() {
|
||||
if (!_ready) return;
|
||||
final from = widget.from;
|
||||
if (from == null) return;
|
||||
final points = _road.isNotEmpty ? _road : [from, widget.to];
|
||||
_controller.fitCamera(
|
||||
CameraFit.coordinates(
|
||||
coordinates: points,
|
||||
padding: const EdgeInsets.fromLTRB(36, 44, 36, 30),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final from = widget.from;
|
||||
|
||||
return IgnorePointer(
|
||||
child: MilerMap(
|
||||
controller: _controller,
|
||||
initialCenter: from == null
|
||||
? widget.to
|
||||
: LatLng(
|
||||
(widget.to.latitude + from.latitude) / 2,
|
||||
(widget.to.longitude + from.longitude) / 2,
|
||||
),
|
||||
initialZoom: 13,
|
||||
onReady: () {
|
||||
_ready = true;
|
||||
// After layout, when the viewport has a size to fit within.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _fit());
|
||||
},
|
||||
polylines: [
|
||||
if (from != null)
|
||||
Polyline(
|
||||
points: _road.isNotEmpty ? _road : [from, widget.to],
|
||||
color: widget.accent,
|
||||
strokeWidth: 4,
|
||||
strokeCap: StrokeCap.round,
|
||||
strokeJoin: StrokeJoin.round,
|
||||
// Dashed while it is a bearing, solid once it is a road.
|
||||
pattern: _road.isNotEmpty
|
||||
? const StrokePattern.solid()
|
||||
: StrokePattern.dashed(segments: const [14, 10]),
|
||||
),
|
||||
],
|
||||
markers: [
|
||||
milerMarker(
|
||||
point: widget.to,
|
||||
width: MilerPin.size + 8,
|
||||
height: MilerPin.size + MilerPin.tail,
|
||||
child: MilerPin(
|
||||
color: widget.accent,
|
||||
icon: widget.icon,
|
||||
emphasised: true,
|
||||
),
|
||||
),
|
||||
if (from != null)
|
||||
milerMarker(
|
||||
point: from,
|
||||
width: MilerRiderDot.size,
|
||||
height: MilerRiderDot.size,
|
||||
child: const MilerRiderDot(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
378
lib/views/helpers/widgets/miler_sheet_kit.dart
Normal file
378
lib/views/helpers/widgets/miler_sheet_kit.dart
Normal file
@@ -0,0 +1,378 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/widgets/miler_app_bar.dart'
|
||||
show milerGlassSheet;
|
||||
import 'package:miler/views/helpers/widgets/page_transitions.dart'
|
||||
show kMilerSheetStyle, kMilerLargeSheetStyle;
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE SHEET KIT — one way to present a modal sheet, one structure inside it
|
||||
///
|
||||
/// ── What this replaced ──
|
||||
///
|
||||
/// An audit of every `showModalBottomSheet` in `lib/` (outside reference-only
|
||||
/// `lib/xpress`) found six presentation styles for one kind of surface:
|
||||
///
|
||||
/// • frosted glass, radius 28 — duty, action, skip, pickup, map
|
||||
/// • opaque `pureSurface`, radius 24 — stop detail, pickup preview
|
||||
/// • default white, radius 16, and NO
|
||||
/// `isScrollControlled` — the reject / cancel sheets, whose
|
||||
/// reason field the keyboard covered
|
||||
/// • an ad-hoc Container with its own
|
||||
/// shadow and a 50pt top margin — the trip-map sheet
|
||||
/// • `Get.bottomSheet`, flat 16 — every auth error
|
||||
/// • a floating card with margins — the payment confirm
|
||||
///
|
||||
/// Each also drew its own drag handle — 40×4 in `borderSubtle` in four files,
|
||||
/// which on frosted glass is close to invisible, and 56×4 in a fifth — and
|
||||
/// applied `SafeArea` its own way. A rider crossing one shift met most of
|
||||
/// these, which is precisely what makes an app feel assembled rather than
|
||||
/// designed: the same gesture arriving on different-looking surfaces.
|
||||
///
|
||||
/// The material itself was never the problem — [milerGlassSheet] is the app's
|
||||
/// committed sheet fabric and stays exactly as it is. What was missing is the
|
||||
/// layer above it: one presenter that owns the route configuration, and one
|
||||
/// scaffold that owns handle + padding + keyboard + gesture inset, so a sheet
|
||||
/// author writes content and nothing else.
|
||||
///
|
||||
/// ── The rules the kit enforces ──
|
||||
///
|
||||
/// • **Glass, always.** `backgroundColor: transparent` on the route; the
|
||||
/// scaffold paints the fabric. No caller re-declares a shape.
|
||||
/// • **One handle.** 40×4, `borderStrong` — `borderSubtle` at 7% glass over a
|
||||
/// dimmed page measures under 1.15:1 against its own ground.
|
||||
/// • **One inset rule.** Bottom padding is `max(keyboard, gesture bar)` plus
|
||||
/// the content gap — the keyboard replaces the gesture bar, so summing them
|
||||
/// (the double-SafeArea bug this app has already had once) pushes the CTA a
|
||||
/// bar's height above the keyboard.
|
||||
/// • **Scroll-controlled, always.** A sheet that cannot grow past half height
|
||||
/// is a sheet whose text field the keyboard covers.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Presents [builder]'s widget as a Miler modal sheet.
|
||||
///
|
||||
/// The one place `showModalBottomSheet` is configured. [large] picks the
|
||||
/// slower entrance tuned for sheets that cover most of the screen — see
|
||||
/// [kMilerLargeSheetStyle].
|
||||
Future<T?> showMilerSheet<T>(
|
||||
BuildContext context, {
|
||||
required WidgetBuilder builder,
|
||||
bool large = false,
|
||||
bool isDismissible = true,
|
||||
bool enableDrag = true,
|
||||
}) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
sheetAnimationStyle: large ? kMilerLargeSheetStyle : kMilerSheetStyle,
|
||||
isScrollControlled: true,
|
||||
// The scaffold paints the glass; an opaque route background would show as
|
||||
// a square white corner behind the rounded one.
|
||||
backgroundColor: Colors.transparent,
|
||||
// ── The scrim is the separation; the shadow is not ──
|
||||
//
|
||||
// Layer 3 sits over everything, and what tells the rider it is *over* is
|
||||
// the page going dark behind it. This was Flutter's default `black54`,
|
||||
// which is a Material number rather than a Miler one — and on the frosted
|
||||
// fabric this app uses, a lighter dim leaves the sheet's own translucency
|
||||
// reading as part of the page beneath it.
|
||||
//
|
||||
// [MilerSurface.scrim] is the app's, stated once, so every sheet dims the
|
||||
// same amount. A sheet that needs a heavier shadow to be legible is a
|
||||
// sheet whose scrim is too weak — this is the knob, not the shadow.
|
||||
barrierColor: MilerSurface.scrim,
|
||||
isDismissible: isDismissible,
|
||||
enableDrag: enableDrag,
|
||||
builder: builder,
|
||||
);
|
||||
}
|
||||
|
||||
/// The structure every sheet shares: glass fabric, drag handle, content
|
||||
/// padding, and the bottom inset done once.
|
||||
///
|
||||
/// The body is not scrolled for you — a short confirm sheet has nothing to
|
||||
/// scroll and a tall one owns its own `ListView` — but the insets are: the
|
||||
/// scaffold reads the keyboard and the gesture bar so its child never has to
|
||||
/// mention either.
|
||||
class MilerSheetScaffold extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
/// Hide only for a sheet that must not be casually flicked away.
|
||||
final bool handle;
|
||||
|
||||
/// Horizontal content inset. 20 matches the page gutter the sheet rises
|
||||
/// over; the two sheets that need edge-to-edge content (a map, a full-bleed
|
||||
/// list) pass their own.
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
const MilerSheetScaffold({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.handle = true,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final media = MediaQuery.of(context);
|
||||
// The keyboard replaces the gesture bar; whichever is up pays the inset.
|
||||
// Adding them — the double-SafeArea mistake — floats the CTA a gesture
|
||||
// bar's height above the keyboard.
|
||||
final double bottomInset = math.max(
|
||||
media.viewInsets.bottom,
|
||||
media.viewPadding.bottom,
|
||||
);
|
||||
|
||||
return milerGlassSheet(
|
||||
child: AnimatedPadding(
|
||||
// The keyboard animates in; a sheet that jumps its full keyboard
|
||||
// height in one frame reads as a glitch, not a response.
|
||||
duration: DesignConstants.motionState,
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: EdgeInsets.only(bottom: bottomInset),
|
||||
child: Padding(
|
||||
padding: padding ?? EdgeInsets.fromLTRB(20.w, 0, 20.w, 18.h),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (handle) const MilerSheetHandle() else SizedBox(height: 14.h),
|
||||
Flexible(child: child),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The drag handle: the one affordance saying "this surface moves".
|
||||
///
|
||||
/// `borderStrong`, not `borderSubtle` — four sheets drew the subtle grey on
|
||||
/// frosted glass, where it is effectively invisible, so the sheets most worth
|
||||
/// flicking away looked least like they could be.
|
||||
class MilerSheetHandle extends StatelessWidget {
|
||||
const MilerSheetHandle({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 40.w,
|
||||
height: 4.h,
|
||||
margin: EdgeInsets.only(top: 10.h, bottom: 14.h),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.borderStrong,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A sheet's opening statement: what this is, and the one line under it.
|
||||
///
|
||||
/// The leading badge is optional and tinted, never filled — a filled disc at
|
||||
/// this size outweighs the title it introduces. Pass the accent that the
|
||||
/// sheet's subject already wears elsewhere (the duty green, the brand red, a
|
||||
/// stop-kind accent) so the sheet is visibly about the thing that opened it.
|
||||
class MilerSheetHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final IconData? icon;
|
||||
final Color? tint;
|
||||
|
||||
const MilerSheetHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.icon,
|
||||
this.tint,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = tint ?? ColorConstants.primary;
|
||||
|
||||
final text = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.45,
|
||||
height: 1.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
if (subtitle != null && subtitle!.trim().isNotEmpty) ...[
|
||||
SizedBox(height: 3.h),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.35,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
if (icon == null) return text;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 42.w,
|
||||
height: 42.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Icon(icon, size: 21.sp, color: accent),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(child: text),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ── One answer in a reason list ──
|
||||
///
|
||||
/// Update Status, Skip and Reject all ask the same shape of question — "why?",
|
||||
/// a handful of answers, pick one — and drew it three different ways: stacks
|
||||
/// of outlined white cards with each icon inside a second box, and (on
|
||||
/// Reject) solid-primary slabs that outshouted the destructive CTA under
|
||||
/// them.
|
||||
///
|
||||
/// A hairline separates, the tick answers, and selection is carried by a soft
|
||||
/// fill rather than a thicker outline or a full-strength slab. A fill is what
|
||||
/// survives sunlight; a 1.5px border is what does not — see
|
||||
/// [ColorConstants.cardSurface]. Promoted here from the pickups library's
|
||||
/// private `_ReasonRow` the day the Reject sheet became its third caller.
|
||||
class MilerSheetChoiceRow extends StatelessWidget {
|
||||
final String label;
|
||||
|
||||
/// Optional leading glyph. A list whose answers have no natural icons reads
|
||||
/// fine without any — the tick still says which one is chosen.
|
||||
final IconData? icon;
|
||||
|
||||
final Color accent;
|
||||
final bool selected;
|
||||
|
||||
/// The hairline above this row. Suppressed either side of the selected row
|
||||
/// so it does not cut across the tint and turn the one chosen answer back
|
||||
/// into a box.
|
||||
final bool showRule;
|
||||
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const MilerSheetChoiceRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
this.icon,
|
||||
this.accent = ColorConstants.primary,
|
||||
this.showRule = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
if (showRule)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: icon == null ? 0 : 33.w,
|
||||
color: ColorConstants.borderSubtle,
|
||||
),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
child: AnimatedContainer(
|
||||
duration: DesignConstants.motionState,
|
||||
padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 11.w),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
color: selected
|
||||
? accent.withValues(alpha: 0.10)
|
||||
: Colors.transparent,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: 19.sp,
|
||||
color: selected ? accent : ColorConstants.secondaryText,
|
||||
),
|
||||
SizedBox(width: 13.w),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14.5.sp,
|
||||
color: selected ? accent : ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontWeight: selected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
// The tick appears rather than an empty ring sitting on
|
||||
// every row: hollow circles read as more things to deal
|
||||
// with, when only one of them is ever the answer.
|
||||
AnimatedScale(
|
||||
duration: DesignConstants.motionState,
|
||||
curve: Curves.easeOutBack,
|
||||
scale: selected ? 1 : 0.6,
|
||||
child: AnimatedOpacity(
|
||||
duration: DesignConstants.motionState,
|
||||
opacity: selected ? 1 : 0,
|
||||
child: Container(
|
||||
width: 22.w,
|
||||
height: 22.w,
|
||||
decoration: BoxDecoration(
|
||||
color: accent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.check,
|
||||
color: Colors.white,
|
||||
size: 14.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -257,7 +257,22 @@ Future<T?> showAppDialog<T>({
|
||||
barrierColor: barrierColor,
|
||||
useRootNavigator: useRootNavigator,
|
||||
transitionDuration: const Duration(milliseconds: 260),
|
||||
pageBuilder: (context, _, _) => Builder(builder: builder),
|
||||
// ── Every dialog gets a Material, whether its caller remembered one ──
|
||||
//
|
||||
// `showGeneralDialog` hands the builder a bare route with no Material in
|
||||
// scope, unlike `showDialog`, which wraps its child in one. A caller that
|
||||
// returns a `Container` with a `Text` in it — which several do — therefore
|
||||
// painted Flutter's **missing-Material debug marker**: the yellow double
|
||||
// underline under every word. It looked like a styling bug in the dialog
|
||||
// and was actually the framework saying "there is no Material here".
|
||||
//
|
||||
// `MaterialType.transparency` adds the ancestor and paints nothing, so a
|
||||
// dialog that draws its own surface is unchanged and one that forgot stops
|
||||
// being underlined.
|
||||
pageBuilder: (context, _, _) => Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Builder(builder: builder),
|
||||
),
|
||||
transitionBuilder: (context, animation, _, child) {
|
||||
// `drive` rather than a `CurvedAnimation`: this builder runs every frame
|
||||
// of the transition, and a CurvedAnimation built here would be a new
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.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/constants/miler_type.dart';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
|
||||
@@ -38,17 +39,18 @@ class SettingsCard extends StatelessWidget {
|
||||
|
||||
const SettingsCard({super.key, required this.child, this.padding});
|
||||
|
||||
/// ── No outline ──
|
||||
///
|
||||
/// The card carried a hairline border *and* a shadow, which is two
|
||||
/// separations doing one job: a lift already says "this is in front of the
|
||||
/// page", and the line drawn round it only added weight. Every surface in the
|
||||
/// app that a rider scrolls past — the stop cards on Home, the job cards on
|
||||
/// Deliveries — is a borderless fill under [DesignConstants.shadowGlass], and
|
||||
/// this is now the same object in the section's own white.
|
||||
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),
|
||||
),
|
||||
],
|
||||
boxShadow: DesignConstants.shadowGlass,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -169,7 +171,15 @@ class SettingsRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color fg = danger ? ColorConstants.primary : ColorConstants.onSurface;
|
||||
// ── Destructive is red, not brand red ──
|
||||
//
|
||||
// The rule the rest of the app already follows: green advances the job at a
|
||||
// door, brand maroon is navigation and identity, and red destroys. Logout
|
||||
// was drawn in the brand colour, which made the one row that ends the
|
||||
// session the same colour as the logo above it.
|
||||
final Color fg = danger
|
||||
? ColorConstants.errorRed
|
||||
: ColorConstants.onSurface;
|
||||
|
||||
return PressScale(
|
||||
onTap: onTap,
|
||||
@@ -178,52 +188,135 @@ class SettingsRow extends StatelessWidget {
|
||||
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: ConstrainedBox(
|
||||
// A row is the tap target, so the row is never shorter than one.
|
||||
constraints: BoxConstraints(minHeight: ButtonSizes.secondary.h),
|
||||
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: 11.h),
|
||||
child: Row(
|
||||
children: [
|
||||
// ── The icon does not outrank the title ──
|
||||
//
|
||||
// Every glyph on the page was drawn in `onSurface`, the same
|
||||
// near-black as the words beside it, so a column of twelve icons
|
||||
// read as loudly as the twelve things they were labelling. They
|
||||
// are a column of markers, not the content; the title leads and
|
||||
// the icon locates it.
|
||||
Icon(
|
||||
icon,
|
||||
color:
|
||||
iconColor ?? (danger ? fg : ColorConstants.secondaryText),
|
||||
size: 21.sp,
|
||||
),
|
||||
SizedBox(width: 14.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: MilerType.body.on(fg)),
|
||||
if (subtitle != null && subtitle!.trim().isNotEmpty) ...[
|
||||
SizedBox(height: 2.h),
|
||||
Text(
|
||||
subtitle!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: MilerType.label,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
SizedBox(width: 10.w),
|
||||
trailing!,
|
||||
] else if (!danger) ...[
|
||||
SizedBox(width: 10.w),
|
||||
// An affordance, not a mark. At `onSurfaceVariant` it was as
|
||||
// dark as the subtitle it sat beside; it only has to say the
|
||||
// row goes somewhere.
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
color: ColorConstants.borderStrong,
|
||||
size: 20.sp,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE ONE CONTROL IN THIS SECTION THAT IS EVER URGENT
|
||||
///
|
||||
/// `⚠ Emergency help 📞`
|
||||
/// Calls 112 if you are in immediate danger
|
||||
///
|
||||
/// ── A wash, not an outline ──
|
||||
///
|
||||
/// Account and Help & Support each drew this as an `InfoBanner`: an 8% brand
|
||||
/// fill inside a 16% brand *border*, with the whole sentence set in red. Three
|
||||
/// ways of saying one thing — and, once the section's cards lost their edges,
|
||||
/// the only outlined object left on either page. The loudest element on a
|
||||
/// rider's account was a notice rather than anything he owns.
|
||||
///
|
||||
/// The wash stays, because this row genuinely is not a preference. The border
|
||||
/// goes, the words go back to the page's near-black so they can be read at a
|
||||
/// glance rather than decoded, and the red is spent on the two marks that
|
||||
/// carry the meaning: the warning glyph and the handset.
|
||||
///
|
||||
/// One widget rather than two copies, because the two pages had already
|
||||
/// drifted apart on the copy — and the sentence that tells a rider what will
|
||||
/// happen when he presses it is not a thing to have two versions of.
|
||||
class EmergencyRow extends StatelessWidget {
|
||||
/// The number that will be dialled. Named in the subtitle, so the rider can
|
||||
/// see it before he presses and dial it himself if the handset refuses.
|
||||
final String number;
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
const EmergencyRow({super.key, required this.number, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PressScale(
|
||||
onTap: onTap,
|
||||
semanticLabel: 'Emergency. Calls $number',
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.primary.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: iconColor ?? fg, size: 22.sp),
|
||||
Icon(
|
||||
LucideIcons.triangleAlert,
|
||||
color: ColorConstants.primary,
|
||||
size: 22.sp,
|
||||
),
|
||||
SizedBox(width: 14.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Emergency help', style: MilerType.body.bold),
|
||||
SizedBox(height: 2.h),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: fg,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
'Calls $number if you are in immediate danger',
|
||||
maxLines: 2,
|
||||
style: MilerType.micro,
|
||||
),
|
||||
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,
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Icon(LucideIcons.phone, color: ColorConstants.primary, size: 20.sp),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -239,17 +332,21 @@ class SettingsLabel extends StatelessWidget {
|
||||
@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,
|
||||
),
|
||||
),
|
||||
// ── Aligned to the titles, not to the icons ──
|
||||
//
|
||||
// The label used to start on the page gutter, level with the row icons
|
||||
// below it. Text-to-glyph alignment is optical, never exact: an `Icon`
|
||||
// does not paint flush to the left of its box, so an 11sp heading and a
|
||||
// 21sp glyph starting at the same x read as eight points out of true —
|
||||
// and a column of three headings all eight points out is what makes a
|
||||
// settings page look assembled rather than laid out.
|
||||
//
|
||||
// Text-to-text is exact. The heading sits over the words it heads, and
|
||||
// the icons become a clean margin rail to its left — the arrangement iOS
|
||||
// Settings has used since it had groups. The inset is the row's own icon
|
||||
// box plus its gap, read from the same numbers [SettingsRow] uses.
|
||||
padding: EdgeInsetsDirectional.only(start: 21.sp + 14.w, bottom: 6.h),
|
||||
child: Text(text.toUpperCase(), style: MilerType.eyebrow),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -263,16 +360,8 @@ class SettingsHint extends StatelessWidget {
|
||||
@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,
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(2.w, 10.h, 2.w, 0),
|
||||
child: Text(text, style: MilerType.micro.copyWith(height: 1.5)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user