Settings rows carried their own platform-conditional typography and density, so on desktop and TV they rendered a 16px title, 14px subtitle and 80px row while every other row in the app — the Focusable*ListTile defaults plus ThemeData.listTileTheme's `dense: true` — renders 13/12 in 61px. Drop the overrides instead of re-tuning them: the tile defaults already encode the app's row style, and the explicit title styles were redundant under a dense ListTile (they also masked the disabled/selected title color). settingsOptionTitleStyle now only serves group children that are not ListTiles, and matches the dense title unconditionally. SettingsGroup hands its children that same compact density, so the plain ListTiles used as non-interactive info rows stop standing 11px taller than their interactive siblings.
133 lines
4.4 KiB
Dart
133 lines
4.4 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../theme/mono_tokens.dart';
|
|
import 'app_icon.dart';
|
|
import 'expressive_button_group.dart';
|
|
|
|
/// Standard settings-option title style, for group children that are not a
|
|
/// [ListTile] (segmented controls, sliders) and so don't inherit its
|
|
/// typography.
|
|
///
|
|
/// The app renders every row compactly — [ThemeData.listTileTheme] sets
|
|
/// `dense: true` and the `Focusable*ListTile`s default to it — and Flutter
|
|
/// draws a dense [ListTile] title at 13. Match that so a settings page reads
|
|
/// as one family instead of one size per row type.
|
|
TextStyle? settingsOptionTitleStyle(BuildContext context) =>
|
|
Theme.of(context).textTheme.bodyLarge?.copyWith(fontSize: 13);
|
|
|
|
class SettingsSectionHeader extends StatelessWidget {
|
|
final String title;
|
|
|
|
const SettingsSectionHeader(this.title, {super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
|
|
child: Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(color: tokens(context).textMuted, fontWeight: .w600),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// M3E grouped list section: each child gets its own rounded surface card —
|
|
/// large radii on the section's outer corners, small radii between adjacent
|
|
/// items, hairline gaps — with an optional [SettingsSectionHeader] above.
|
|
///
|
|
/// Corner shapes are computed from the child list index, so conditional tiles
|
|
/// MUST be excluded with `if (...)` at list-build time. A child that renders
|
|
/// `SizedBox.shrink()` still occupies a corner slot and corrupts the group's
|
|
/// shape; hoist its condition (or wrap the whole group in a SettingsBuilder).
|
|
///
|
|
/// Each item is a shaped [Material] so the tiles' native ink focus/hover
|
|
/// highlight paints clipped inside the card — that is the d-pad focus visual
|
|
/// (background focus). The group adds no [Focus] nodes of its own; traversal
|
|
/// order and externally-owned tile focus nodes are untouched.
|
|
///
|
|
/// Children inherit the compact row geometry the `Focusable*ListTile`s default
|
|
/// to, so a plain [ListTile] used as a non-interactive info row lines up with
|
|
/// its interactive siblings instead of standing 11px taller.
|
|
class SettingsGroup extends StatelessWidget {
|
|
final String? title;
|
|
final List<Widget> children;
|
|
final EdgeInsetsGeometry margin;
|
|
|
|
const SettingsGroup({
|
|
super.key,
|
|
this.title,
|
|
required this.children,
|
|
this.margin = const EdgeInsets.symmetric(horizontal: 16),
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = tokens(context);
|
|
return Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
if (title != null) SettingsSectionHeader(title!),
|
|
Padding(
|
|
padding: margin,
|
|
child: ListTileTheme.merge(
|
|
visualDensity: const VisualDensity(vertical: -3),
|
|
child: Column(
|
|
children: [
|
|
for (var i = 0; i < children.length; i++) ...[
|
|
if (i > 0) SizedBox(height: t.groupGap),
|
|
Material(
|
|
color: t.surface,
|
|
clipBehavior: Clip.antiAlias,
|
|
shape: RoundedRectangleBorder(borderRadius: groupItemRadii(context, i, children.length)),
|
|
child: children[i],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A setting with a label + icon row and a full-width button group below.
|
|
/// Used for settings with 2-3 short options.
|
|
class SegmentedSetting<T> extends StatelessWidget {
|
|
final IconData icon;
|
|
final String title;
|
|
final List<ButtonSegment<T>> segments;
|
|
final T selected;
|
|
final ValueChanged<T> onChanged;
|
|
|
|
const SegmentedSetting({
|
|
super.key,
|
|
required this.icon,
|
|
required this.title,
|
|
required this.segments,
|
|
required this.selected,
|
|
required this.onChanged,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
AppIcon(icon, fill: 1),
|
|
const SizedBox(width: 16),
|
|
Text(title, style: settingsOptionTitleStyle(context)),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
ExpressiveButtonGroup<T>(segments: segments, selected: selected, onChanged: onChanged),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|