diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index 6ef45d1c..476a3a33 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -267,7 +267,7 @@ class AppearanceSettingsScreen extends StatelessWidget { children: [ const AppIcon(Symbols.grid_view_rounded, fill: 1), const SizedBox(width: 16), - Text(t.settings.libraryDensity, style: theme.textTheme.bodyLarge), + Text(t.settings.libraryDensity, style: settingsOptionTitleStyle(context)), ], ), const SizedBox(height: 12), diff --git a/lib/widgets/setting_tile.dart b/lib/widgets/setting_tile.dart index ad0a232e..57210bbe 100644 --- a/lib/widgets/setting_tile.dart +++ b/lib/widgets/setting_tile.dart @@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../screens/settings/settings_utils.dart'; import '../services/settings_service.dart'; +import '../utils/platform_detector.dart'; import 'app_icon.dart'; import 'focusable_list_tile.dart'; import 'settings_section.dart'; @@ -40,17 +41,18 @@ class SettingSwitchTile extends StatelessWidget { @override Widget build(BuildContext context) { + final isMobile = PlatformDetector.isMobile(context); final svc = _TileBase._svc; return ValueListenableBuilder( valueListenable: svc.listenable(pref), builder: (_, value, _) => FocusableSwitchListTile( focusNode: focusNode, secondary: AppIcon(icon, fill: 1), - title: Text(title), + title: Text(title, style: settingsOptionTitleStyle(context)), subtitle: subtitle != null ? Text(subtitle!) : null, value: value, - dense: false, - visualDensity: VisualDensity.standard, + dense: isMobile, + visualDensity: isMobile ? const VisualDensity(vertical: -3) : VisualDensity.standard, onChanged: enabled ? (v) async { await svc.write(pref, v); @@ -86,15 +88,16 @@ class SettingNavigationTile extends StatelessWidget { @override Widget build(BuildContext context) { + final isMobile = PlatformDetector.isMobile(context); return FocusableListTile( focusNode: focusNode, leading: AppIcon(icon, fill: 1), - title: Text(title), + title: Text(title, style: settingsOptionTitleStyle(context)), subtitle: subtitle != null ? Text(subtitle!) : null, trailing: AppIcon(trailingIcon, fill: 1), onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)), - dense: false, - visualDensity: VisualDensity.standard, + dense: isMobile, + visualDensity: isMobile ? const VisualDensity(vertical: -3) : VisualDensity.standard, ); } } @@ -126,16 +129,17 @@ class SettingNumberTile extends StatelessWidget { @override Widget build(BuildContext context) { + final isMobile = PlatformDetector.isMobile(context); final svc = _TileBase._svc; return ValueListenableBuilder( valueListenable: svc.listenable(pref), builder: (_, value, _) => FocusableListTile( leading: AppIcon(icon, fill: 1), - title: Text(title), + title: Text(title, style: settingsOptionTitleStyle(context)), subtitle: Text(subtitleBuilder(value)), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - dense: false, - visualDensity: VisualDensity.standard, + dense: isMobile, + visualDensity: isMobile ? const VisualDensity(vertical: -3) : VisualDensity.standard, onTap: () => showNumericInputDialog( context: context, title: title, @@ -182,6 +186,7 @@ class SettingSelectionTile extends StatelessWidget { @override Widget build(BuildContext context) { + final isMobile = PlatformDetector.isMobile(context); final svc = _TileBase._svc; return ValueListenableBuilder( valueListenable: svc.listenable(pref), @@ -189,11 +194,11 @@ class SettingSelectionTile extends StatelessWidget { final value = decode(raw); return FocusableListTile( leading: AppIcon(icon, fill: 1), - title: Text(title), + title: Text(title, style: settingsOptionTitleStyle(context)), subtitle: Text(subtitleBuilder(value)), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - dense: false, - visualDensity: VisualDensity.standard, + dense: isMobile, + visualDensity: isMobile ? const VisualDensity(vertical: -3) : VisualDensity.standard, onTap: () async { final picked = await showSelectionDialog( context: context, @@ -233,16 +238,17 @@ class SettingRegexTile extends StatelessWidget { @override Widget build(BuildContext context) { + final isMobile = PlatformDetector.isMobile(context); final svc = _TileBase._svc; return ValueListenableBuilder( valueListenable: svc.listenable(pref), builder: (_, value, _) => FocusableListTile( leading: AppIcon(icon, fill: 1), - title: Text(title), + title: Text(title, style: settingsOptionTitleStyle(context)), subtitle: Text(subtitle), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - dense: false, - visualDensity: VisualDensity.standard, + dense: isMobile, + visualDensity: isMobile ? const VisualDensity(vertical: -3) : VisualDensity.standard, onTap: () => showRegexInputDialog( context: context, title: title, @@ -325,12 +331,13 @@ class SettingColorTile extends StatelessWidget { @override Widget build(BuildContext context) { + final isMobile = PlatformDetector.isMobile(context); final svc = _TileBase._svc; return ValueListenableBuilder( valueListenable: svc.listenable(pref), builder: (_, hex, _) => FocusableListTile( leading: AppIcon(icon, fill: 1), - title: Text(title), + title: Text(title, style: settingsOptionTitleStyle(context)), subtitle: subtitle != null ? Text(subtitle!) : null, trailing: Container( width: 28, @@ -341,8 +348,8 @@ class SettingColorTile extends StatelessWidget { border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), ), ), - dense: false, - visualDensity: VisualDensity.standard, + dense: isMobile, + visualDensity: isMobile ? const VisualDensity(vertical: -3) : VisualDensity.standard, onTap: () => showColorInputDialog( context: context, title: title, diff --git a/lib/widgets/settings_section.dart b/lib/widgets/settings_section.dart index be6619d3..eea166cd 100644 --- a/lib/widgets/settings_section.dart +++ b/lib/widgets/settings_section.dart @@ -1,8 +1,19 @@ 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. +/// +/// Mobile matches Flutter's dense ListTile title size so custom controls and +/// ordinary settings rows keep the same hierarchy. Desktop and TV retain the +/// larger body style used for D-pad readability. +TextStyle? settingsOptionTitleStyle(BuildContext context) { + final style = Theme.of(context).textTheme.bodyLarge; + return PlatformDetector.isMobile(context) ? style?.copyWith(fontSize: 13) : style; +} + class SettingsSectionHeader extends StatelessWidget { final String title; @@ -109,7 +120,7 @@ class SegmentedSetting extends StatelessWidget { children: [ AppIcon(icon, fill: 1), const SizedBox(width: 16), - Text(title, style: Theme.of(context).textTheme.bodyLarge), + Text(title, style: settingsOptionTitleStyle(context)), ], ), const SizedBox(height: 12), diff --git a/test/widgets/setting_tile_test.dart b/test/widgets/setting_tile_test.dart new file mode 100644 index 00000000..5c778afa --- /dev/null +++ b/test/widgets/setting_tile_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/widgets/focusable_list_tile.dart'; +import 'package:plezy/widgets/setting_tile.dart'; +import 'package:plezy/widgets/settings_section.dart'; + +void main() { + testWidgets('mobile settings option titles match compact native rows', (tester) async { + await tester.pumpWidget(_harness(TargetPlatform.android, referenceDense: true)); + + final referenceHeight = tester.getSize(find.text('Clear Cache')).height; + expect(tester.getSize(find.text('View Logs')).height, referenceHeight); + expect(tester.getSize(find.text('View Mode')).height, referenceHeight); + + final referenceSubtitleHeight = tester.getSize(find.text('Clear cached data')).height; + expect(tester.getSize(find.text('View application logs')).height, referenceSubtitleHeight); + }); + + testWidgets('desktop settings option titles retain standard row size', (tester) async { + await tester.pumpWidget(_harness(TargetPlatform.macOS, referenceDense: false)); + + final referenceHeight = tester.getSize(find.text('Clear Cache')).height; + expect(tester.getSize(find.text('View Logs')).height, referenceHeight); + expect(tester.getSize(find.text('View Mode')).height, referenceHeight); + }); +} + +Widget _harness(TargetPlatform platform, {required bool referenceDense}) { + return MaterialApp( + theme: monoTheme(dark: false).copyWith(platform: platform), + home: Scaffold( + body: Column( + children: [ + SettingNavigationTile( + icon: Icons.article, + title: 'View Logs', + subtitle: 'View application logs', + onTap: () {}, + ), + FocusableListTile( + leading: const Icon(Icons.cleaning_services), + title: const Text('Clear Cache'), + subtitle: const Text('Clear cached data'), + trailing: const Icon(Icons.chevron_right), + dense: referenceDense, + visualDensity: referenceDense ? const VisualDensity(vertical: -3) : VisualDensity.standard, + onTap: () {}, + ), + SegmentedSetting( + icon: Icons.view_list, + title: 'View Mode', + segments: const [ + ButtonSegment(value: 'grid', label: Text('Grid')), + ButtonSegment(value: 'list', label: Text('List')), + ], + selected: 'grid', + onChanged: (_) {}, + ), + ], + ), + ), + ); +}