first commit
This commit is contained in:
336
lib/views/helpers/widgets/settings_ui.dart
Normal file
336
lib/views/helpers/widgets/settings_ui.dart
Normal file
@@ -0,0 +1,336 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user