diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 61495a3a..1945aa9e 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -18,6 +18,7 @@ import '../utils/app_logger.dart'; import '../utils/platform_detector.dart'; import '../focus/focusable_button.dart'; import '../utils/navigation_transitions.dart'; +import '../widgets/dialog_action_button.dart'; import 'main_screen.dart'; class AuthScreen extends StatefulWidget { @@ -261,8 +262,8 @@ class _AuthScreenState extends State { ], ), actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(t.common.cancel)), - ElevatedButton( + DialogActionButton(onPressed: () => Navigator.of(context).pop(), label: t.common.cancel), + DialogActionButton( onPressed: () async { final token = tokenController.text.trim(); if (token.isEmpty) { @@ -298,7 +299,8 @@ class _AuthScreenState extends State { }); } }, - child: Text(t.auth.authenticate), + label: t.auth.authenticate, + isPrimary: true, ), ], ); diff --git a/lib/screens/companion_remote/mobile_remote_screen.dart b/lib/screens/companion_remote/mobile_remote_screen.dart index b87e1f77..b86312cd 100644 --- a/lib/screens/companion_remote/mobile_remote_screen.dart +++ b/lib/screens/companion_remote/mobile_remote_screen.dart @@ -11,6 +11,7 @@ import '../../providers/companion_remote_provider.dart'; import '../../utils/platform_detector.dart'; import '../../theme/mono_tokens.dart'; import '../../utils/app_logger.dart'; +import '../../utils/dialogs.dart'; import '../../widgets/companion_remote/discovery_view.dart'; import '../../widgets/overlay_sheet.dart'; import '../../widgets/pill_input_decoration.dart'; @@ -36,19 +37,15 @@ class _MobileRemoteScreenState extends State { return IconButton( icon: const Icon(Icons.link_off), onPressed: () async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(t.common.disconnect), - content: Text(t.companionRemote.remote.disconnectConfirm), - actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)), - TextButton(onPressed: () => Navigator.pop(context, true), child: Text(t.common.disconnect)), - ], - ), + final confirmed = await showConfirmDialog( + context, + title: t.common.disconnect, + message: t.companionRemote.remote.disconnectConfirm, + confirmText: t.common.disconnect, + isDestructive: true, ); - if (confirmed == true && context.mounted) { + if (confirmed && context.mounted) { await context.read().leaveSession(); } }, diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index ce7e583a..a156a2da 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -6,7 +6,6 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../focus/focus_theme.dart'; import '../../focus/focusable_action_bar.dart'; -import '../../focus/focusable_button.dart'; import '../../focus/dpad_navigator.dart'; import '../../focus/input_mode_tracker.dart'; import '../../focus/key_event_utils.dart'; @@ -18,6 +17,7 @@ import '../../providers/hidden_libraries_provider.dart'; import '../../providers/libraries_provider.dart'; import '../../providers/multi_server_provider.dart'; import '../../utils/app_logger.dart'; +import '../../utils/dialogs.dart'; import '../../utils/platform_detector.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; @@ -593,29 +593,14 @@ class _LibrariesScreenState extends State if (item == null) return; if (item.requiresConfirmation) { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(item.confirmationTitle ?? t.dialog.confirmAction), - content: Text(item.confirmationMessage ?? t.libraries.confirmActionMessage), - actions: [ - FocusableButton( - autofocus: true, - onPressed: () => Navigator.pop(context, false), - child: TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)), - ), - FocusableButton( - onPressed: () => Navigator.pop(context, true), - child: TextButton( - onPressed: () => Navigator.pop(context, true), - style: item.isDestructive ? TextButton.styleFrom(foregroundColor: Colors.red) : null, - child: Text(t.common.confirm), - ), - ), - ], - ), + final confirmed = await showConfirmDialog( + context, + title: item.confirmationTitle ?? t.dialog.confirmAction, + message: item.confirmationMessage ?? t.libraries.confirmActionMessage, + confirmText: t.common.confirm, + isDestructive: item.isDestructive, ); - if (confirmed != true) return; + if (!confirmed) return; } switch (action) { diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 924dc5d9..59e1289d 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -12,7 +12,7 @@ import '../../services/plex_client.dart'; import '../i18n/strings.g.dart'; import '../services/update_service.dart'; import '../utils/app_logger.dart'; -import '../focus/focusable_button.dart'; +import '../widgets/dialog_action_button.dart'; import '../utils/dialogs.dart'; import '../utils/provider_extensions.dart'; import '../utils/platform_detector.dart'; @@ -254,36 +254,15 @@ class _MainScreenState extends State with RouteAware, WindowListener ], ), actions: [ - FocusableButton( - autofocus: true, - onPressed: () => Navigator.pop(dialogContext), - child: TextButton( - onPressed: () => Navigator.pop(dialogContext), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), - shape: const StadiumBorder(), - ), - child: Text(t.common.later), - ), - ), - FocusableButton( + DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.later), + DialogActionButton( onPressed: () async { await UpdateService.skipVersion(updateInfo['latestVersion']); if (dialogContext.mounted) Navigator.pop(dialogContext); }, - child: TextButton( - onPressed: () async { - await UpdateService.skipVersion(updateInfo['latestVersion']); - if (dialogContext.mounted) Navigator.pop(dialogContext); - }, - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), - shape: const StadiumBorder(), - ), - child: Text(t.update.skipVersion), - ), + label: t.update.skipVersion, ), - FocusableButton( + DialogActionButton( onPressed: () async { final url = Uri.parse(updateInfo['releaseUrl']); if (await canLaunchUrl(url)) { @@ -291,16 +270,8 @@ class _MainScreenState extends State with RouteAware, WindowListener } if (dialogContext.mounted) Navigator.pop(dialogContext); }, - child: FilledButton( - onPressed: () async { - final url = Uri.parse(updateInfo['releaseUrl']); - if (await canLaunchUrl(url)) { - await launchUrl(url, mode: LaunchMode.externalApplication); - } - if (dialogContext.mounted) Navigator.pop(dialogContext); - }, - child: Text(t.update.viewRelease), - ), + label: t.update.viewRelease, + isPrimary: true, ), ], ); diff --git a/lib/screens/settings/playback_settings_screen.dart b/lib/screens/settings/playback_settings_screen.dart index ef6c18cd..afbb349a 100644 --- a/lib/screens/settings/playback_settings_screen.dart +++ b/lib/screens/settings/playback_settings_screen.dart @@ -591,7 +591,7 @@ class _PlaybackSettingsScreenState extends State { title: Text(t.settings.introPattern), subtitle: Text(t.settings.introPatternDescription), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showTextInputDialog( + onTap: () => showRegexInputDialog( context: context, title: t.settings.introPattern, currentValue: _introPattern, @@ -610,7 +610,7 @@ class _PlaybackSettingsScreenState extends State { title: Text(t.settings.creditsPattern), subtitle: Text(t.settings.creditsPatternDescription), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => showTextInputDialog( + onTap: () => showRegexInputDialog( context: context, title: t.settings.creditsPattern, currentValue: _creditsPattern, diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 95b1f806..b98c30ae 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -28,9 +28,11 @@ import '../../providers/trakt_account_provider.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../services/settings_service.dart' as settings; import '../../services/update_service.dart'; +import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/desktop_app_bar.dart'; +import '../../widgets/dialog_action_button.dart'; import '../../widgets/settings_section.dart'; import 'about_screen.dart'; import 'appearance_settings_screen.dart'; @@ -557,20 +559,21 @@ class _SettingsScreenState extends State with FocusableTab { ), actions: [ if (isCustom) - TextButton( + DialogActionButton( onPressed: () async { Navigator.pop(dialogContext); await _resetDownloadLocation(); }, - child: Text(t.settings.resetToDefault), + label: t.settings.resetToDefault, ), - TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), - FilledButton( + DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), + DialogActionButton( onPressed: () async { Navigator.pop(dialogContext); await _selectDownloadLocation(); }, - child: Text(t.settings.selectFolder), + label: t.settings.selectFolder, + isPrimary: true, ), ], ), @@ -654,17 +657,17 @@ class _SettingsScreenState extends State with FocusableTab { onEditingComplete: () => saveFocusNode.requestFocus(), ), actions: [ - TextButton( + DialogActionButton( onPressed: () async { controller.clear(); await _settingsService.setCustomRelayUrl(null); if (mounted) setState(() => _customRelayUrl = null); if (dialogContext.mounted) Navigator.pop(dialogContext); }, - child: Text(t.settings.resetToDefault), + label: t.settings.resetToDefault, ), - TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), - TextButton( + DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), + DialogActionButton( focusNode: saveFocusNode, onPressed: () async { final url = controller.text.trim().isEmpty ? null : controller.text.trim(); @@ -672,7 +675,7 @@ class _SettingsScreenState extends State with FocusableTab { if (mounted) setState(() => _customRelayUrl = url); if (dialogContext.mounted) Navigator.pop(dialogContext); }, - child: Text(t.common.save), + label: t.common.save, ), ], ); @@ -683,58 +686,33 @@ class _SettingsScreenState extends State with FocusableTab { }); } - void _showClearCacheDialog() { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text(t.settings.clearCache), - content: Text(t.settings.clearCacheDescription), - actions: [ - TextButton(autofocus: true, onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), - TextButton( - onPressed: () async { - final navigator = Navigator.of(context); - await _settingsService.clearCache(); - if (mounted) { - navigator.pop(); - showSuccessSnackBar(this.context, t.settings.clearCacheSuccess); - } - }, - child: Text(t.common.clear), - ), - ], - ); - }, + Future _showClearCacheDialog() async { + final confirmed = await showConfirmDialog( + context, + title: t.settings.clearCache, + message: t.settings.clearCacheDescription, + confirmText: t.common.clear, ); + if (!confirmed) return; + await _settingsService.clearCache(); + if (mounted) showSuccessSnackBar(context, t.settings.clearCacheSuccess); } - void _showResetSettingsDialog() { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text(t.settings.resetSettings), - content: Text(t.settings.resetSettingsDescription), - actions: [ - TextButton(autofocus: true, onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), - TextButton( - onPressed: () async { - final navigator = Navigator.of(context); - await _settingsService.resetAllSettings(); - await _keyboardService?.resetToDefaults(); - if (mounted) { - navigator.pop(); - showSuccessSnackBar(this.context, t.settings.resetSettingsSuccess); - _loadSettings(); - } - }, - child: Text(t.common.reset), - ), - ], - ); - }, + Future _showResetSettingsDialog() async { + final confirmed = await showConfirmDialog( + context, + title: t.settings.resetSettings, + message: t.settings.resetSettingsDescription, + confirmText: t.common.reset, + isDestructive: true, ); + if (!confirmed) return; + await _settingsService.resetAllSettings(); + await _keyboardService?.resetToDefaults(); + if (mounted) { + showSuccessSnackBar(context, t.settings.resetSettingsSuccess); + _loadSettings(); + } } Future _handleExportSettings() async { @@ -750,26 +728,15 @@ class _SettingsScreenState extends State with FocusableTab { } } - void _showImportSettingsDialog() { - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return AlertDialog( - title: Text(t.settings.importSettings), - content: Text(t.settings.importSettingsConfirm), - actions: [ - TextButton(autofocus: true, onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), - TextButton( - onPressed: () { - Navigator.pop(dialogContext); - _handleImportSettings(); - }, - child: Text(t.settings.importSettings), - ), - ], - ); - }, + Future _showImportSettingsDialog() async { + final confirmed = await showConfirmDialog( + context, + title: t.settings.importSettings, + message: t.settings.importSettingsConfirm, + confirmText: t.settings.importSettings, ); + if (!confirmed) return; + await _handleImportSettings(); } Future _handleImportSettings() async { @@ -856,8 +823,8 @@ class _SettingsScreenState extends State with FocusableTab { ], ), actions: [ - TextButton(autofocus: true, onPressed: () => Navigator.pop(context), child: Text(t.common.close)), - FilledButton( + DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.close), + DialogActionButton( onPressed: () async { final url = Uri.parse(_updateInfo!['releaseUrl']); if (await canLaunchUrl(url)) { @@ -865,7 +832,8 @@ class _SettingsScreenState extends State with FocusableTab { } if (context.mounted) Navigator.pop(context); }, - child: Text(t.update.viewRelease), + label: t.update.viewRelease, + isPrimary: true, ), ], ); diff --git a/lib/screens/settings/settings_utils.dart b/lib/screens/settings/settings_utils.dart index 50b590ab..42d8d310 100644 --- a/lib/screens/settings/settings_utils.dart +++ b/lib/screens/settings/settings_utils.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import '../../focus/input_mode_tracker.dart'; import '../../i18n/strings.g.dart'; +import '../../widgets/dialog_action_button.dart'; +import '../../widgets/focusable_list_tile.dart'; import '../../widgets/tv_number_spinner.dart'; /// Model for option selection dialogs. @@ -13,7 +15,7 @@ class DialogOption { const DialogOption({required this.value, required this.title, this.subtitle}); } -/// Shows an M3-conformant AlertDialog with RadioListTiles for multi-option selection. +/// Shows a selection dialog with focusable rows for dpad/keyboard navigation. /// Used for settings with 5+ options (language, buffer size, etc.). Future showSelectionDialog({ required BuildContext context, @@ -21,27 +23,29 @@ Future showSelectionDialog({ required List> options, required T currentValue, }) { + final focusFirstItem = InputModeTracker.isKeyboardMode(context); return showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(title), contentPadding: const EdgeInsets.only(top: 12, bottom: 24), content: SingleChildScrollView( - child: RadioGroup( - groupValue: currentValue, - onChanged: (value) => Navigator.pop(dialogContext, value), - child: Column( - mainAxisSize: MainAxisSize.min, - children: options - .map( - (option) => RadioListTile( - title: Text(option.title), - subtitle: option.subtitle != null ? Text(option.subtitle!) : null, - value: option.value, - ), - ) - .toList(), - ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: options.map((option) { + final selected = option.value == currentValue; + return FocusableListTile( + leading: Icon( + selected ? Icons.radio_button_checked : Icons.radio_button_unchecked, + color: selected ? Theme.of(dialogContext).colorScheme.primary : null, + ), + title: Text(option.title), + subtitle: option.subtitle != null ? Text(option.subtitle!) : null, + selected: selected, + autofocus: focusFirstItem && selected, + onTap: () => Navigator.pop(dialogContext, option.value), + ); + }).toList(), ), ), ), @@ -133,8 +137,8 @@ void _showNumericInputDialogTV({ ], ), actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), - TextButton( + DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), + DialogActionButton( focusNode: saveFocusNode, onPressed: () async { await onSave(spinnerValue); @@ -142,7 +146,7 @@ void _showNumericInputDialogTV({ Navigator.pop(dialogContext); } }, - child: Text(t.common.save), + label: t.common.save, ), ], ); @@ -201,8 +205,8 @@ void _showNumericInputDialogStandard({ }, ), actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), - TextButton( + DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), + DialogActionButton( focusNode: saveFocusNode, onPressed: () async { final parsed = int.tryParse(controller.text); @@ -213,7 +217,7 @@ void _showNumericInputDialogStandard({ } } }, - child: Text(t.common.save), + label: t.common.save, ), ], ); @@ -227,7 +231,7 @@ void _showNumericInputDialogStandard({ } /// Shows a text input dialog with regex validation and reset-to-default support. -void showTextInputDialog({ +void showRegexInputDialog({ required BuildContext context, required String title, required String currentValue, @@ -263,22 +267,22 @@ void showTextInputDialog({ }, ), actions: [ - TextButton( + DialogActionButton( onPressed: () { controller.text = defaultValue; setDialogState(() => errorText = null); }, - child: Text(t.settings.resetToDefault), + label: t.settings.resetToDefault, ), - TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), - TextButton( + DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), + DialogActionButton( focusNode: saveFocusNode, onPressed: () async { if (errorText != null) return; await onSave(controller.text); if (dialogContext.mounted) Navigator.pop(dialogContext); }, - child: Text(t.common.save), + label: t.common.save, ), ], ); diff --git a/lib/screens/settings/trakt_settings_screen.dart b/lib/screens/settings/trakt_settings_screen.dart index 587d1ff8..5c04fb31 100644 --- a/lib/screens/settings/trakt_settings_screen.dart +++ b/lib/screens/settings/trakt_settings_screen.dart @@ -14,7 +14,9 @@ import '../../services/settings_service.dart'; import '../../services/trakt/trakt_scrobble_service.dart'; import '../../services/trakt/trakt_sync_service.dart'; import '../../utils/app_logger.dart'; +import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; +import '../../widgets/dialog_action_button.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/settings_section.dart'; @@ -99,21 +101,14 @@ class _TraktSettingsScreenState extends State { } Future _disconnect(TraktAccountProvider account) async { - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(t.trakt.disconnectConfirm), - content: Text(t.trakt.disconnectConfirmBody), - actions: [ - TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.common.cancel)), - TextButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: Text(t.common.disconnect, style: TextStyle(color: Theme.of(ctx).colorScheme.error)), - ), - ], - ), + final confirmed = await showConfirmDialog( + context, + title: t.trakt.disconnectConfirm, + message: t.trakt.disconnectConfirmBody, + confirmText: t.common.disconnect, + isDestructive: true, ); - if (confirmed != true) return; + if (!confirmed) return; await account.disconnect(); // build()'s post-frame handler pops the screen once the provider rebuilds // with isConnected == false — don't pop here too. @@ -262,12 +257,12 @@ class _DeviceCodeDialogState extends State<_DeviceCodeDialog> { ], ), actions: [ - TextButton( + DialogActionButton( onPressed: () { widget.account.cancelConnect(); Navigator.of(context, rootNavigator: true).pop(); }, - child: Text(t.common.cancel), + label: t.common.cancel, ), ], ); diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index dc19c800..45be7d9c 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -14,6 +14,7 @@ import '../../utils/app_logger.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; +import '../../widgets/dialog_action_button.dart'; import '../../utils/video_player_navigation.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../models/watch_session.dart'; @@ -241,37 +242,21 @@ class _NotInSessionViewState extends State<_NotInSessionView> { } Future _showControlModeDialog() { - const buttonPadding = EdgeInsets.symmetric(horizontal: 18, vertical: 14); - const buttonShape = StadiumBorder(); return showDialog( context: context, builder: (context) => AlertDialog( title: Text(t.watchTogether.controlMode), content: Text(t.watchTogether.controlModeQuestion), actions: [ - FocusableButton( - autofocus: true, - onPressed: () => Navigator.pop(context), - child: TextButton( - onPressed: () => Navigator.pop(context), - style: TextButton.styleFrom(padding: buttonPadding, shape: buttonShape), - child: Text(t.common.cancel), - ), - ), - FocusableButton( + DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel), + DialogActionButton( onPressed: () => Navigator.pop(context, ControlMode.hostOnly), - child: TextButton( - onPressed: () => Navigator.pop(context, ControlMode.hostOnly), - style: TextButton.styleFrom(padding: buttonPadding, shape: buttonShape), - child: Text(t.watchTogether.hostOnly), - ), + label: t.watchTogether.hostOnly, ), - FocusableButton( + DialogActionButton( onPressed: () => Navigator.pop(context, ControlMode.anyone), - child: FilledButton( - onPressed: () => Navigator.pop(context, ControlMode.anyone), - child: Text(t.watchTogether.anyone), - ), + label: t.watchTogether.anyone, + isPrimary: true, ), ], ),