Files
plezy/lib/widgets/settings_section.dart
T
edde746 961e9c0326 feat(automotive): scale the car interface and make it adjustable
A head unit is a large screen sitting an arm's length further away than a phone,
and Plezy drew phone-sized controls on it: the primary button measured 8.3 mm
against the 64 dp a car needs. The whole surface is now scaled - 1.35 by default,
adjustable in Appearance - by giving the app a smaller logical viewport and
scaling the result back, so text, spacing and touch targets grow together instead
of a font size being nudged in isolation.

The scale sits above the messenger and the root Scaffold so snackbars and dialogs
are scaled too, and insets are divided back into the scaled space so a system bar
still reserves its physical size. A scaled surface is also a short one: the setup
screen's fixed offsets and the now-playing transport are laid out to survive it,
and a mistyped scale in a hand-edited settings file is clamped rather than
failing startup.
2026-08-06 03:45:09 +02:00

134 lines
4.5 KiB
Dart

import 'package:flutter/material.dart';
import '../theme/mono_tokens.dart';
import '../utils/platform_detector.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: PlatformDetector.isAutomotive() ? VisualDensity.standard : 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),
],
),
);
}
}