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>
426 lines
17 KiB
Dart
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,
|
|
),
|
|
);
|
|
}
|
|
}
|