337 lines
12 KiB
Dart
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,
|
|
),
|
|
);
|
|
}
|
|
}
|