feat: convert settings dialogs to platform-adaptive menus

Replace AlertDialog-based selection dialogs with popup dropdowns on
desktop/TV and bottom sheets on mobile, matching the context menu
pattern. Fix popup background blending into cards by tinting surface.
This commit is contained in:
edde746
2026-03-10 01:18:33 +01:00
parent 0372840ee1
commit 6daf77c890
2 changed files with 497 additions and 366 deletions
+493 -366
View File
@@ -10,10 +10,11 @@ import '../../models/hotkey_model.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/focus_memory_tracker.dart';
import '../../focus/input_mode_tracker.dart';
import '../../i18n/strings.g.dart';
import '../../utils/focus_utils.dart';
import '../main_screen.dart';
import '../../mixins/refreshable.dart';
import '../../services/discord_rpc_service.dart';
@@ -29,7 +30,9 @@ import '../../services/update_service.dart';
import '../../utils/snackbar_helper.dart';
import '../../utils/platform_detector.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../widgets/focusable_list_tile.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../../widgets/overlay_sheet.dart';
import '../../widgets/tv_number_spinner.dart';
import 'hotkey_recorder_widget.dart';
import '../../providers/companion_remote_provider.dart';
@@ -268,59 +271,179 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
),
Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return ListTile(
focusNode: _focusTracker.get(_kTheme),
leading: AppIcon(themeProvider.themeModeIcon, fill: 1),
title: Text(t.settings.theme),
subtitle: Text(themeProvider.themeModeDisplayName),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showThemeDialog(themeProvider),
return Builder(
builder: (tileContext) => ListTile(
focusNode: _focusTracker.get(_kTheme),
leading: AppIcon(themeProvider.themeModeIcon, fill: 1),
title: Text(t.settings.theme),
subtitle: Text(themeProvider.themeModeDisplayName),
trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1),
onTap: () async {
final value = await _showSettingsMenu<settings.ThemeMode>(
tileContext: tileContext,
title: t.settings.theme,
options: [
_DialogOption(
value: settings.ThemeMode.system,
title: t.settings.systemTheme,
subtitle: t.settings.systemThemeDescription,
),
_DialogOption(value: settings.ThemeMode.light, title: t.settings.lightTheme),
_DialogOption(value: settings.ThemeMode.dark, title: t.settings.darkTheme),
_DialogOption(
value: settings.ThemeMode.oled,
title: t.settings.oledTheme,
subtitle: t.settings.oledThemeDescription,
),
],
currentValue: themeProvider.themeMode,
);
if (value != null) {
themeProvider.setThemeMode(value);
}
},
),
);
},
),
ListTile(
focusNode: _focusTracker.get(_kLanguage),
leading: const AppIcon(Symbols.language_rounded, fill: 1),
title: Text(t.settings.language),
subtitle: Text(_getLanguageDisplayName(LocaleSettings.currentLocale)),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showLanguageDialog(),
Builder(
builder: (tileContext) => ListTile(
focusNode: _focusTracker.get(_kLanguage),
leading: const AppIcon(Symbols.language_rounded, fill: 1),
title: Text(t.settings.language),
subtitle: Text(_getLanguageDisplayName(LocaleSettings.currentLocale)),
trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1),
onTap: () async {
final value = await _showSettingsMenu<AppLocale>(
tileContext: tileContext,
title: t.settings.language,
options: AppLocale.values
.map((locale) => _DialogOption(value: locale, title: _getLanguageDisplayName(locale)))
.toList(),
currentValue: LocaleSettings.currentLocale,
);
if (value != null) {
await _settingsService.setAppLocale(value);
LocaleSettings.setLocale(value);
_restartApp();
}
},
),
),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return ListTile(
focusNode: _focusTracker.get(_kLibraryDensity),
leading: const AppIcon(Symbols.grid_view_rounded, fill: 1),
title: Text(t.settings.libraryDensity),
subtitle: Text(settingsProvider.libraryDensityDisplayName),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showLibraryDensityDialog(),
);
},
),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return ListTile(
focusNode: _focusTracker.get(_kViewMode),
leading: const AppIcon(Symbols.view_list_rounded, fill: 1),
title: Text(t.settings.viewMode),
subtitle: Text(
settingsProvider.viewMode == settings.ViewMode.grid ? t.settings.gridView : t.settings.listView,
return Builder(
builder: (tileContext) => ListTile(
focusNode: _focusTracker.get(_kLibraryDensity),
leading: const AppIcon(Symbols.grid_view_rounded, fill: 1),
title: Text(t.settings.libraryDensity),
subtitle: Text(settingsProvider.libraryDensityDisplayName),
trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1),
onTap: () async {
final value = await _showSettingsMenu<settings.LibraryDensity>(
tileContext: tileContext,
title: t.settings.libraryDensity,
options: [
_DialogOption(
value: settings.LibraryDensity.compact,
title: t.settings.compact,
subtitle: t.settings.compactDescription,
),
_DialogOption(
value: settings.LibraryDensity.normal,
title: t.settings.normal,
subtitle: t.settings.normalDescription,
),
_DialogOption(
value: settings.LibraryDensity.comfortable,
title: t.settings.comfortable,
subtitle: t.settings.comfortableDescription,
),
],
currentValue: settingsProvider.libraryDensity,
);
if (value != null) {
settingsProvider.setLibraryDensity(value);
}
},
),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showViewModeDialog(),
);
},
),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return ListTile(
focusNode: _focusTracker.get(_kEpisodePosterMode),
leading: const AppIcon(Symbols.image_rounded, fill: 1),
title: Text(t.settings.episodePosterMode),
subtitle: Text(settingsProvider.episodePosterModeDisplayName),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showEpisodePosterModeDialog(),
return Builder(
builder: (tileContext) => ListTile(
focusNode: _focusTracker.get(_kViewMode),
leading: const AppIcon(Symbols.view_list_rounded, fill: 1),
title: Text(t.settings.viewMode),
subtitle: Text(
settingsProvider.viewMode == settings.ViewMode.grid ? t.settings.gridView : t.settings.listView,
),
trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1),
onTap: () async {
final value = await _showSettingsMenu<settings.ViewMode>(
tileContext: tileContext,
title: t.settings.viewMode,
options: [
_DialogOption(
value: settings.ViewMode.grid,
title: t.settings.gridView,
subtitle: t.settings.gridViewDescription,
),
_DialogOption(
value: settings.ViewMode.list,
title: t.settings.listView,
subtitle: t.settings.listViewDescription,
),
],
currentValue: settingsProvider.viewMode,
);
if (value != null) {
settingsProvider.setViewMode(value);
}
},
),
);
},
),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return Builder(
builder: (tileContext) => ListTile(
focusNode: _focusTracker.get(_kEpisodePosterMode),
leading: const AppIcon(Symbols.image_rounded, fill: 1),
title: Text(t.settings.episodePosterMode),
subtitle: Text(settingsProvider.episodePosterModeDisplayName),
trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1),
onTap: () async {
final value = await _showSettingsMenu<settings.EpisodePosterMode>(
tileContext: tileContext,
title: t.settings.episodePosterMode,
options: [
_DialogOption(
value: settings.EpisodePosterMode.seriesPoster,
title: t.settings.seriesPoster,
subtitle: t.settings.seriesPosterDescription,
),
_DialogOption(
value: settings.EpisodePosterMode.seasonPoster,
title: t.settings.seasonPoster,
subtitle: t.settings.seasonPosterDescription,
),
_DialogOption(
value: settings.EpisodePosterMode.episodeThumbnail,
title: t.settings.episodeThumbnail,
subtitle: t.settings.episodeThumbnailDescription,
),
],
currentValue: settingsProvider.episodePosterMode,
);
if (value != null) {
settingsProvider.setEpisodePosterMode(value);
}
},
),
);
},
),
@@ -472,13 +595,33 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
),
),
if (Platform.isAndroid)
ListTile(
focusNode: _focusTracker.get(_kPlayerBackend),
leading: const AppIcon(Symbols.play_circle_rounded, fill: 1),
title: Text(t.settings.playerBackend),
subtitle: Text(_useExoPlayer ? t.settings.exoPlayerDescription : t.settings.mpvDescription),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showPlayerBackendDialog(),
Builder(
builder: (tileContext) => ListTile(
focusNode: _focusTracker.get(_kPlayerBackend),
leading: const AppIcon(Symbols.play_circle_rounded, fill: 1),
title: Text(t.settings.playerBackend),
subtitle: Text(_useExoPlayer ? t.settings.exoPlayerDescription : t.settings.mpvDescription),
trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1),
onTap: () async {
final value = await _showSettingsMenu<bool>(
tileContext: tileContext,
title: t.settings.playerBackend,
options: [
_DialogOption(
value: true,
title: t.settings.exoPlayer,
subtitle: t.settings.exoPlayerDescription,
),
_DialogOption(value: false, title: t.settings.mpv, subtitle: t.settings.mpvDescription),
],
currentValue: _useExoPlayer,
);
if (value != null) {
setState(() => _useExoPlayer = value);
await _settingsService.setUseExoPlayer(value);
}
},
),
),
ListTile(
focusNode: _focusTracker.get(_kExternalPlayer),
@@ -552,13 +695,45 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
await _settingsService.setTunneledPlayback(value);
},
),
ListTile(
focusNode: _focusTracker.get(_kBufferSize),
leading: const AppIcon(Symbols.memory_rounded, fill: 1),
title: Text(t.settings.bufferSize),
subtitle: Text(_bufferSize == 0 ? t.settings.bufferSizeAuto : t.settings.bufferSizeMB(size: _bufferSize.toString())),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showBufferSizeDialog(),
Builder(
builder: (tileContext) => ListTile(
focusNode: _focusTracker.get(_kBufferSize),
leading: const AppIcon(Symbols.memory_rounded, fill: 1),
title: Text(t.settings.bufferSize),
subtitle: Text(
_bufferSize == 0 ? t.settings.bufferSizeAuto : t.settings.bufferSizeMB(size: _bufferSize.toString()),
),
trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1),
onTap: () async {
final bufferOptions = [0, 64, 128, 256, 512, 1024];
final value = await _showSettingsMenu<int>(
tileContext: tileContext,
title: t.settings.bufferSize,
options: bufferOptions
.map(
(size) =>
_DialogOption(value: size, title: size == 0 ? t.settings.bufferSizeAuto : '${size}MB'),
)
.toList(),
currentValue: _bufferSize,
);
if (value != null) {
setState(() {
_bufferSize = value;
_settingsService.setBufferSize(value);
});
if (Platform.isAndroid && value > 0) {
final heapMB = await PlayerAndroid.getHeapSize();
if (heapMB > 0 && value > heapMB ~/ 4 && mounted) {
showAppSnackBar(
context,
t.settings.bufferSizeWarning(heap: heapMB.toString(), size: value.toString()),
);
}
}
}
},
),
),
ListTile(
focusNode: _focusTracker.get(_kSubtitleStyling),
@@ -1115,113 +1290,73 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
);
}
void _showThemeDialog(ThemeProvider themeProvider) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(t.settings.theme),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: AppIcon(
themeProvider.themeMode == settings.ThemeMode.system
? Symbols.radio_button_checked_rounded
: Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(t.settings.systemTheme),
subtitle: Text(t.settings.systemThemeDescription),
onTap: () {
themeProvider.setThemeMode(settings.ThemeMode.system);
Navigator.pop(context);
},
),
ListTile(
leading: AppIcon(
themeProvider.themeMode == settings.ThemeMode.light
? Symbols.radio_button_checked_rounded
: Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(t.settings.lightTheme),
onTap: () {
themeProvider.setThemeMode(settings.ThemeMode.light);
Navigator.pop(context);
},
),
ListTile(
leading: AppIcon(
themeProvider.themeMode == settings.ThemeMode.dark
? Symbols.radio_button_checked_rounded
: Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(t.settings.darkTheme),
onTap: () {
themeProvider.setThemeMode(settings.ThemeMode.dark);
Navigator.pop(context);
},
),
ListTile(
leading: AppIcon(
themeProvider.themeMode == settings.ThemeMode.oled
? Symbols.radio_button_checked_rounded
: Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(t.settings.oledTheme),
subtitle: Text(t.settings.oledThemeDescription),
onTap: () {
themeProvider.setThemeMode(settings.ThemeMode.oled);
Navigator.pop(context);
},
),
],
),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))],
);
},
/// Shows a platform-adaptive settings menu: popup dropdown on desktop/TV,
/// bottom sheet on mobile.
Future<T?> _showSettingsMenu<T>({
required BuildContext tileContext,
required String title,
required List<_DialogOption<T>> options,
required T currentValue,
}) {
final useBottomSheet = Platform.isIOS || Platform.isAndroid;
final focusFirstItem = InputModeTracker.isKeyboardMode(context);
if (useBottomSheet) {
return _showSettingsMenuSheet<T>(
title: title,
options: options,
currentValue: currentValue,
focusFirstItem: focusFirstItem,
);
} else {
return _showSettingsMenuPopup<T>(
tileContext: tileContext,
options: options,
currentValue: currentValue,
focusFirstItem: focusFirstItem,
);
}
}
/// Mobile path: bottom sheet via OverlaySheetController.showAdaptive
Future<T?> _showSettingsMenuSheet<T>({
required String title,
required List<_DialogOption<T>> options,
required T currentValue,
required bool focusFirstItem,
}) {
return OverlaySheetController.showAdaptive<T>(
context,
showDragHandle: true,
builder: (context) => _SettingsMenuSheet<T>(
title: title,
options: options,
currentValue: currentValue,
focusFirstItem: focusFirstItem,
),
);
}
void _showBufferSizeDialog() {
final options = [0, 64, 128, 256, 512, 1024];
/// Desktop/TV path: positioned popup dropdown
Future<T?> _showSettingsMenuPopup<T>({
required BuildContext tileContext,
required List<_DialogOption<T>> options,
required T currentValue,
required bool focusFirstItem,
}) {
final renderBox = tileContext.findRenderObject() as RenderBox;
final position = renderBox.localToGlobal(Offset.zero);
final tileSize = renderBox.size;
showDialog(
return showDialog<T>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(t.settings.bufferSize),
content: Column(
mainAxisSize: MainAxisSize.min,
children: options.map((size) {
return ListTile(
leading: AppIcon(
_bufferSize == size ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(size == 0 ? t.settings.bufferSizeAuto : '${size}MB'),
onTap: () async {
setState(() {
_bufferSize = size;
_settingsService.setBufferSize(size);
});
Navigator.pop(context);
if (Platform.isAndroid && size > 0) {
final heapMB = await PlayerAndroid.getHeapSize();
if (heapMB > 0 && size > heapMB ~/ 4 && mounted) {
showAppSnackBar(this.context, t.settings.bufferSizeWarning(heap: heapMB.toString(), size: size.toString()));
}
}
},
);
}).toList(),
),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))],
);
},
barrierColor: Colors.transparent,
builder: (context) => _SettingsMenuPopup<T>(
position: Offset(position.dx + tileSize.width, position.dy + tileSize.height),
options: options,
currentValue: currentValue,
focusFirstItem: focusFirstItem,
),
);
}
@@ -1482,53 +1617,6 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
);
}
void _showPlayerBackendDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(t.settings.playerBackend),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: AppIcon(
_useExoPlayer ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(t.settings.exoPlayer),
subtitle: Text(t.settings.exoPlayerDescription),
onTap: () async {
setState(() {
_useExoPlayer = true;
});
await _settingsService.setUseExoPlayer(true);
if (context.mounted) Navigator.pop(context);
},
),
ListTile(
leading: AppIcon(
!_useExoPlayer ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(t.settings.mpv),
subtitle: Text(t.settings.mpvDescription),
onTap: () async {
setState(() {
_useExoPlayer = false;
});
await _settingsService.setUseExoPlayer(false);
if (context.mounted) Navigator.pop(context);
},
),
],
),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))],
);
},
);
}
void _showKeyboardShortcutsDialog() {
if (_keyboardService == null) return;
@@ -1546,11 +1634,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
title: Text(t.settings.clearCache),
content: Text(t.settings.clearCacheDescription),
actions: [
TextButton(
autofocus: true,
onPressed: () => Navigator.pop(context),
child: Text(t.common.cancel),
),
TextButton(autofocus: true, onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
TextButton(
onPressed: () async {
final navigator = Navigator.of(context);
@@ -1576,11 +1660,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
title: Text(t.settings.resetSettings),
content: Text(t.settings.resetSettingsDescription),
actions: [
TextButton(
autofocus: true,
onPressed: () => Navigator.pop(context),
child: Text(t.common.cancel),
),
TextButton(autofocus: true, onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
TextButton(
onPressed: () async {
final navigator = Navigator.of(context);
@@ -1635,50 +1715,6 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
}
}
void _showLanguageDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(t.settings.language),
content: Column(
mainAxisSize: MainAxisSize.min,
children: AppLocale.values.map((locale) {
final isSelected = LocaleSettings.currentLocale == locale;
return ListTile(
title: Text(_getLanguageDisplayName(locale)),
leading: AppIcon(
isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
fill: 1,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
),
tileColor: isSelected ? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3) : null,
onTap: () async {
// Save the locale to settings
await _settingsService.setAppLocale(locale);
// Set the locale immediately
LocaleSettings.setLocale(locale);
// Close dialog
if (context.mounted) {
Navigator.pop(context);
}
// Trigger app-wide rebuild by restarting the app
if (context.mounted) {
_restartApp();
}
},
);
}).toList(),
),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))],
);
},
);
}
void _restartApp() {
// Navigate to the root and remove all previous routes
Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false);
@@ -1738,11 +1774,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
],
),
actions: [
TextButton(
autofocus: true,
onPressed: () => Navigator.pop(context),
child: Text(t.common.close),
),
TextButton(autofocus: true, onPressed: () => Navigator.pop(context), child: Text(t.common.close)),
FilledButton(
onPressed: () async {
final url = Uri.parse(_updateInfo!['releaseUrl']);
@@ -1758,117 +1790,212 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
},
);
}
}
/// Generic option selection dialog for settings with SettingsProvider
void _showOptionSelectionDialog<T>({
required String title,
required List<_DialogOption<T>> options,
required T Function(SettingsProvider) getCurrentValue,
required Future<void> Function(T value, SettingsProvider provider) onSelect,
}) {
final settingsProvider = context.read<SettingsProvider>();
showDialog(
context: context,
builder: (BuildContext context) {
return Consumer<SettingsProvider>(
builder: (context, provider, child) {
final currentValue = getCurrentValue(provider);
return AlertDialog(
title: Text(title),
content: Column(
mainAxisSize: MainAxisSize.min,
children: options.map((option) {
return ListTile(
/// Bottom sheet content for settings menu (mobile)
class _SettingsMenuSheet<T> extends StatefulWidget {
final String title;
final List<_DialogOption<T>> options;
final T currentValue;
final bool focusFirstItem;
const _SettingsMenuSheet({
required this.title,
required this.options,
required this.currentValue,
this.focusFirstItem = false,
});
@override
State<_SettingsMenuSheet<T>> createState() => _SettingsMenuSheetState<T>();
}
class _SettingsMenuSheetState<T> extends State<_SettingsMenuSheet<T>> {
late final FocusNode _initialFocusNode;
@override
void initState() {
super.initState();
_initialFocusNode = FocusNode(debugLabel: 'SettingsMenuSheetInitialFocus');
}
@override
void dispose() {
_initialFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Text(
widget.title,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...widget.options.asMap().entries.map((entry) {
final index = entry.key;
final option = entry.value;
final isSelected = widget.currentValue == option.value;
return FocusableListTile(
focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null,
dense: true,
leading: AppIcon(
currentValue == option.value
? Symbols.radio_button_checked_rounded
: Symbols.radio_button_unchecked_rounded,
isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(option.title),
subtitle: option.subtitle != null ? Text(option.subtitle!) : null,
onTap: () async {
await onSelect(option.value, settingsProvider);
if (context.mounted) Navigator.pop(context);
},
onTap: () => OverlaySheetController.closeAdaptive(context, option.value),
);
}).toList(),
}),
],
),
),
),
],
);
}
}
/// Positioned popup menu for settings menu (desktop/TV)
class _SettingsMenuPopup<T> extends StatefulWidget {
final Offset position;
final List<_DialogOption<T>> options;
final T currentValue;
final bool focusFirstItem;
const _SettingsMenuPopup({
required this.position,
required this.options,
required this.currentValue,
this.focusFirstItem = false,
});
@override
State<_SettingsMenuPopup<T>> createState() => _SettingsMenuPopupState<T>();
}
class _SettingsMenuPopupState<T> extends State<_SettingsMenuPopup<T>> {
late final FocusNode _initialFocusNode;
@override
void initState() {
super.initState();
_initialFocusNode = FocusNode(debugLabel: 'SettingsMenuPopupInitialFocus');
if (widget.focusFirstItem) {
FocusUtils.requestFocusAfterBuild(this, _initialFocusNode);
}
}
@override
void dispose() {
_initialFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
const menuWidth = 280.0;
const edgePadding = 8.0;
// Right-align: position.dx is the tile's right edge
final left = (widget.position.dx - menuWidth).clamp(edgePadding, screenSize.width - menuWidth - edgePadding);
final estimatedHeight = widget.options.length * 56.0 + 16;
final spaceBelow = screenSize.height - widget.position.dy - edgePadding;
final spaceAbove = widget.position.dy - edgePadding;
final double top;
final double maxHeight;
if (estimatedHeight <= spaceBelow) {
top = widget.position.dy;
maxHeight = spaceBelow;
} else if (spaceAbove > spaceBelow) {
final menuHeight = estimatedHeight.clamp(0.0, spaceAbove);
top = widget.position.dy - menuHeight;
maxHeight = menuHeight;
} else {
top = widget.position.dy;
maxHeight = spaceBelow;
}
return FocusScope(
autofocus: false,
child: Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (node, event) {
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: () => Navigator.pop(context),
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))],
);
},
);
},
);
}
void _showLibraryDensityDialog() {
_showOptionSelectionDialog<settings.LibraryDensity>(
title: t.settings.libraryDensity,
options: [
_DialogOption(
value: settings.LibraryDensity.compact,
title: t.settings.compact,
subtitle: t.settings.compactDescription,
),
Positioned(
left: left,
top: top,
child: Material(
elevation: 8,
color: Color.alphaBlend(
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08),
Theme.of(context).colorScheme.surface,
),
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: menuWidth, maxWidth: menuWidth, maxHeight: maxHeight),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: widget.options.asMap().entries.map((entry) {
final index = entry.key;
final option = entry.value;
final isSelected = widget.currentValue == option.value;
return FocusableListTile(
focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null,
dense: true,
leading: AppIcon(
isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(option.title),
subtitle: option.subtitle != null ? Text(option.subtitle!) : null,
onTap: () => Navigator.pop(context, option.value),
);
}).toList(),
),
),
),
),
),
],
),
_DialogOption(
value: settings.LibraryDensity.normal,
title: t.settings.normal,
subtitle: t.settings.normalDescription,
),
_DialogOption(
value: settings.LibraryDensity.comfortable,
title: t.settings.comfortable,
subtitle: t.settings.comfortableDescription,
),
],
getCurrentValue: (p) => p.libraryDensity,
onSelect: (value, provider) => provider.setLibraryDensity(value),
);
}
void _showViewModeDialog() {
_showOptionSelectionDialog<settings.ViewMode>(
title: t.settings.viewMode,
options: [
_DialogOption(
value: settings.ViewMode.grid,
title: t.settings.gridView,
subtitle: t.settings.gridViewDescription,
),
_DialogOption(
value: settings.ViewMode.list,
title: t.settings.listView,
subtitle: t.settings.listViewDescription,
),
],
getCurrentValue: (p) => p.viewMode,
onSelect: (value, provider) => provider.setViewMode(value),
);
}
void _showEpisodePosterModeDialog() {
_showOptionSelectionDialog<settings.EpisodePosterMode>(
title: t.settings.episodePosterMode,
options: [
_DialogOption(
value: settings.EpisodePosterMode.seriesPoster,
title: t.settings.seriesPoster,
subtitle: t.settings.seriesPosterDescription,
),
_DialogOption(
value: settings.EpisodePosterMode.seasonPoster,
title: t.settings.seasonPoster,
subtitle: t.settings.seasonPosterDescription,
),
_DialogOption(
value: settings.EpisodePosterMode.episodeThumbnail,
title: t.settings.episodeThumbnail,
subtitle: t.settings.episodeThumbnailDescription,
),
],
getCurrentValue: (p) => p.episodePosterMode,
onSelect: (value, provider) => provider.setEpisodePosterMode(value),
),
);
}
}
+4
View File
@@ -1424,6 +1424,10 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> {
top: top,
child: Material(
elevation: 8,
color: Color.alphaBlend(
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08),
Theme.of(context).colorScheme.surface,
),
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(