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.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
|
||||
class FittingTitleText extends StatelessWidget {
|
||||
final String text;
|
||||
@@ -23,6 +24,12 @@ class FittingTitleText extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final baseStyle = style ?? DefaultTextStyle.of(context).style;
|
||||
if (PlatformDetector.isAutomotive()) {
|
||||
return Align(
|
||||
alignment: alignment,
|
||||
child: Text(text, style: baseStyle, maxLines: maxLines, overflow: overflow, textAlign: textAlign),
|
||||
);
|
||||
}
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
var fittedStyle = baseStyle;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focusable_tile_mixin.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import 'clickable_cursor.dart';
|
||||
|
||||
/// A ListTile that accepts a FocusNode for keyboard/controller navigation.
|
||||
@@ -89,6 +90,7 @@ class _FocusableListTileState extends State<FocusableListTile> with FocusableTil
|
||||
final needsContrastSwap = _isHoveredOrFocused && widget.hoverColor != null && widget.textColor != null;
|
||||
final textColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.textColor;
|
||||
final iconColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.iconColor;
|
||||
final automotive = PlatformDetector.isAutomotive();
|
||||
|
||||
final Widget tile = MouseRegion(
|
||||
cursor: widget.enabled && (widget.onTap != null || widget.onLongPress != null)
|
||||
@@ -103,11 +105,11 @@ class _FocusableListTileState extends State<FocusableListTile> with FocusableTil
|
||||
trailing: widget.trailing,
|
||||
onTap: widget.onTap,
|
||||
onLongPress: widget.onLongPress,
|
||||
dense: widget.dense,
|
||||
dense: automotive ? false : widget.dense,
|
||||
enabled: widget.enabled,
|
||||
selected: widget.selected,
|
||||
contentPadding: widget.contentPadding,
|
||||
visualDensity: widget.visualDensity,
|
||||
visualDensity: automotive ? VisualDensity.standard : widget.visualDensity,
|
||||
focusNode: widget.suppressInitialSelect ? null : effectiveFocusNode,
|
||||
autofocus: widget.suppressInitialSelect ? false : widget.autofocus,
|
||||
hoverColor: widget.hoverColor,
|
||||
@@ -196,6 +198,7 @@ class _FocusableRadioListTileState<T> extends State<FocusableRadioListTile<T>>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final automotive = PlatformDetector.isAutomotive();
|
||||
return ClickableCursor(
|
||||
enabled: widget.enabled ?? true,
|
||||
child: RadioListTile<T>(
|
||||
@@ -204,8 +207,8 @@ class _FocusableRadioListTileState<T> extends State<FocusableRadioListTile<T>>
|
||||
secondary: widget.secondary,
|
||||
value: widget.value,
|
||||
// groupValue and onChanged provided by RadioGroup ancestor
|
||||
dense: widget.dense,
|
||||
visualDensity: widget.visualDensity,
|
||||
dense: automotive ? false : widget.dense,
|
||||
visualDensity: automotive ? VisualDensity.standard : widget.visualDensity,
|
||||
focusNode: effectiveFocusNode,
|
||||
autofocus: widget.autofocus,
|
||||
enabled: widget.enabled,
|
||||
@@ -282,6 +285,7 @@ class _FocusableSwitchListTileState extends State<FocusableSwitchListTile>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final automotive = PlatformDetector.isAutomotive();
|
||||
return ClickableCursor(
|
||||
enabled: widget.onChanged != null,
|
||||
child: SwitchListTile(
|
||||
@@ -290,8 +294,8 @@ class _FocusableSwitchListTileState extends State<FocusableSwitchListTile>
|
||||
secondary: widget.secondary,
|
||||
value: widget.value,
|
||||
onChanged: widget.onChanged,
|
||||
dense: widget.dense,
|
||||
visualDensity: widget.visualDensity,
|
||||
dense: automotive ? false : widget.dense,
|
||||
visualDensity: automotive ? VisualDensity.standard : widget.visualDensity,
|
||||
contentPadding: widget.contentPadding,
|
||||
focusNode: effectiveFocusNode,
|
||||
autofocus: widget.autofocus,
|
||||
@@ -346,6 +350,7 @@ class _FocusableCheckboxListTileState extends State<FocusableCheckboxListTile>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final automotive = PlatformDetector.isAutomotive();
|
||||
return ClickableCursor(
|
||||
enabled: widget.onChanged != null,
|
||||
child: CheckboxListTile(
|
||||
@@ -355,8 +360,8 @@ class _FocusableCheckboxListTileState extends State<FocusableCheckboxListTile>
|
||||
value: widget.value,
|
||||
onChanged: widget.onChanged,
|
||||
tristate: widget.tristate,
|
||||
dense: widget.dense,
|
||||
visualDensity: widget.visualDensity,
|
||||
dense: automotive ? false : widget.dense,
|
||||
visualDensity: automotive ? VisualDensity.standard : widget.visualDensity,
|
||||
contentPadding: widget.contentPadding,
|
||||
focusNode: effectiveFocusNode,
|
||||
autofocus: widget.autofocus,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import '../media/media_item.dart' show CardShape;
|
||||
import '../utils/grid_size_calculator.dart';
|
||||
import '../utils/layout_constants.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
|
||||
/// Shared grid delegate configuration for media item grids
|
||||
/// Maintains consistent aspect ratio and spacing across all media grids.
|
||||
@@ -81,6 +82,7 @@ class MediaGridDelegate {
|
||||
}
|
||||
|
||||
static double spacingFor({required BuildContext context, bool fullBleedImage = false}) {
|
||||
if (PlatformDetector.isAutomotive()) return GridLayoutConstants.crossAxisSpacing;
|
||||
if (!fullBleedImage) return GridLayoutConstants.crossAxisSpacing;
|
||||
return GridLayoutConstants.fullCardGridSpacingForScale(TvLayoutConstants.scaleOf(context));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import 'app_icon.dart';
|
||||
import 'expressive_button_group.dart';
|
||||
|
||||
@@ -70,7 +71,7 @@ class SettingsGroup extends StatelessWidget {
|
||||
Padding(
|
||||
padding: margin,
|
||||
child: ListTileTheme.merge(
|
||||
visualDensity: const VisualDensity(vertical: -3),
|
||||
visualDensity: PlatformDetector.isAutomotive() ? VisualDensity.standard : const VisualDensity(vertical: -3),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
|
||||
@@ -17,7 +17,7 @@ import 'package:flutter/widgets.dart';
|
||||
///
|
||||
/// Collapses to zero height wherever the bottom padding is already zero, so it
|
||||
/// needs no platform branching: desktop and Android TV report no inset, tvOS
|
||||
/// has it zeroed by `_AppleTvScale`, and inside `MainScreen`'s tab bodies
|
||||
/// has it zeroed by `_FormFactorScale`, and inside `MainScreen`'s tab bodies
|
||||
/// Flutter's [Scaffold] has already stripped it because a `bottomNavigationBar`
|
||||
/// is present.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user