"Delete from server" read identically for an episode, a season and a
whole show: same menu label, same dialog title, same red button, and a
body that named nothing. The menu header did not disambiguate either,
because MediaItem.displayTitle collapses an episode to its show name.
A reporter deleted a whole series from the detail hero's ⋮ believing it
acted on the episode he had highlighted, and the confirmation gave him
nothing to catch it with. Every one of those strings now names the kind,
and the body names the exact item — show, season and episode number, and
episode title.
Deleting a single item also destroyed files the confirmation never
mentioned: a Plex multi-episode file (S01E01-E03.mkv) takes its other
episodes with it, and a split item takes every part. The dialog now
reports that up front and, on success, emits deletion events for the
siblings the server destroyed so their rows do not linger.
The scope behind that warning is only asserted when it is established.
MediaItem.allPartFiles drops parts with no path, so a non-empty set
proves nothing about the ones it filtered out; a version is trusted only
when every part reports a file. A browse row that omits paths is missing
evidence rather than proof of a distinct file, so both the target and
each candidate sibling fall back to the detail endpoint before any
conclusion — otherwise a thin row, including the file-less part
PlexMappers fabricates for an empty payload, would look like a server
that withholds paths. When the answer cannot be established the dialog
says so in an error-tinted block and its button reads "Delete anyway",
separating a transient probe failure from a server that never sends
paths. It deliberately does not refuse: Plex withholds paths from
restricted users the server itself authorizes to delete, so failing
closed would take the feature away from them permanently.
Probing a season stays bounded in both directions. Siblings resolve one
at a time, so a season of thin rows cannot fan out a detail request per
episode, and expiry cancels the walk rather than merely abandoning it —
`Future.timeout` completes the future the caller awaits but leaves the
work behind it running, which would resume on the next sibling once the
outstanding request answered. A cooperative flag is checked before each
lookup, so at most the one already in flight outlives the deadline; the
neutral client exposes no abort handle for item lookups, so that one
cannot be recalled.
The spinner covering the probe was only barrierDismissible, which does
not stop system back. Back dismissed it and the cleanup pop then closed
the screen underneath, dropping the user out of the detail page
mid-flow. It now traps back, matching the non-dismissible contract its
own doc claims, which also repairs the log uploader and the file-info
sheet.
Coverage splits by what each layer owns. The dialog, its copy and the
DELETE wiring are backend-neutral and stay in the menu widget tests.
Plex — the backend multi-episode files actually come from — gets the
resolver over a real PlexClient and a mocked transport: a row with no
media at all, scope recovered from /library/metadata/{id}, siblings and
paths from /children, a Part that names no file, a sibling whose path
never resolves, the request count a sixty-episode thin season may cost,
and the rating key the DELETE carries. Those are plain async tests
because the Plex metadata cache is a real database whose I/O the widget
tester's fake clock never drives. Deadline behaviour needs the opposite,
so it is pinned separately under fakeAsync against a gated fake client,
with no wall-clock waiting anywhere.
close #1781
463 lines
15 KiB
Dart
463 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.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,
|
|
String? warning,
|
|
}) async {
|
|
final confirmed = await showScopedDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
final colorScheme = Theme.of(dialogContext).colorScheme;
|
|
return AlertDialog(
|
|
title: Text(title),
|
|
content: warning == null
|
|
? Text(message)
|
|
: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(message),
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.errorContainer,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(warning, style: TextStyle(color: colorScheme.onErrorContainer)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
DialogActionButton(
|
|
autofocus: true,
|
|
onPressed: () => Navigator.pop(dialogContext, false),
|
|
label: cancelText ?? t.common.cancel,
|
|
style: TextButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
|
|
),
|
|
DialogActionButton(
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
|
label: confirmText,
|
|
isPrimary: true,
|
|
style: isDestructive
|
|
? FilledButton.styleFrom(
|
|
padding: _buttonPadding,
|
|
shape: _buttonShape,
|
|
backgroundColor: colorScheme.error,
|
|
foregroundColor: colorScheme.onError,
|
|
)
|
|
: FilledButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
return confirmed ?? false;
|
|
}
|
|
|
|
/// Shows a non-dismissible loading-spinner dialog. Caller is responsible for
|
|
/// closing it via `Navigator.pop(context)` when the work completes.
|
|
///
|
|
/// `barrierDismissible: false` only blocks the barrier, so the spinner also
|
|
/// traps system back. Without that, back would dismiss the spinner and the
|
|
/// caller's cleanup pop would land on the route underneath — closing the
|
|
/// screen the user was working in.
|
|
void showLoadingDialog(BuildContext context) {
|
|
showScopedDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (_) => const PopScope(canPop: false, child: 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: [
|
|
DialogActionButton(
|
|
autofocus: true,
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
label: t.common.close,
|
|
isPrimary: true,
|
|
style: FilledButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Shows the server-side 404 modal: the item exists but the server cannot read
|
|
/// the file behind it, so nothing client-side can recover the playback.
|
|
Future<void> showMediaUnreadableDialog(BuildContext context) async {
|
|
await showScopedDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(t.messages.mediaUnreadableTitle),
|
|
content: Text(t.messages.mediaUnreadableBody),
|
|
actions: [
|
|
DialogActionButton(
|
|
autofocus: true,
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
label: t.common.close,
|
|
isPrimary: true,
|
|
style: FilledButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 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,
|
|
String? warning,
|
|
}) {
|
|
return showConfirmDialog(
|
|
context,
|
|
title: title,
|
|
message: message,
|
|
confirmText: confirmText ?? t.common.delete,
|
|
isDestructive: true,
|
|
warning: warning,
|
|
);
|
|
}
|
|
|
|
/// Shows a text input dialog and returns validated submitted text.
|
|
///
|
|
/// Returns `null` when the dialog is cancelled or dismissed. Validation errors
|
|
/// are shown in the field and keep the dialog open, so a non-null result always
|
|
/// represents an explicit, valid submission.
|
|
Future<String?> showTextInputDialog(
|
|
BuildContext context, {
|
|
required String title,
|
|
required String labelText,
|
|
String? hintText,
|
|
String? initialValue,
|
|
String? confirmText,
|
|
TextInputType? keyboardType,
|
|
List<TextInputFormatter>? inputFormatters,
|
|
String? Function(String)? validator,
|
|
bool allowEmpty = false,
|
|
bool multiline = false,
|
|
bool obscureText = 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,
|
|
multiline: multiline,
|
|
obscureText: obscureText,
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
final bool multiline;
|
|
final bool obscureText;
|
|
|
|
const _TextInputDialog({
|
|
required this.title,
|
|
required this.labelText,
|
|
required this.hintText,
|
|
this.initialValue,
|
|
this.confirmText,
|
|
this.keyboardType,
|
|
this.inputFormatters,
|
|
this.validator,
|
|
this.allowEmpty = false,
|
|
this.multiline = false,
|
|
this.obscureText = false,
|
|
}) : assert(!multiline || !obscureText, 'A text input dialog cannot be both multiline and obscure.');
|
|
|
|
@override
|
|
State<_TextInputDialog> createState() => _TextInputDialogState();
|
|
}
|
|
|
|
class _TextInputDialogState extends State<_TextInputDialog> with ControllerDisposerMixin {
|
|
late final TextEditingController _controller;
|
|
final _fieldFocusNode = FocusNode(debugLabel: 'TextInputField');
|
|
final _cancelFocusNode = FocusNode(debugLabel: 'TextInputCancel');
|
|
final _saveFocusNode = FocusNode(debugLabel: 'TextInputSave');
|
|
String? _errorText;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = createTextEditingController(text: widget.initialValue);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_fieldFocusNode.dispose();
|
|
_cancelFocusNode.dispose();
|
|
_saveFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
String? _validate(String text) {
|
|
if (text.isEmpty && !widget.allowEmpty) return t.addServer.required;
|
|
return widget.validator?.call(text);
|
|
}
|
|
|
|
void _submit() {
|
|
final text = _controller.text;
|
|
final errorText = _validate(text);
|
|
if (errorText != null) {
|
|
setState(() => _errorText = errorText);
|
|
return;
|
|
}
|
|
Navigator.pop(context, text);
|
|
}
|
|
|
|
void _handleChanged(String text) {
|
|
if (_errorText == null) return;
|
|
setState(() => _errorText = _validate(text));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(widget.title),
|
|
content: widget.multiline ? SizedBox(width: 400, child: textField) : textField,
|
|
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,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget get textField {
|
|
return FocusableTextField(
|
|
controller: _controller,
|
|
focusNode: _fieldFocusNode,
|
|
autofocus: true,
|
|
tvTextInputPresentation: widget.multiline
|
|
? TvTextInputPresentation.flutterOverlay
|
|
: TvTextInputPresentation.automatic,
|
|
decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText, errorText: _errorText),
|
|
keyboardType: widget.keyboardType ?? (widget.multiline ? TextInputType.multiline : null),
|
|
inputFormatters: widget.inputFormatters,
|
|
textInputAction: widget.multiline ? TextInputAction.newline : TextInputAction.done,
|
|
obscureText: widget.obscureText,
|
|
maxLines: widget.multiline ? 8 : 1,
|
|
minLines: widget.multiline ? 3 : 1,
|
|
onChanged: _handleChanged,
|
|
onNavigateDown: _saveFocusNode.requestFocus,
|
|
onSubmitted: widget.multiline ? null : (_) => _saveFocusNode.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).
|
|
/// Optional persistent toggle rendered above the option rows. Its state is held
|
|
/// by the dialog (toggling does not pop), and [onChanged] mirrors the new value
|
|
/// out so the caller can read it once an option row is picked.
|
|
typedef OptionPickerToggle = ({String label, IconData? icon, bool value, ValueChanged<bool> onChanged});
|
|
|
|
Future<T?> showOptionPickerDialog<T>(
|
|
BuildContext context, {
|
|
required String title,
|
|
required List<({IconData? icon, String label, T value})> options,
|
|
Future<T?> Function(T value)? onBeforeClose,
|
|
OptionPickerToggle? toggle,
|
|
}) {
|
|
final focusFirstItem = InputModeTracker.isKeyboardMode(context, listen: false);
|
|
return showScopedDialog<T>(
|
|
context: context,
|
|
builder: (context) => _OptionPickerDialog<T>(
|
|
title: title,
|
|
options: options,
|
|
focusFirstItem: focusFirstItem,
|
|
onBeforeClose: onBeforeClose,
|
|
toggle: toggle,
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
final OptionPickerToggle? toggle;
|
|
|
|
const _OptionPickerDialog({
|
|
required this.title,
|
|
required this.options,
|
|
this.focusFirstItem = false,
|
|
this.onBeforeClose,
|
|
this.toggle,
|
|
});
|
|
|
|
@override
|
|
State<_OptionPickerDialog<T>> createState() => _OptionPickerDialogState<T>();
|
|
}
|
|
|
|
class _OptionPickerDialogState<T> extends State<_OptionPickerDialog<T>> {
|
|
late final FocusNode _initialFocusNode;
|
|
late bool _toggleValue;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initialFocusNode = FocusNode(debugLabel: 'OptionPickerInitialFocus');
|
|
_toggleValue = widget.toggle?.value ?? false;
|
|
if (widget.focusFirstItem) {
|
|
FocusUtils.requestFocusAfterBuild(this, _initialFocusNode);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_initialFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
const rowPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 4);
|
|
const rowHorizontalTitleGap = 8.0;
|
|
const rowMinLeadingWidth = 24.0;
|
|
final toggle = widget.toggle;
|
|
void updateToggle(bool value) {
|
|
setState(() => _toggleValue = value);
|
|
toggle?.onChanged(value);
|
|
}
|
|
|
|
return SimpleDialog(
|
|
title: Text(widget.title),
|
|
insetPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 24),
|
|
constraints: const BoxConstraints(minWidth: 304),
|
|
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
|
children: [
|
|
if (toggle != null)
|
|
MergeSemantics(
|
|
child: FocusableListTile(
|
|
title: Row(
|
|
children: [
|
|
if (toggle.icon != null) ...[
|
|
AppIcon(toggle.icon!, fill: 1, size: 24),
|
|
const SizedBox(width: rowHorizontalTitleGap),
|
|
],
|
|
Expanded(
|
|
child: Text(
|
|
toggle.label,
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: rowHorizontalTitleGap),
|
|
ExcludeFocus(
|
|
child: Switch(value: _toggleValue, onChanged: updateToggle),
|
|
),
|
|
],
|
|
),
|
|
contentPadding: rowPadding,
|
|
onTap: () => updateToggle(!_toggleValue),
|
|
),
|
|
),
|
|
...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: rowPadding,
|
|
horizontalTitleGap: rowHorizontalTitleGap,
|
|
minLeadingWidth: rowMinLeadingWidth,
|
|
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);
|
|
}
|
|
},
|
|
);
|
|
}),
|
|
],
|
|
);
|
|
}
|
|
}
|