Files
doormile_milderapp/lib/views/helpers/widgets/settings_ui.dart
Thiru-tenext d7348e253f 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>
2026-08-22 05:40:35 +05:30

426 lines
17 KiB
Dart

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