404 lines
12 KiB
Dart
404 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import '../focus/focusable_button.dart';
|
|
import '../focus/focusable_text_field.dart';
|
|
import '../focus/input_mode_tracker.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../mixins/controller_disposer_mixin.dart';
|
|
import '../widgets/app_icon.dart';
|
|
import '../widgets/dialog_action_button.dart';
|
|
import '../widgets/focusable_list_tile.dart';
|
|
import 'focus_utils.dart';
|
|
|
|
const _buttonPadding = EdgeInsets.symmetric(horizontal: 18, vertical: 14);
|
|
const _buttonShape = StadiumBorder();
|
|
|
|
/// Shows a dialog on the nearest navigator instead of Flutter's default root
|
|
/// navigator. Use this for profile/session-owned modal routes so they are
|
|
/// disposed when the active profile session is replaced.
|
|
Future<T?> showScopedDialog<T>({
|
|
required BuildContext context,
|
|
required WidgetBuilder builder,
|
|
bool barrierDismissible = true,
|
|
}) {
|
|
return showDialog<T>(
|
|
context: context,
|
|
builder: builder,
|
|
barrierDismissible: barrierDismissible,
|
|
useRootNavigator: false,
|
|
);
|
|
}
|
|
|
|
/// Shows a confirmation dialog with consistent button sizing and autofocus.
|
|
/// Returns true if user confirmed, false if cancelled.
|
|
Future<bool> showConfirmDialog(
|
|
BuildContext context, {
|
|
required String title,
|
|
required String message,
|
|
required String confirmText,
|
|
String? cancelText,
|
|
bool isDestructive = false,
|
|
}) async {
|
|
final confirmed = await showScopedDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
final colorScheme = Theme.of(dialogContext).colorScheme;
|
|
return AlertDialog(
|
|
title: Text(title),
|
|
content: Text(message),
|
|
actions: [
|
|
FocusableButton(
|
|
autofocus: true,
|
|
onPressed: () => Navigator.pop(dialogContext, false),
|
|
child: TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, false),
|
|
style: TextButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
|
|
child: Text(cancelText ?? t.common.cancel),
|
|
),
|
|
),
|
|
FocusableButton(
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
|
child: FilledButton(
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
|
style: isDestructive
|
|
? FilledButton.styleFrom(backgroundColor: colorScheme.error, foregroundColor: colorScheme.onError)
|
|
: null,
|
|
child: Text(confirmText),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
return confirmed ?? false;
|
|
}
|
|
|
|
/// Shows a non-dismissible loading-spinner dialog. Caller is responsible for
|
|
/// closing it via `Navigator.pop(context)` when the work completes.
|
|
void showLoadingDialog(BuildContext context) {
|
|
showScopedDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (_) => const Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
|
|
/// Shows the server-side 500 modal (bandwidth/transcoding limit rejection).
|
|
Future<void> showServerLimitDialog(BuildContext context) async {
|
|
await showScopedDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(t.messages.serverLimitTitle),
|
|
content: Text(t.messages.serverLimitBody),
|
|
actions: [
|
|
FocusableButton(
|
|
autofocus: true,
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: FilledButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
style: FilledButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
|
|
child: Text(t.common.close),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Shows a delete confirmation dialog.
|
|
/// Convenience wrapper around [showConfirmDialog] with destructive styling.
|
|
Future<bool> showDeleteConfirmation(
|
|
BuildContext context, {
|
|
required String title,
|
|
required String message,
|
|
String? confirmText,
|
|
}) {
|
|
return showConfirmDialog(
|
|
context,
|
|
title: title,
|
|
message: message,
|
|
confirmText: confirmText ?? t.common.delete,
|
|
isDestructive: true,
|
|
);
|
|
}
|
|
|
|
/// Shows a text input dialog for creating/naming items
|
|
/// Returns the entered text, or null if cancelled
|
|
Future<String?> showTextInputDialog(
|
|
BuildContext context, {
|
|
required String title,
|
|
required String labelText,
|
|
required String hintText,
|
|
String? initialValue,
|
|
String? confirmText,
|
|
TextInputType? keyboardType,
|
|
List<TextInputFormatter>? inputFormatters,
|
|
String? Function(String)? validator,
|
|
bool allowEmpty = false,
|
|
}) {
|
|
return showScopedDialog<String>(
|
|
context: context,
|
|
builder: (context) => _TextInputDialog(
|
|
title: title,
|
|
labelText: labelText,
|
|
hintText: hintText,
|
|
initialValue: initialValue,
|
|
confirmText: confirmText,
|
|
keyboardType: keyboardType,
|
|
inputFormatters: inputFormatters,
|
|
validator: validator,
|
|
allowEmpty: allowEmpty,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Shows a multiline text input dialog for editing longer text like summaries.
|
|
/// Returns the entered text, or null if cancelled.
|
|
/// Allows empty text to be submitted (for clearing fields).
|
|
Future<String?> showMultilineTextInputDialog(
|
|
BuildContext context, {
|
|
required String title,
|
|
required String labelText,
|
|
String? initialValue,
|
|
}) {
|
|
return showScopedDialog<String>(
|
|
context: context,
|
|
builder: (context) => _MultilineTextInputDialog(title: title, labelText: labelText, initialValue: initialValue),
|
|
);
|
|
}
|
|
|
|
/// Shared lifecycle for the two private text-input dialogs below: a single
|
|
/// [TextEditingController] seeded from [initialValue], plus a focus node for
|
|
/// the save button.
|
|
mixin _TextInputDialogStateMixin<T extends StatefulWidget> on State<T>, ControllerDisposerMixin<T> {
|
|
late final TextEditingController _controller;
|
|
final _fieldFocusNode = FocusNode();
|
|
final _cancelFocusNode = FocusNode();
|
|
final _saveFocusNode = FocusNode();
|
|
|
|
String? get initialValue;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = createTextEditingController(text: initialValue);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_fieldFocusNode.dispose();
|
|
_cancelFocusNode.dispose();
|
|
_saveFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
class _MultilineTextInputDialog extends StatefulWidget {
|
|
final String title;
|
|
final String labelText;
|
|
final String? initialValue;
|
|
|
|
const _MultilineTextInputDialog({required this.title, required this.labelText, this.initialValue});
|
|
|
|
@override
|
|
State<_MultilineTextInputDialog> createState() => _MultilineTextInputDialogState();
|
|
}
|
|
|
|
class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog>
|
|
with ControllerDisposerMixin, _TextInputDialogStateMixin<_MultilineTextInputDialog> {
|
|
@override
|
|
String? get initialValue => widget.initialValue;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(widget.title),
|
|
content: SizedBox(
|
|
width: 400,
|
|
child: FocusableTextField(
|
|
controller: _controller,
|
|
focusNode: _fieldFocusNode,
|
|
autofocus: true,
|
|
decoration: InputDecoration(labelText: widget.labelText),
|
|
keyboardType: TextInputType.multiline,
|
|
maxLines: 8,
|
|
minLines: 3,
|
|
onNavigateDown: _saveFocusNode.requestFocus,
|
|
),
|
|
),
|
|
actions: [
|
|
DialogActionButton(
|
|
focusNode: _cancelFocusNode,
|
|
onPressed: () => Navigator.pop(context),
|
|
onNavigateRight: _saveFocusNode.requestFocus,
|
|
label: t.common.cancel,
|
|
),
|
|
DialogActionButton(
|
|
onPressed: () => Navigator.pop(context, _controller.text),
|
|
label: t.common.save,
|
|
focusNode: _saveFocusNode,
|
|
onNavigateLeft: _cancelFocusNode.requestFocus,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TextInputDialog extends StatefulWidget {
|
|
final String title;
|
|
final String labelText;
|
|
final String hintText;
|
|
final String? initialValue;
|
|
final String? confirmText;
|
|
final TextInputType? keyboardType;
|
|
final List<TextInputFormatter>? inputFormatters;
|
|
final String? Function(String)? validator;
|
|
final bool allowEmpty;
|
|
|
|
const _TextInputDialog({
|
|
required this.title,
|
|
required this.labelText,
|
|
required this.hintText,
|
|
this.initialValue,
|
|
this.confirmText,
|
|
this.keyboardType,
|
|
this.inputFormatters,
|
|
this.validator,
|
|
this.allowEmpty = false,
|
|
});
|
|
|
|
@override
|
|
State<_TextInputDialog> createState() => _TextInputDialogState();
|
|
}
|
|
|
|
class _TextInputDialogState extends State<_TextInputDialog>
|
|
with ControllerDisposerMixin, _TextInputDialogStateMixin<_TextInputDialog> {
|
|
@override
|
|
String? get initialValue => widget.initialValue;
|
|
|
|
void _submit() {
|
|
final text = _controller.text;
|
|
if (text.isEmpty && !widget.allowEmpty) return;
|
|
if (widget.validator != null && widget.validator!(text) != null) return;
|
|
Navigator.pop(context, text);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(widget.title),
|
|
content: FocusableTextField(
|
|
controller: _controller,
|
|
focusNode: _fieldFocusNode,
|
|
autofocus: true,
|
|
decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText),
|
|
keyboardType: widget.keyboardType,
|
|
inputFormatters: widget.inputFormatters,
|
|
textInputAction: TextInputAction.done,
|
|
onNavigateDown: _saveFocusNode.requestFocus,
|
|
onSubmitted: (_) => _saveFocusNode.requestFocus(),
|
|
),
|
|
actions: [
|
|
DialogActionButton(
|
|
focusNode: _cancelFocusNode,
|
|
onPressed: () => Navigator.pop(context),
|
|
onNavigateRight: _saveFocusNode.requestFocus,
|
|
label: t.common.cancel,
|
|
),
|
|
DialogActionButton(
|
|
onPressed: _submit,
|
|
label: widget.confirmText ?? t.common.save,
|
|
focusNode: _saveFocusNode,
|
|
onNavigateLeft: _cancelFocusNode.requestFocus,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Shows a simple option picker dialog with focusable items for TV/keyboard navigation.
|
|
/// Returns the selected value, or null if cancelled. Each option's [icon] may
|
|
/// be `null` to render a label-only row (useful when the choices are variants
|
|
/// of the same thing and a repeated icon would just be noise).
|
|
Future<T?> showOptionPickerDialog<T>(
|
|
BuildContext context, {
|
|
required String title,
|
|
required List<({IconData? icon, String label, T value})> options,
|
|
Future<T?> Function(T value)? onBeforeClose,
|
|
}) {
|
|
final focusFirstItem = InputModeTracker.isKeyboardMode(context);
|
|
return showScopedDialog<T>(
|
|
context: context,
|
|
builder: (context) => _OptionPickerDialog<T>(
|
|
title: title,
|
|
options: options,
|
|
focusFirstItem: focusFirstItem,
|
|
onBeforeClose: onBeforeClose,
|
|
),
|
|
);
|
|
}
|
|
|
|
class _OptionPickerDialog<T> extends StatefulWidget {
|
|
final String title;
|
|
final List<({IconData? icon, String label, T value})> options;
|
|
final bool focusFirstItem;
|
|
final Future<T?> Function(T value)? onBeforeClose;
|
|
|
|
const _OptionPickerDialog({
|
|
required this.title,
|
|
required this.options,
|
|
this.focusFirstItem = false,
|
|
this.onBeforeClose,
|
|
});
|
|
|
|
@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');
|
|
if (widget.focusFirstItem) {
|
|
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];
|
|
final icon = option.icon;
|
|
return FocusableListTile(
|
|
focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null,
|
|
leading: icon != null ? AppIcon(icon, fill: 1, size: 24) : null,
|
|
title: Text(option.label, style: Theme.of(context).textTheme.bodyLarge),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
|
onTap: () async {
|
|
if (widget.onBeforeClose != null) {
|
|
final result = await widget.onBeforeClose!(option.value);
|
|
if (context.mounted) Navigator.pop(context, result);
|
|
} else {
|
|
Navigator.pop(context, option.value);
|
|
}
|
|
},
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
}
|