import 'dart:io'; import 'package:flex_color_picker/flex_color_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../focus/focusable_text_field.dart'; import '../../focus/input_mode_tracker.dart'; import '../../i18n/strings.g.dart'; import '../../services/settings_service.dart' as settings; import '../../utils/dialogs.dart'; import '../../utils/app_logger.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/dialog_action_button.dart'; import '../../widgets/focusable_list_tile.dart'; import '../../widgets/tv_color_picker.dart'; import '../../widgets/tv_number_spinner.dart'; String themeModeLabel(settings.ThemeMode mode) => switch (mode) { settings.ThemeMode.system => t.settings.systemTheme, settings.ThemeMode.light => t.settings.lightTheme, settings.ThemeMode.dark => t.settings.darkTheme, settings.ThemeMode.oled => t.settings.oledTheme, }; /// Model for option selection dialogs. class DialogOption { final T value; final String title; final String? subtitle; const DialogOption({required this.value, required this.title, this.subtitle}); } typedef _SettingsDialogContentBuilder = Widget Function( BuildContext dialogContext, BuildContext contentContext, StateSetter setDialogState, FocusNode saveFocusNode, ); typedef _SettingsDialogActionsBuilder = List Function(BuildContext dialogContext, StateSetter setDialogState); /// Reports a recoverable settings persistence failure without swallowing /// programming errors or other unexpected exception types. void showSettingsFailure( BuildContext context, { required String operation, required Object error, required StackTrace stackTrace, }) { appLogger.e('$operation failed', error: error, stackTrace: stackTrace); if (context.mounted) showErrorSnackBar(context, t.settings.saveFailed); } void _showSettingsInputDialog({ required BuildContext context, required String title, required _SettingsDialogContentBuilder contentBuilder, required Future Function(BuildContext dialogContext) onSave, _SettingsDialogActionsBuilder? leadingActionsBuilder, VoidCallback? onDispose, }) { showScopedDialog( context: context, builder: (_) => _SettingsInputDialog( title: title, contentBuilder: contentBuilder, onSave: onSave, leadingActionsBuilder: leadingActionsBuilder, onDispose: onDispose, ), ); } class _SettingsInputDialog extends StatefulWidget { final String title; final _SettingsDialogContentBuilder contentBuilder; final Future Function(BuildContext dialogContext) onSave; final _SettingsDialogActionsBuilder? leadingActionsBuilder; final VoidCallback? onDispose; const _SettingsInputDialog({ required this.title, required this.contentBuilder, required this.onSave, this.leadingActionsBuilder, this.onDispose, }); @override State<_SettingsInputDialog> createState() => _SettingsInputDialogState(); } class _SettingsInputDialogState extends State<_SettingsInputDialog> { final _saveFocusNode = FocusNode(debugLabel: 'SettingsInputSave'); @override void dispose() { _saveFocusNode.dispose(); widget.onDispose?.call(); super.dispose(); } Future _save() async { try { final shouldClose = await widget.onSave(context); if (shouldClose && mounted) Navigator.pop(context); } on PlatformException catch (error, stackTrace) { if (!mounted) return; showSettingsFailure(context, operation: 'Settings input save', error: error, stackTrace: stackTrace); } on FileSystemException catch (error, stackTrace) { if (!mounted) return; showSettingsFailure(context, operation: 'Settings input save', error: error, stackTrace: stackTrace); } } @override Widget build(BuildContext context) { return AlertDialog( title: Text(widget.title), content: widget.contentBuilder(context, context, setState, _saveFocusNode), actions: [ ...?widget.leadingActionsBuilder?.call(context, setState), DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel), DialogActionButton(focusNode: _saveFocusNode, onPressed: _save, label: t.common.save), ], ); } } /// 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, required String title, required List> options, required T currentValue, }) { final focusFirstItem = InputModeTracker.isKeyboardMode(context, listen: false); return showScopedDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(title), contentPadding: const EdgeInsets.only(top: 12, bottom: 24), content: SingleChildScrollView( child: Column( mainAxisSize: .min, children: options.map((option) { final selected = option.value == currentValue; return FocusableListTile( key: ValueKey(option.value), leading: AppIcon( selected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, 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(), ), ), ), ); } /// Generic numeric input dialog. /// On TV/keyboard mode, uses a spinner widget with +/- buttons for D-pad navigation. /// On other platforms, uses a TextField with focus management. void showNumericInputDialog({ required BuildContext context, required String title, required String labelText, required String suffixText, required int min, required int max, required int currentValue, required Future Function(int value) onSave, }) { final useDpadControls = InputModeTracker.isKeyboardMode(context, listen: false); if (useDpadControls) { _showNumericInputDialogTV( context: context, title: title, suffixText: suffixText, min: min, max: max, currentValue: currentValue, onSave: onSave, ); } else { _showNumericInputDialogStandard( context: context, title: title, labelText: labelText, suffixText: suffixText, min: min, max: max, currentValue: currentValue, onSave: onSave, ); } } void _showNumericInputDialogTV({ required BuildContext context, required String title, required String suffixText, required int min, required int max, required int currentValue, required Future Function(int value) onSave, }) { int spinnerValue = currentValue; _showSettingsInputDialog( context: context, title: title, contentBuilder: (dialogContext, context, setDialogState, saveFocusNode) { return Column( mainAxisSize: .min, children: [ TvNumberSpinner( value: spinnerValue, min: min, max: max, suffix: suffixText, autofocus: true, onChanged: (value) { setDialogState(() { spinnerValue = value; }); }, onConfirm: () => saveFocusNode.requestFocus(), onCancel: () => Navigator.pop(dialogContext), ), const SizedBox(height: 8), Text( t.settings.durationHint(min: min, max: max), style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ], ); }, onSave: (_) async { await onSave(spinnerValue); return true; }, ); } void _showNumericInputDialogStandard({ required BuildContext context, required String title, required String labelText, required String suffixText, required int min, required int max, required int currentValue, required Future Function(int value) onSave, }) { final controller = TextEditingController(text: currentValue.toString()); String? errorText; _showSettingsInputDialog( context: context, title: title, contentBuilder: (_, _, setDialogState, saveFocusNode) { return FocusableTextField( controller: controller, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: labelText, hintText: t.settings.durationHint(min: min, max: max), errorText: errorText, suffixText: suffixText, ), autofocus: true, textInputAction: TextInputAction.done, onEditingComplete: () { saveFocusNode.requestFocus(); }, onChanged: (value) { final parsed = int.tryParse(value); setDialogState(() { if (parsed == null) { errorText = t.settings.validationErrorEnterNumber; } else if (parsed < min || parsed > max) { errorText = t.settings.validationErrorDuration(min: min, max: max, unit: labelText.toLowerCase()); } else { errorText = null; } }); }, ); }, onSave: (_) async { final parsed = int.tryParse(controller.text); if (parsed == null || parsed < min || parsed > max) return false; await onSave(parsed); return true; }, onDispose: controller.dispose, ); } /// Convert `#RRGGBB` (or `#AARRGGBB`) hex to [Color]. Defaults to black on parse error. Color hexToColor(String hex) { final buffer = StringBuffer(); if (hex.length == 7) buffer.write('ff'); buffer.write(hex.replaceFirst('#', '')); return Color(int.tryParse(buffer.toString(), radix: 16) ?? 0xff000000); } /// Convert [Color] to `#RRGGBB` hex (uppercase). Drops alpha. String colorToHex(Color color) { String two(num c) => ((c * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0'); return '#${two(color.r)}${two(color.g)}${two(color.b)}'.toUpperCase(); } /// Shows a color picker dialog. Uses [TvColorPicker] in keyboard/D-pad mode, /// otherwise the standard FlexColorPicker. Calls [onSave] with `#RRGGBB`. void showColorInputDialog({ required BuildContext context, required String title, required String currentHex, required Future Function(String hex) onSave, }) { if (InputModeTracker.isKeyboardMode(context, listen: false)) { _showColorInputDialogTV(context: context, title: title, currentHex: currentHex, onSave: onSave); } else { _showColorInputDialogStandard(context: context, title: title, currentHex: currentHex, onSave: onSave); } } Future _showColorInputDialogStandard({ required BuildContext context, required String title, required String currentHex, required Future Function(String hex) onSave, }) async { final initial = hexToColor(currentHex); final selected = await showColorPickerDialog( context, initial, title: Text(title), barrierColor: Colors.black54, width: 40, height: 40, spacing: 0, runSpacing: 0, borderRadius: 4, wheelDiameter: 165, enableOpacity: false, showColorCode: true, colorCodeHasColor: true, pickersEnabled: const { ColorPickerType.both: false, ColorPickerType.primary: true, ColorPickerType.accent: false, ColorPickerType.wheel: true, ColorPickerType.custom: false, }, actionButtons: const ColorPickerActionButtons(okButton: true, closeButton: true, dialogActionButtons: false), ); if (selected == initial || !context.mounted) return; try { await onSave(colorToHex(selected)); } on PlatformException catch (error, stackTrace) { if (!context.mounted) return; showSettingsFailure(context, operation: 'Color setting save', error: error, stackTrace: stackTrace); } on FileSystemException catch (error, stackTrace) { if (!context.mounted) return; showSettingsFailure(context, operation: 'Color setting save', error: error, stackTrace: stackTrace); } } void _showColorInputDialogTV({ required BuildContext context, required String title, required String currentHex, required Future Function(String hex) onSave, }) { Color picked = hexToColor(currentHex); _showSettingsInputDialog( context: context, title: title, contentBuilder: (_, _, setDialogState, saveFocusNode) { return TvColorPicker( initialColor: picked, onColorChanged: (c) => setDialogState(() => picked = c), onConfirm: () => saveFocusNode.requestFocus(), ); }, onSave: (_) async { await onSave(colorToHex(picked)); return true; }, ); } void showRegexInputDialog({ required BuildContext context, required String title, required String currentValue, required String defaultValue, required Future Function(String value) onSave, }) { final controller = TextEditingController(text: currentValue); String? errorText; _showSettingsInputDialog( context: context, title: title, contentBuilder: (_, _, setDialogState, saveFocusNode) { return FocusableTextField( controller: controller, decoration: InputDecoration(labelText: t.settings.regex, errorText: errorText), autofocus: true, textInputAction: TextInputAction.done, onEditingComplete: () => saveFocusNode.requestFocus(), onChanged: (value) { setDialogState(() { try { RegExp(value, caseSensitive: false); errorText = null; } catch (_) { errorText = t.settings.invalidRegex; } }); }, ); }, leadingActionsBuilder: (_, setDialogState) => [ DialogActionButton( onPressed: () { controller.text = defaultValue; setDialogState(() => errorText = null); }, label: t.settings.resetToDefault, ), ], onSave: (_) async { if (errorText != null) return false; await onSave(controller.text); return true; }, onDispose: controller.dispose, ); }