Files
2026-08-11 13:16:33 +05:30

337 lines
12 KiB
Dart

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