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 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 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 children; final Future 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, ), ); } }