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:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

View File

@@ -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,
),
),
),
],
),
);
}
}

View 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),
),
),
),
),
],
],
),
);
}
}

View File

@@ -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,
),
);
}
}

View File

@@ -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(),
),
],
),
);
}
}

View 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,
),
),
),
),
],
),
),
),
),
],
);
}
}

View File

@@ -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

View File

@@ -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)),
);
}
}