fix: option picker dialog focus for TV/keyboard nav

This commit is contained in:
edde746
2026-04-01 06:30:08 +02:00
parent 51a49cfca3
commit fc7892daed
3 changed files with 77 additions and 41 deletions
+61
View File
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import '../focus/focusable_button.dart';
import '../i18n/strings.g.dart';
import '../widgets/app_icon.dart';
import '../widgets/focusable_list_tile.dart';
import 'focus_utils.dart';
/// Utility functions for showing common dialogs
@@ -270,3 +273,61 @@ class _TextInputDialogState extends State<_TextInputDialog> {
);
}
}
/// Shows a simple option picker dialog with focusable items for TV/keyboard navigation.
/// Returns the selected value, or null if cancelled.
Future<T?> showOptionPickerDialog<T>(
BuildContext context, {
required String title,
required List<({IconData icon, String label, T value})> options,
}) {
return showDialog<T>(
context: context,
builder: (context) => _OptionPickerDialog<T>(title: title, options: options),
);
}
class _OptionPickerDialog<T> extends StatefulWidget {
final String title;
final List<({IconData icon, String label, T value})> options;
const _OptionPickerDialog({required this.title, required this.options});
@override
State<_OptionPickerDialog<T>> createState() => _OptionPickerDialogState<T>();
}
class _OptionPickerDialogState<T> extends State<_OptionPickerDialog<T>> {
late final FocusNode _initialFocusNode;
@override
void initState() {
super.initState();
_initialFocusNode = FocusNode(debugLabel: 'OptionPickerInitialFocus');
FocusUtils.requestFocusAfterBuild(this, _initialFocusNode);
}
@override
void dispose() {
_initialFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SimpleDialog(
title: Text(widget.title),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
children: List.generate(widget.options.length, (index) {
final option = widget.options[index];
return FocusableListTile(
focusNode: index == 0 ? _initialFocusNode : null,
leading: AppIcon(option.icon, fill: 1, size: 24),
title: Text(option.label, style: Theme.of(context).textTheme.bodyLarge),
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
onTap: () => Navigator.pop(context, option.value),
);
}),
);
}
}
+9 -21
View File
@@ -4,7 +4,7 @@ import '../models/plex_media_version.dart';
import '../models/plex_metadata.dart';
import '../services/plex_client.dart';
import '../utils/app_logger.dart';
import '../widgets/app_icon.dart';
import '../utils/dialogs.dart';
import '../i18n/strings.g.dart';
/// Configuration for download version selection, threaded through the queue pipeline.
@@ -81,26 +81,14 @@ Future<DownloadVersionConfig?> resolveDownloadVersion(
/// Show a dialog for selecting a media version.
/// Returns the selected index, or null if cancelled.
Future<int?> showVersionPickerDialog(BuildContext context, List<PlexMediaVersion> versions, String title) {
return showDialog<int>(
context: context,
builder: (dialogContext) => SimpleDialog(
title: Text(title),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
children: List.generate(versions.length, (index) {
final version = versions[index];
return SimpleDialogOption(
onPressed: () => Navigator.pop(dialogContext, index),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
children: [
AppIcon(Symbols.video_file_rounded, fill: 1, size: 24),
const SizedBox(width: 16),
Text(version.displayLabel, style: Theme.of(dialogContext).textTheme.bodyLarge),
],
),
);
}),
),
return showOptionPickerDialog<int>(
context,
title: title,
options: List.generate(versions.length, (index) => (
icon: Symbols.video_file_rounded,
label: versions[index].displayLabel,
value: index,
)),
);
}
+7 -20
View File
@@ -674,26 +674,13 @@ class MediaContextMenuState extends State<MediaContextMenu> {
/// Show submenu for Add to... (Playlist or Collection)
Future<void> _showAddToSubmenu(BuildContext context) async {
final submenuActions = [
_MenuAction(value: 'playlist', icon: Symbols.playlist_play_rounded, label: t.playlists.playlist),
_MenuAction(value: 'collection', icon: Symbols.collections_rounded, label: t.collections.collection),
];
final selected = await showDialog<String>(
context: context,
builder: (dialogContext) => SimpleDialog(
title: Text(t.common.addTo),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
children: submenuActions.map((action) {
return SimpleDialogOption(
onPressed: () => Navigator.pop(dialogContext, action.value),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
children: [AppIcon(action.icon, fill: 1, size: 24), const SizedBox(width: 16), Text(action.label, style: Theme.of(dialogContext).textTheme.bodyLarge)],
),
);
}).toList(),
),
final selected = await showOptionPickerDialog<String>(
context,
title: t.common.addTo,
options: [
(icon: Symbols.playlist_play_rounded, label: t.playlists.playlist, value: 'playlist'),
(icon: Symbols.collections_rounded, label: t.collections.collection, value: 'collection'),
],
);
// Handle the submenu selection