import 'package:flutter/material.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/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}); 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), ), ], ); @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) { final Color fg = danger ? ColorConstants.primary : 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: 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: Row( children: [ Icon(icon, color: iconColor ?? fg, size: 22.sp), SizedBox(width: 14.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w600, color: fg, fontFamily: FontConstants.fontFamily, ), ), 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, ), ], ), ), ); } } /// 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( 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, ), ), ); } } /// 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(4.w, 8.h, 4.w, 0), child: Text( text, style: TextStyle( fontSize: 12.sp, height: 1.45, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), ), ); } } /// 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, ), ); } }