import 'package:flex_color_picker/flex_color_picker.dart'; import 'package:flutter/material.dart'; import '../../focus/focusable_text_field.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_color_picker.dart'; import '../../widgets/tv_number_spinner.dart'; /// 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); void _showSettingsInputDialog({ required BuildContext context, required String title, required _SettingsDialogContentBuilder contentBuilder, required Future Function(BuildContext dialogContext) onSave, _SettingsDialogActionsBuilder? leadingActionsBuilder, VoidCallback? onDispose, }) { final saveFocusNode = FocusNode(); showDialog( context: context, builder: (BuildContext dialogContext) { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( title: Text(title), content: contentBuilder(dialogContext, context, setDialogState, saveFocusNode), actions: [ ...?leadingActionsBuilder?.call(dialogContext, setDialogState), DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel), DialogActionButton( focusNode: saveFocusNode, onPressed: () async { final shouldClose = await onSave(dialogContext); if (shouldClose && dialogContext.mounted) { Navigator.pop(dialogContext); } }, label: t.common.save, ), ], ); }, ); }, ).then((_) { saveFocusNode.dispose(); onDispose?.call(); }); } /// 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); return showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(title), contentPadding: const EdgeInsets.only(top: 12, bottom: 24), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: options.map((option) { final selected = option.value == currentValue; return FocusableListTile( key: ValueKey(option.value), 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(), ), ), ), ); } /// 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); 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: 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)) { _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) await onSave(colorToHex(selected)); } 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; }, ); } /// Shows a text input dialog with regex validation and reset-to-default support. 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: '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, ); }