fix: align UI focus and sheet behavior
This commit is contained in:
@@ -387,7 +387,7 @@ class _AppMenuItemTileState<T> extends State<AppMenuItemTile<T>> with FocusableT
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final enabled = item.enabled && widget.onPressed != null;
|
||||
final active = enabled && (_isFocused || _isHovered);
|
||||
final active = enabled && ((_isFocused && InputModeTracker.isKeyboardMode(context)) || _isHovered);
|
||||
final foreground = _foregroundColor(context, active: active);
|
||||
final subtitleColor = foreground.withValues(alpha: active && item.stateLayerColor != null ? 0.86 : 0.68);
|
||||
final background = _backgroundColor(context, active: active);
|
||||
|
||||
@@ -18,7 +18,7 @@ class BottomSheetHeader extends StatelessWidget {
|
||||
final Widget? action;
|
||||
|
||||
/// Optional callback when close button is pressed
|
||||
/// Defaults to Navigator.pop(context)
|
||||
/// Defaults to closing the nearest hosted sheet, with modal-route fallback.
|
||||
final VoidCallback? onClose;
|
||||
|
||||
/// Optional icon to display as leading widget
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../focus/card_focus_scope.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
@@ -132,8 +133,14 @@ class CastMemberStripState extends State<CastMemberStrip> {
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
if (key.isBackKey || !event.isActionable) return KeyEventResult.ignored;
|
||||
if (widget.members.isEmpty) return KeyEventResult.ignored;
|
||||
if (key.isBackKey || widget.members.isEmpty) return KeyEventResult.ignored;
|
||||
|
||||
final onMemberTap = widget.onMemberTap;
|
||||
if (onMemberTap != null) {
|
||||
final selectResult = handleOneShotSelect(event, () => onMemberTap(_focusedIndex));
|
||||
if (selectResult != KeyEventResult.ignored) return selectResult;
|
||||
}
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
|
||||
if (key.isLeftKey) {
|
||||
_moveFocus(-1);
|
||||
@@ -151,10 +158,6 @@ class CastMemberStripState extends State<CastMemberStrip> {
|
||||
widget.onNavigateDown!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isSelectKey && widget.onMemberTap != null) {
|
||||
widget.onMemberTap!(_focusedIndex);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../focus/focusable_wrapper.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'clickable_cursor.dart';
|
||||
|
||||
class CollapsibleText extends StatefulWidget {
|
||||
@@ -54,33 +53,6 @@ class _CollapsibleTextState extends State<CollapsibleText> {
|
||||
});
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final selectResult = handleOneShotSelect(event, _toggleExpanded);
|
||||
if (selectResult != KeyEventResult.ignored) return selectResult;
|
||||
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
|
||||
final key = event.logicalKey;
|
||||
if (key.isUpKey && widget.onNavigateUp != null) {
|
||||
widget.onNavigateUp!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isDownKey && widget.onNavigateDown != null) {
|
||||
widget.onNavigateDown!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isLeftKey && widget.onNavigateLeft != null) {
|
||||
widget.onNavigateLeft!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isRightKey && widget.onNavigateRight != null) {
|
||||
widget.onNavigateRight!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = widget.style ?? DefaultTextStyle.of(context).style;
|
||||
@@ -123,31 +95,22 @@ class _CollapsibleTextState extends State<CollapsibleText> {
|
||||
),
|
||||
);
|
||||
|
||||
final focusNode = widget.focusNode;
|
||||
if (focusNode != null) {
|
||||
result = Focus(
|
||||
focusNode: focusNode,
|
||||
skipTraversal: widget.skipTraversal,
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: ListenableBuilder(
|
||||
listenable: focusNode,
|
||||
builder: (context, child) {
|
||||
final showFocus = focusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: showFocus
|
||||
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12)
|
||||
: Colors.transparent,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: result,
|
||||
),
|
||||
);
|
||||
result = FocusableWrapper(
|
||||
focusNode: widget.focusNode,
|
||||
onSelect: _toggleExpanded,
|
||||
onNavigateUp: widget.onNavigateUp,
|
||||
onNavigateDown: widget.onNavigateDown,
|
||||
onNavigateLeft: widget.onNavigateLeft,
|
||||
onNavigateRight: widget.onNavigateRight,
|
||||
semanticLabel: _expanded ? t.accessibility.collapseText : t.accessibility.expandText,
|
||||
descendantsAreFocusable: false,
|
||||
disableScale: true,
|
||||
useBackgroundFocus: true,
|
||||
borderRadius: 8,
|
||||
child: result,
|
||||
);
|
||||
if (widget.skipTraversal) {
|
||||
result = ExcludeFocusTraversal(child: result);
|
||||
}
|
||||
|
||||
return ClickableCursor(
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../../services/settings_service.dart';
|
||||
import '../../utils/dialogs.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../focus/key_event_utils.dart';
|
||||
import '../dialog_action_button.dart';
|
||||
|
||||
class RemoteSessionDialog extends StatefulWidget {
|
||||
const RemoteSessionDialog({super.key});
|
||||
@@ -115,22 +116,22 @@ class _RemoteSessionDialogState extends State<RemoteSessionDialog> with MountedS
|
||||
title: Text(t.common.error),
|
||||
content: Text(_errorMessage!, style: const TextStyle(fontFamily: 'monospace')),
|
||||
actions: [
|
||||
FocusableButton(
|
||||
DialogActionButton(
|
||||
autofocus: true,
|
||||
focusNode: _errorCloseFocusNode,
|
||||
onPressed: _close,
|
||||
onBack: _close,
|
||||
onNavigateRight: () => _errorRetryFocusNode.requestFocus(),
|
||||
useBackgroundFocus: true,
|
||||
child: TextButton(onPressed: _close, child: Text(t.common.close)),
|
||||
label: t.common.close,
|
||||
),
|
||||
FocusableButton(
|
||||
DialogActionButton(
|
||||
focusNode: _errorRetryFocusNode,
|
||||
onPressed: _startServer,
|
||||
onBack: _close,
|
||||
onNavigateLeft: () => _errorCloseFocusNode.requestFocus(),
|
||||
useBackgroundFocus: true,
|
||||
child: TextButton(onPressed: _startServer, child: Text(t.common.retry)),
|
||||
label: t.common.retry,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/focusable_wrapper.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../models/trackers/device_code.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
@@ -45,17 +46,25 @@ class DeviceCodeDialog extends StatelessWidget {
|
||||
Text(t.services.deviceCode.body(url: code.verificationUrl), style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: InkWell(
|
||||
onTap: () => _copy(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
child: Text(
|
||||
code.userCode,
|
||||
style: theme.textTheme.displaySmall?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
letterSpacing: 4,
|
||||
fontWeight: .w600,
|
||||
child: FocusableWrapper(
|
||||
onSelect: () => _copy(context),
|
||||
semanticLabel: t.services.deviceCode.copyCode,
|
||||
descendantsAreFocusable: false,
|
||||
useBackgroundFocus: true,
|
||||
borderRadius: 8,
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
onTap: () => _copy(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
child: Text(
|
||||
code.userCode,
|
||||
style: theme.textTheme.displaySmall?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
letterSpacing: 4,
|
||||
fontWeight: .w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -66,6 +75,7 @@ class DeviceCodeDialog extends StatelessWidget {
|
||||
width: double.infinity,
|
||||
child: FocusableButton(
|
||||
onPressed: _open,
|
||||
useBackgroundFocus: true,
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: Text(t.services.deviceCode.openToActivate(service: serviceName)),
|
||||
|
||||
@@ -9,40 +9,61 @@ import '../focus/focusable_button.dart';
|
||||
/// `FocusableButton(onPressed: ..., child: TextButton(onPressed: ..., ...))`
|
||||
/// boilerplate with a single call.
|
||||
class DialogActionButton extends StatelessWidget {
|
||||
final VoidCallback onPressed;
|
||||
final VoidCallback? onPressed;
|
||||
final String label;
|
||||
final FocusNode? focusNode;
|
||||
final bool autofocus;
|
||||
final bool isPrimary;
|
||||
final bool? useBackgroundFocus;
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback? onNavigateUp;
|
||||
final VoidCallback? onNavigateDown;
|
||||
final VoidCallback? onNavigateLeft;
|
||||
final VoidCallback? onNavigateRight;
|
||||
|
||||
final ButtonStyle? style;
|
||||
final Widget? icon;
|
||||
const DialogActionButton({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
required this.label,
|
||||
this.focusNode,
|
||||
this.autofocus = false,
|
||||
this.isPrimary = false,
|
||||
this.useBackgroundFocus,
|
||||
this.onBack,
|
||||
this.onNavigateUp,
|
||||
this.onNavigateDown,
|
||||
this.onNavigateLeft,
|
||||
this.onNavigateRight,
|
||||
this.style,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final button = switch ((isPrimary, icon)) {
|
||||
(true, final Widget icon) => FilledButton.icon(
|
||||
onPressed: onPressed,
|
||||
style: style,
|
||||
icon: icon,
|
||||
label: Text(label),
|
||||
),
|
||||
(true, null) => FilledButton(onPressed: onPressed, style: style, child: Text(label)),
|
||||
(false, final Widget icon) => TextButton.icon(onPressed: onPressed, style: style, icon: icon, label: Text(label)),
|
||||
(false, null) => TextButton(onPressed: onPressed, style: style, child: Text(label)),
|
||||
};
|
||||
|
||||
return FocusableButton(
|
||||
focusNode: focusNode,
|
||||
autofocus: autofocus,
|
||||
onPressed: onPressed,
|
||||
useBackgroundFocus: isPrimary,
|
||||
useBackgroundFocus: useBackgroundFocus ?? isPrimary,
|
||||
onBack: onBack,
|
||||
onNavigateUp: onNavigateUp,
|
||||
onNavigateDown: onNavigateDown,
|
||||
onNavigateLeft: onNavigateLeft,
|
||||
onNavigateRight: onNavigateRight,
|
||||
child: isPrimary
|
||||
? FilledButton(onPressed: onPressed, child: Text(label))
|
||||
: TextButton(onPressed: onPressed, child: Text(label)),
|
||||
child: button,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,3 +355,85 @@ class _FocusableSwitchListTileState extends State<FocusableSwitchListTile>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A CheckboxListTile that accepts a FocusNode for keyboard/controller navigation.
|
||||
///
|
||||
/// Uses Flutter's native CheckboxListTile focus support - no custom styling wrapper.
|
||||
class FocusableCheckboxListTile extends StatefulWidget {
|
||||
final Widget? title;
|
||||
final Widget? subtitle;
|
||||
final Widget? secondary;
|
||||
final bool? value;
|
||||
final ValueChanged<bool?>? onChanged;
|
||||
final bool tristate;
|
||||
final bool dense;
|
||||
final FocusNode? focusNode;
|
||||
final bool autofocus;
|
||||
final VisualDensity? visualDensity;
|
||||
final EdgeInsetsGeometry? contentPadding;
|
||||
final ListTileControlAffinity controlAffinity;
|
||||
|
||||
const FocusableCheckboxListTile({
|
||||
super.key,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.secondary,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.tristate = false,
|
||||
this.dense = true,
|
||||
this.focusNode,
|
||||
this.autofocus = false,
|
||||
this.visualDensity = const VisualDensity(vertical: -3),
|
||||
this.contentPadding,
|
||||
this.controlAffinity = ListTileControlAffinity.platform,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FocusableCheckboxListTile> createState() => _FocusableCheckboxListTileState();
|
||||
}
|
||||
|
||||
class _FocusableCheckboxListTileState extends State<FocusableCheckboxListTile>
|
||||
with FocusableTileStateMixin<FocusableCheckboxListTile> {
|
||||
@override
|
||||
FocusNode? get widgetFocusNode => widget.focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initFocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FocusableCheckboxListTile oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
updateFocusNode(oldWidget.focusNode);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
disposeFocusNode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClickableCursor(
|
||||
enabled: widget.onChanged != null,
|
||||
child: CheckboxListTile(
|
||||
title: widget.title,
|
||||
subtitle: widget.subtitle,
|
||||
secondary: widget.secondary,
|
||||
value: widget.value,
|
||||
onChanged: widget.onChanged,
|
||||
tristate: widget.tristate,
|
||||
dense: widget.dense,
|
||||
visualDensity: widget.visualDensity,
|
||||
contentPadding: widget.contentPadding,
|
||||
focusNode: effectiveFocusNode,
|
||||
autofocus: widget.autofocus,
|
||||
controlAffinity: widget.controlAffinity,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,17 @@ import 'app_menu.dart';
|
||||
/// An [AppMenuButton] that can be focused and opened with D-pad select.
|
||||
class FocusablePopupMenuButton<T> extends StatefulWidget {
|
||||
final Widget? icon;
|
||||
final Widget? child;
|
||||
final String? tooltip;
|
||||
final bool enabled;
|
||||
final AppMenuEntryBuilder<T> itemBuilder;
|
||||
final ValueChanged<T>? onSelected;
|
||||
final GlobalKey<AppMenuButtonState<T>>? menuKey;
|
||||
final AppMenuAnchorAlignment anchorAlignment;
|
||||
final Offset alignmentOffset;
|
||||
final double minWidth;
|
||||
final double? maxWidth;
|
||||
final EdgeInsetsGeometry? childPadding;
|
||||
final FocusNode? focusNode;
|
||||
final VoidCallback? onNavigateUp;
|
||||
final VoidCallback? onNavigateDown;
|
||||
@@ -23,10 +30,17 @@ class FocusablePopupMenuButton<T> extends StatefulWidget {
|
||||
const FocusablePopupMenuButton({
|
||||
super.key,
|
||||
this.icon,
|
||||
this.child,
|
||||
this.tooltip,
|
||||
this.enabled = true,
|
||||
required this.itemBuilder,
|
||||
this.onSelected,
|
||||
this.menuKey,
|
||||
this.anchorAlignment = AppMenuAnchorAlignment.start,
|
||||
this.alignmentOffset = Offset.zero,
|
||||
this.minWidth = 220,
|
||||
this.maxWidth,
|
||||
this.childPadding,
|
||||
this.focusNode,
|
||||
this.onNavigateUp,
|
||||
this.onNavigateDown,
|
||||
@@ -36,7 +50,7 @@ class FocusablePopupMenuButton<T> extends StatefulWidget {
|
||||
this.borderRadius = 100,
|
||||
this.useBackgroundFocus = true,
|
||||
this.enableLongPress = true,
|
||||
});
|
||||
}) : assert(icon != null || child != null, 'FocusablePopupMenuButton requires icon or child');
|
||||
|
||||
@override
|
||||
State<FocusablePopupMenuButton<T>> createState() => _FocusablePopupMenuButtonState<T>();
|
||||
@@ -53,6 +67,7 @@ class _FocusablePopupMenuButtonState<T> extends State<FocusablePopupMenuButton<T
|
||||
Widget build(BuildContext context) {
|
||||
return FocusableWrapper(
|
||||
focusNode: widget.focusNode,
|
||||
canRequestFocus: widget.enabled,
|
||||
disableScale: true,
|
||||
borderRadius: widget.borderRadius,
|
||||
useBackgroundFocus: widget.useBackgroundFocus,
|
||||
@@ -63,14 +78,21 @@ class _FocusablePopupMenuButtonState<T> extends State<FocusablePopupMenuButton<T
|
||||
onNavigateDown: widget.onNavigateDown,
|
||||
onNavigateLeft: widget.onNavigateLeft,
|
||||
onNavigateRight: widget.onNavigateRight,
|
||||
onSelect: _showMenu,
|
||||
onLongPress: widget.enableLongPress ? _showMenu : null,
|
||||
onSelect: widget.enabled ? _showMenu : null,
|
||||
onLongPress: widget.enabled && widget.enableLongPress ? _showMenu : null,
|
||||
child: AppMenuButton<T>(
|
||||
key: _menuKey,
|
||||
icon: widget.icon,
|
||||
tooltip: widget.tooltip,
|
||||
enabled: widget.enabled,
|
||||
onSelected: widget.onSelected,
|
||||
entriesBuilder: widget.itemBuilder,
|
||||
anchorAlignment: widget.anchorAlignment,
|
||||
alignmentOffset: widget.alignmentOffset,
|
||||
minWidth: widget.minWidth,
|
||||
maxWidth: widget.maxWidth,
|
||||
childPadding: widget.childPadding,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ class FocusedScrollScaffold extends StatefulWidget {
|
||||
/// Optional actions to display in the app bar (e.g., IconButton widgets).
|
||||
final List<Widget>? actions;
|
||||
|
||||
/// Whether app-bar controls participate in keyboard/controller traversal.
|
||||
///
|
||||
/// They remain excluded while initial focus is assigned so the first
|
||||
/// content control still receives focus when the screen opens.
|
||||
final bool focusableAppBarActions;
|
||||
|
||||
/// Whether the app bar should remain visible when scrolling.
|
||||
/// Defaults to true.
|
||||
final bool pinned;
|
||||
@@ -44,6 +50,7 @@ class FocusedScrollScaffold extends StatefulWidget {
|
||||
required this.title,
|
||||
required this.slivers,
|
||||
this.actions,
|
||||
this.focusableAppBarActions = false,
|
||||
this.pinned = true,
|
||||
this.automaticallyImplyLeading = true,
|
||||
this.onBackPressed,
|
||||
@@ -56,6 +63,7 @@ class FocusedScrollScaffold extends StatefulWidget {
|
||||
class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
|
||||
final _scopeNode = FocusScopeNode();
|
||||
bool _focusRequested = false;
|
||||
bool _appBarFocusEnabled = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -71,6 +79,9 @@ class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
|
||||
if (_scopeNode.focusedChild != null) return;
|
||||
_scopeNode.requestFocus();
|
||||
_scopeNode.nextFocus();
|
||||
if (widget.focusableAppBarActions && !_appBarFocusEnabled) {
|
||||
setState(() => _appBarFocusEnabled = true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -94,14 +105,22 @@ class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
ExcludeFocus(
|
||||
child: CustomAppBar(
|
||||
if (!widget.focusableAppBarActions || !_appBarFocusEnabled)
|
||||
ExcludeFocus(
|
||||
child: CustomAppBar(
|
||||
title: widget.title,
|
||||
pinned: widget.pinned,
|
||||
actions: widget.actions,
|
||||
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
||||
),
|
||||
)
|
||||
else
|
||||
CustomAppBar(
|
||||
title: widget.title,
|
||||
pinned: widget.pinned,
|
||||
actions: widget.actions,
|
||||
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
||||
),
|
||||
),
|
||||
...widget.slivers,
|
||||
],
|
||||
),
|
||||
|
||||
@@ -5,10 +5,11 @@ import '../models/hotkey_model.dart';
|
||||
|
||||
/// Captures a key combination from the user and calls [onHotKeyRecorded].
|
||||
class HotKeyRecorder extends StatefulWidget {
|
||||
const HotKeyRecorder({super.key, this.initalHotKey, required this.onHotKeyRecorded});
|
||||
const HotKeyRecorder({super.key, this.initalHotKey, required this.onHotKeyRecorded, this.enabled = true});
|
||||
|
||||
final HotKey? initalHotKey;
|
||||
final ValueChanged<HotKey> onHotKeyRecorded;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
State<HotKeyRecorder> createState() => _HotKeyRecorderState();
|
||||
@@ -24,6 +25,14 @@ class _HotKeyRecorderState extends State<HotKeyRecorder> {
|
||||
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(HotKeyRecorder oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.initalHotKey != oldWidget.initalHotKey) {
|
||||
_hotKey = widget.initalHotKey;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
|
||||
@@ -31,6 +40,7 @@ class _HotKeyRecorderState extends State<HotKeyRecorder> {
|
||||
}
|
||||
|
||||
bool _handleKeyEvent(KeyEvent keyEvent) {
|
||||
if (!widget.enabled) return false;
|
||||
if (keyEvent is KeyUpEvent) return false;
|
||||
|
||||
final physicalKeysPressed = HardwareKeyboard.instance.physicalKeysPressed;
|
||||
|
||||
@@ -24,6 +24,7 @@ import '../utils/provider_extensions.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import 'app_icon.dart';
|
||||
import 'app_menu.dart';
|
||||
import 'bottom_sheet_header.dart';
|
||||
import 'overlay_sheet.dart';
|
||||
|
||||
/// A menu action item for context menus
|
||||
@@ -320,6 +321,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
List<MediaLibrary>? _originalOrder; // Original order before move (for cancel)
|
||||
final FocusNode _listFocusNode = FocusNode();
|
||||
final ScrollController _dialogScrollController = ScrollController();
|
||||
final ScrollController _sheetScrollController = ScrollController();
|
||||
bool _backKeyDownSeen = false;
|
||||
|
||||
@override
|
||||
@@ -332,6 +334,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
void dispose() {
|
||||
_listFocusNode.dispose();
|
||||
_dialogScrollController.dispose();
|
||||
_sheetScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -505,7 +508,11 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
for (final item in menuItems)
|
||||
AppMenuItem<String>(value: item.value, icon: item.icon, label: item.label, destructive: item.isDestructive),
|
||||
],
|
||||
onSelected: (value) => widget.onLibraryMenuAction(value, library),
|
||||
closeOnSelected: false,
|
||||
onSelected: (value) {
|
||||
OverlaySheetController.popAdaptive(context, value);
|
||||
widget.onLibraryMenuAction(value, library);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -547,6 +554,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
),
|
||||
body: Focus(
|
||||
focusNode: _listFocusNode,
|
||||
descendantsAreFocusable: false,
|
||||
autofocus: InputModeTracker.isKeyboardMode(context),
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: _buildFlatLibraryListDialog(hiddenLibraryKeys),
|
||||
@@ -556,47 +564,19 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const AppIcon(Symbols.edit_rounded, fill: 1),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(t.libraries.manageLibraries, style: const TextStyle(fontSize: 20, fontWeight: .bold)),
|
||||
),
|
||||
IconButton(
|
||||
icon: const AppIcon(Symbols.close_rounded, fill: 1),
|
||||
onPressed: () => OverlaySheetController.popAdaptive(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Library list (grouped by server if multiple servers)
|
||||
Expanded(
|
||||
child: Focus(
|
||||
focusNode: _listFocusNode,
|
||||
autofocus: InputModeTracker.isKeyboardMode(context),
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: _buildFlatLibraryList(scrollController, hiddenLibraryKeys),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
return Column(
|
||||
children: [
|
||||
BottomSheetHeader(title: t.libraries.manageLibraries, icon: Symbols.edit_rounded),
|
||||
Flexible(
|
||||
child: Focus(
|
||||
focusNode: _listFocusNode,
|
||||
descendantsAreFocusable: false,
|
||||
autofocus: InputModeTracker.isKeyboardMode(context),
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: _buildFlatLibraryList(_sheetScrollController, hiddenLibraryKeys),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ import '../utils/platform_detector.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../services/external_player_service.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import 'dialog_action_button.dart';
|
||||
import '../focus/focusable_text_field.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../screens/plex_match_screen.dart';
|
||||
@@ -588,6 +588,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
selected = await OverlaySheetController.showAdaptive<String>(
|
||||
this.context,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => AppMenuSheet<String>(
|
||||
title: _itemDisplayTitle(),
|
||||
entries: _menuEntries(menuActions),
|
||||
@@ -1320,6 +1321,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
await OverlaySheetController.showAdaptive(
|
||||
this.context,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => RatingBottomSheet(
|
||||
item: item,
|
||||
serverClient: client,
|
||||
@@ -1959,12 +1961,7 @@ class _PickerDialogScaffoldState<T> extends State<_PickerDialogScaffold<T>> {
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FocusableButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
||||
),
|
||||
],
|
||||
actions: [DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.cancel)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../focus/focusable_action_bar.dart';
|
||||
import '../../focus/focusable_wrapper.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../media/ids.dart';
|
||||
import '../../media/media_item.dart';
|
||||
@@ -16,7 +18,6 @@ import '../../utils/music_navigation.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../utils/video_player_navigation.dart';
|
||||
import '../app_icon.dart';
|
||||
import '../media_context_menu.dart';
|
||||
import '../optimized_media_image.dart';
|
||||
import '../overlay_sheet.dart';
|
||||
@@ -252,7 +253,14 @@ class _MiniPlayerCard extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMixin<_MiniPlayerCard> {
|
||||
bool _hovered = false;
|
||||
final _detailsFocusNode = FocusNode(debugLabel: 'mini_player_details');
|
||||
final _transportKey = GlobalKey<FocusableActionBarState>();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_detailsFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -266,96 +274,111 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
|
||||
color: tk.surface,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
borderRadius: BorderRadius.circular(tk.radiusLg),
|
||||
child: InkWell(
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
onTap: () => unawaited(openNowPlaying(context)),
|
||||
onTapDown: storeTapPosition,
|
||||
onLongPress: showContextMenuFromTap,
|
||||
onSecondaryTapDown: storeTapPosition,
|
||||
onSecondaryTap: showContextMenuFromTap,
|
||||
child: SizedBox(
|
||||
height: _MusicMiniPlayerOverlayState._cardHeight,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Played fraction tints the card background itself — the card
|
||||
// fills up as the track progresses (clipped by the Material's
|
||||
// rounded corners above).
|
||||
const Positioned.fill(child: _MiniPlayerProgress()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(tk.radiusSm),
|
||||
child: OptimizedMediaImage(
|
||||
client: client,
|
||||
imagePath: widget.track.thumbPath,
|
||||
imageType: ImageType.square,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fallbackIcon: Symbols.music_note_rounded,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: .center,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(
|
||||
widget.track.title ?? '',
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: TextStyle(fontSize: 14, fontWeight: .w600, color: tk.text),
|
||||
),
|
||||
if (artist != null && artist.isNotEmpty)
|
||||
Text(
|
||||
artist,
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: TextStyle(fontSize: 12, color: tk.textMuted),
|
||||
child: SizedBox(
|
||||
height: _MusicMiniPlayerOverlayState._cardHeight,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: _MiniPlayerProgress()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FocusableWrapper(
|
||||
focusNode: _detailsFocusNode,
|
||||
onSelect: () => unawaited(openNowPlaying(context)),
|
||||
enableLongPress: true,
|
||||
onLongPress: showContextMenuFromTap,
|
||||
onNavigateRight: () => _transportKey.currentState?.requestFocusOnFirst(),
|
||||
semanticLabel: widget.track.title,
|
||||
descendantsAreFocusable: false,
|
||||
disableScale: true,
|
||||
useBackgroundFocus: true,
|
||||
borderRadius: tk.radiusLg,
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
mouseCursor: SystemMouseCursors.click,
|
||||
onTap: () => unawaited(openNowPlaying(context)),
|
||||
onTapDown: storeTapPosition,
|
||||
onLongPress: showContextMenuFromTap,
|
||||
onSecondaryTapDown: storeTapPosition,
|
||||
onSecondaryTap: showContextMenuFromTap,
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(tk.radiusSm),
|
||||
child: OptimizedMediaImage(
|
||||
client: client,
|
||||
imagePath: widget.track.thumbPath,
|
||||
imageType: ImageType.square,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fallbackIcon: Symbols.music_note_rounded,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.desktop)
|
||||
IconButton(
|
||||
icon: AppIcon(Symbols.skip_previous_rounded, fill: 1, color: tk.text),
|
||||
tooltip: t.music.previousTrack,
|
||||
onPressed: () => unawaited(service.previous()),
|
||||
),
|
||||
IconButton(
|
||||
icon: AppIcon(
|
||||
isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
|
||||
fill: 1,
|
||||
color: tk.text,
|
||||
),
|
||||
tooltip: isPlaying ? t.common.pause : t.common.play,
|
||||
onPressed: () => unawaited(service.togglePlayPause()),
|
||||
),
|
||||
IconButton(
|
||||
icon: AppIcon(Symbols.skip_next_rounded, fill: 1, color: tk.text),
|
||||
tooltip: t.music.nextTrack,
|
||||
onPressed: () => unawaited(service.next()),
|
||||
),
|
||||
if (widget.desktop)
|
||||
AnimatedOpacity(
|
||||
opacity: _hovered ? 1 : 0,
|
||||
duration: tk.fast,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_hovered,
|
||||
child: IconButton(
|
||||
icon: AppIcon(Symbols.close_rounded, fill: 1, size: 20, color: tk.textMuted),
|
||||
tooltip: t.music.stopPlayback,
|
||||
onPressed: widget.onDismissed,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: .center,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(
|
||||
widget.track.title ?? '',
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: TextStyle(fontSize: 14, fontWeight: .w600, color: tk.text),
|
||||
),
|
||||
if (artist != null && artist.isNotEmpty)
|
||||
Text(
|
||||
artist,
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: TextStyle(fontSize: 12, color: tk.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
FocusableActionBar(
|
||||
key: _transportKey,
|
||||
onNavigateLeft: _detailsFocusNode.requestFocus,
|
||||
actions: [
|
||||
if (widget.desktop)
|
||||
FocusableAction(
|
||||
icon: Symbols.skip_previous_rounded,
|
||||
iconColor: tk.text,
|
||||
tooltip: t.music.previousTrack,
|
||||
onPressed: () => unawaited(service.previous()),
|
||||
),
|
||||
FocusableAction(
|
||||
icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
|
||||
iconColor: tk.text,
|
||||
tooltip: isPlaying ? t.common.pause : t.common.play,
|
||||
onPressed: () => unawaited(service.togglePlayPause()),
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.skip_next_rounded,
|
||||
iconColor: tk.text,
|
||||
tooltip: t.music.nextTrack,
|
||||
onPressed: () => unawaited(service.next()),
|
||||
),
|
||||
if (widget.desktop)
|
||||
FocusableAction(
|
||||
icon: Symbols.close_rounded,
|
||||
iconColor: tk.textMuted,
|
||||
tooltip: t.music.stopPlayback,
|
||||
onPressed: widget.onDismissed,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -377,12 +400,7 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
|
||||
child: card,
|
||||
);
|
||||
|
||||
if (!widget.desktop) return card;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: card,
|
||||
);
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/focusable_wrapper.dart';
|
||||
import '../services/trackers/oauth_proxy_client.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import 'dialog_action_button.dart';
|
||||
@@ -55,18 +56,26 @@ class OAuthProxyDialog extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
InkWell(
|
||||
onTap: () => _copyUrl(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Text(
|
||||
start.url,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
FocusableWrapper(
|
||||
onSelect: () => _copyUrl(context),
|
||||
semanticLabel: t.services.oauthProxy.copyUrl,
|
||||
descendantsAreFocusable: false,
|
||||
borderRadius: 8,
|
||||
useBackgroundFocus: true,
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
onTap: () => _copyUrl(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Text(
|
||||
start.url,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -75,6 +84,7 @@ class OAuthProxyDialog extends StatelessWidget {
|
||||
width: double.infinity,
|
||||
child: FocusableButton(
|
||||
onPressed: _open,
|
||||
useBackgroundFocus: true,
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: Text(t.services.oauthProxy.openToSignIn(service: serviceName)),
|
||||
|
||||
@@ -156,20 +156,41 @@ class OverlaySheetController {
|
||||
|
||||
/// Push a sub-page using the overlay system if available, otherwise fall
|
||||
/// back to [showModalBottomSheet]. Returns the result from the page.
|
||||
///
|
||||
/// Presentation options apply only to the modal fallback. A hosted push
|
||||
/// retains the root sheet's presentation and changes only its page content.
|
||||
static Future<T?> pushAdaptive<T>(
|
||||
BuildContext context, {
|
||||
required WidgetBuilder builder,
|
||||
FocusNode? initialFocusNode,
|
||||
BoxConstraints? constraints,
|
||||
Color? backgroundColor,
|
||||
bool barrierDismissible = true,
|
||||
bool isScrollControlled = false,
|
||||
bool showDragHandle = false,
|
||||
}) async {
|
||||
final controller = maybeOf(context);
|
||||
if (controller != null) {
|
||||
return controller.push<T>(builder: builder, initialFocusNode: initialFocusNode);
|
||||
}
|
||||
final effectiveConstraints =
|
||||
constraints ??
|
||||
() {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final isDesktop = size.width > 600;
|
||||
return BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75);
|
||||
}();
|
||||
BackKeyCoordinator.clear();
|
||||
openSheetCount.value++;
|
||||
try {
|
||||
return await showModalBottomSheet<T>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(top: false, child: builder(context)),
|
||||
constraints: effectiveConstraints,
|
||||
backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.surface,
|
||||
isDismissible: barrierDismissible,
|
||||
isScrollControlled: isScrollControlled,
|
||||
showDragHandle: showDragHandle,
|
||||
);
|
||||
} finally {
|
||||
openSheetCount.value--;
|
||||
@@ -310,6 +331,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
Alignment alignment = Alignment.bottomCenter,
|
||||
bool showDragHandle = false,
|
||||
}) {
|
||||
BackKeyCoordinator.clear();
|
||||
// If already open, close first (instant)
|
||||
final wasOpen = _isOpen;
|
||||
if (_isOpen) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import '../utils/snackbar_helper.dart';
|
||||
import 'app_icon.dart';
|
||||
import 'app_menu.dart';
|
||||
import 'loading_indicator_box.dart';
|
||||
import 'focusable_list_tile.dart';
|
||||
import 'overlay_sheet.dart';
|
||||
import 'stat_chip.dart';
|
||||
|
||||
@@ -396,7 +397,7 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
|
||||
else ...[
|
||||
if (!_isMovie && _partialSeasons) ..._buildSeasonSection(theme),
|
||||
if (_can4k)
|
||||
SwitchListTile(
|
||||
FocusableSwitchListTile(
|
||||
value: _is4k,
|
||||
onChanged: _submitting ? null : _toggle4k,
|
||||
title: Text(t.seerr.request4k),
|
||||
@@ -432,7 +433,11 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
|
||||
children: [
|
||||
Text(t.seerr.requestsLoadFailed, style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(onPressed: () => unawaited(_load()), child: Text(t.common.retry)),
|
||||
FocusableButton(
|
||||
autofocus: true,
|
||||
onPressed: () => unawaited(_load()),
|
||||
child: OutlinedButton(onPressed: () => unawaited(_load()), child: Text(t.common.retry)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -480,7 +485,7 @@ class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
|
||||
final number = season.seasonNumber;
|
||||
final blockedLabel = _seasonBlockedLabel(number);
|
||||
final episodeCount = season.episodeCount;
|
||||
return CheckboxListTile(
|
||||
return FocusableCheckboxListTile(
|
||||
focusNode: _seasonFocusNodes[index],
|
||||
value: blockedLabel != null || _selectedSeasons.contains(number),
|
||||
onChanged: blockedLabel != null || _submitting
|
||||
@@ -604,7 +609,7 @@ class _PickerTile<T> extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
return FocusableListTile(
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(label),
|
||||
subtitle: value.isEmpty ? null : Text(value, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
|
||||
+104
-105
@@ -6,7 +6,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../screens/settings/settings_utils.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import 'app_icon.dart';
|
||||
import 'clickable_cursor.dart';
|
||||
import 'focusable_list_tile.dart';
|
||||
import 'settings_section.dart';
|
||||
|
||||
/// Reactive setting tiles bound to a [Pref] via [SettingsService.listenable].
|
||||
@@ -43,22 +43,21 @@ class SettingSwitchTile extends StatelessWidget {
|
||||
final svc = _TileBase._svc;
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: svc.listenable(pref),
|
||||
builder: (_, value, _) => ClickableCursor(
|
||||
enabled: enabled,
|
||||
child: SwitchListTile(
|
||||
focusNode: focusNode,
|
||||
secondary: AppIcon(icon, fill: 1),
|
||||
title: Text(title),
|
||||
subtitle: subtitle != null ? Text(subtitle!) : null,
|
||||
value: value,
|
||||
onChanged: enabled
|
||||
? (v) async {
|
||||
await svc.write(pref, v);
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(v);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
builder: (_, value, _) => FocusableSwitchListTile(
|
||||
focusNode: focusNode,
|
||||
secondary: AppIcon(icon, fill: 1),
|
||||
title: Text(title),
|
||||
subtitle: subtitle != null ? Text(subtitle!) : null,
|
||||
value: value,
|
||||
dense: false,
|
||||
visualDensity: VisualDensity.standard,
|
||||
onChanged: enabled
|
||||
? (v) async {
|
||||
await svc.write(pref, v);
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(v);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -87,15 +86,15 @@ class SettingNavigationTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClickableCursor(
|
||||
child: ListTile(
|
||||
focusNode: focusNode,
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(title),
|
||||
subtitle: subtitle != null ? Text(subtitle!) : null,
|
||||
trailing: AppIcon(trailingIcon, fill: 1),
|
||||
onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)),
|
||||
),
|
||||
return FocusableListTile(
|
||||
focusNode: focusNode,
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(title),
|
||||
subtitle: subtitle != null ? Text(subtitle!) : null,
|
||||
trailing: AppIcon(trailingIcon, fill: 1),
|
||||
onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)),
|
||||
dense: false,
|
||||
visualDensity: VisualDensity.standard,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -130,26 +129,26 @@ class SettingNumberTile extends StatelessWidget {
|
||||
final svc = _TileBase._svc;
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: svc.listenable(pref),
|
||||
builder: (_, value, _) => ClickableCursor(
|
||||
child: ListTile(
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitleBuilder(value)),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => showNumericInputDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
labelText: labelText,
|
||||
suffixText: suffixText,
|
||||
min: min,
|
||||
max: max,
|
||||
currentValue: value,
|
||||
onSave: (v) async {
|
||||
await svc.write(pref, v);
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(v);
|
||||
},
|
||||
),
|
||||
builder: (_, value, _) => FocusableListTile(
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitleBuilder(value)),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
dense: false,
|
||||
visualDensity: VisualDensity.standard,
|
||||
onTap: () => showNumericInputDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
labelText: labelText,
|
||||
suffixText: suffixText,
|
||||
min: min,
|
||||
max: max,
|
||||
currentValue: value,
|
||||
onSave: (v) async {
|
||||
await svc.write(pref, v);
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(v);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -188,25 +187,25 @@ class SettingSelectionTile<T, S> extends StatelessWidget {
|
||||
valueListenable: svc.listenable(pref),
|
||||
builder: (_, raw, _) {
|
||||
final value = decode(raw);
|
||||
return ClickableCursor(
|
||||
child: ListTile(
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitleBuilder(value)),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () async {
|
||||
final picked = await showSelectionDialog<T>(
|
||||
context: context,
|
||||
title: title,
|
||||
options: options,
|
||||
currentValue: value,
|
||||
);
|
||||
if (picked == null) return;
|
||||
await svc.write(pref, encode(picked));
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(picked);
|
||||
},
|
||||
),
|
||||
return FocusableListTile(
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitleBuilder(value)),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
dense: false,
|
||||
visualDensity: VisualDensity.standard,
|
||||
onTap: () async {
|
||||
final picked = await showSelectionDialog<T>(
|
||||
context: context,
|
||||
title: title,
|
||||
options: options,
|
||||
currentValue: value,
|
||||
);
|
||||
if (picked == null) return;
|
||||
await svc.write(pref, encode(picked));
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(picked);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -237,23 +236,23 @@ class SettingRegexTile extends StatelessWidget {
|
||||
final svc = _TileBase._svc;
|
||||
return ValueListenableBuilder<String>(
|
||||
valueListenable: svc.listenable(pref),
|
||||
builder: (_, value, _) => ClickableCursor(
|
||||
child: ListTile(
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => showRegexInputDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
currentValue: value,
|
||||
defaultValue: defaultValue,
|
||||
onSave: (v) async {
|
||||
await svc.write(pref, v);
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(v);
|
||||
},
|
||||
),
|
||||
builder: (_, value, _) => FocusableListTile(
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
dense: false,
|
||||
visualDensity: VisualDensity.standard,
|
||||
onTap: () => showRegexInputDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
currentValue: value,
|
||||
defaultValue: defaultValue,
|
||||
onSave: (v) async {
|
||||
await svc.write(pref, v);
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(v);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -329,31 +328,31 @@ class SettingColorTile extends StatelessWidget {
|
||||
final svc = _TileBase._svc;
|
||||
return ValueListenableBuilder<String>(
|
||||
valueListenable: svc.listenable(pref),
|
||||
builder: (_, hex, _) => ClickableCursor(
|
||||
child: ListTile(
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(title),
|
||||
subtitle: subtitle != null ? Text(subtitle!) : null,
|
||||
trailing: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: hexToColor(hex),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
onTap: () => showColorInputDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
currentHex: hex,
|
||||
onSave: (v) async {
|
||||
await svc.write(pref, v);
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(v);
|
||||
},
|
||||
builder: (_, hex, _) => FocusableListTile(
|
||||
leading: AppIcon(icon, fill: 1),
|
||||
title: Text(title),
|
||||
subtitle: subtitle != null ? Text(subtitle!) : null,
|
||||
trailing: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: hexToColor(hex),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
dense: false,
|
||||
visualDensity: VisualDensity.standard,
|
||||
onTap: () => showColorInputDialog(
|
||||
context: context,
|
||||
title: title,
|
||||
currentHex: hex,
|
||||
onSave: (v) async {
|
||||
await svc.write(pref, v);
|
||||
final callback = onAfterWrite;
|
||||
if (callback != null) await callback(v);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focus_memory_tracker.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_library.dart';
|
||||
import '../mixins/mounted_set_state_mixin.dart';
|
||||
@@ -103,7 +104,7 @@ class NavigationRailItem extends StatelessWidget {
|
||||
return ListenableBuilder(
|
||||
listenable: focusNode,
|
||||
builder: (context, _) {
|
||||
final focused = focusNode.hasFocus;
|
||||
final focused = focusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
|
||||
return Focus(
|
||||
focusNode: focusNode,
|
||||
autofocus: autofocus,
|
||||
@@ -1014,9 +1015,10 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: () {
|
||||
if (isCollapsed) return librariesFocusNode.hasFocus ? t.text.withValues(alpha: 0.08) : null;
|
||||
final showFocus = librariesFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
|
||||
if (isCollapsed) return showFocus ? t.text.withValues(alpha: 0.08) : null;
|
||||
if (showLibrariesSelectedBackground) return t.text.withValues(alpha: 0.1);
|
||||
if (librariesFocusNode.hasFocus) return t.text.withValues(alpha: 0.08);
|
||||
if (showFocus) return t.text.withValues(alpha: 0.08);
|
||||
return null;
|
||||
}(),
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
|
||||
@@ -1240,7 +1242,9 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
||||
borderRadius: radius,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: focusNode.hasFocus ? t.text.withValues(alpha: 0.08) : null,
|
||||
color: focusNode.hasFocus && InputModeTracker.isKeyboardMode(context)
|
||||
? t.text.withValues(alpha: 0.08)
|
||||
: null,
|
||||
borderRadius: radius,
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
|
||||
@@ -300,6 +300,7 @@ class _ColorChannelRowState extends State<_ColorChannelRow> with KeyRepeatHelper
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
autofocus: widget.autofocus,
|
||||
descendantsAreFocusable: false,
|
||||
onFocusChange: (hasFocus) {
|
||||
setState(() => _isFocused = hasFocus);
|
||||
if (!hasFocus) stopRepeat();
|
||||
|
||||
@@ -130,6 +130,7 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> with KeyRepeatHelper<
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
autofocus: widget.autofocus,
|
||||
descendantsAreFocusable: false,
|
||||
onFocusChange: (hasFocus) {
|
||||
setState(() => _isFocused = hasFocus);
|
||||
if (!hasFocus) stopRepeat();
|
||||
|
||||
@@ -693,6 +693,8 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
metadata: widget.metadata,
|
||||
style: Platform.isMacOS ? VideoHeaderStyle.singleLine : VideoHeaderStyle.multiLine,
|
||||
onBack: widget.onBack,
|
||||
onCancelAutoHide: widget.onCancelAutoHide,
|
||||
onStartAutoHide: widget.onStartAutoHide,
|
||||
),
|
||||
),
|
||||
if (_isLive && (widget.captureBuffer == null || widget.isAtLiveEdge)) ...[
|
||||
|
||||
@@ -344,6 +344,8 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
|
||||
child: VideoControlsHeader(
|
||||
metadata: widget.metadata,
|
||||
style: VideoHeaderStyle.multiLine,
|
||||
onCancelAutoHide: widget.onCancelAutoHide,
|
||||
onStartAutoHide: widget.onStartAutoHide,
|
||||
trailing: widget.trackChapterControls,
|
||||
onBack: widget.onBack,
|
||||
),
|
||||
|
||||
@@ -139,6 +139,13 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
|
||||
OverlaySheetController.of(context).refocus();
|
||||
}
|
||||
|
||||
void _hideLanguagePickerView() {
|
||||
setState(() => _showLanguagePicker = false);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _languageFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
void _focusFirstResult() {
|
||||
if (_results != null && _results!.isNotEmpty && !_isSearching && _error == null) {
|
||||
_firstResultFocusNode.requestFocus();
|
||||
@@ -219,7 +226,7 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
|
||||
return _LanguagePickerView(
|
||||
currentCode: _languageCode,
|
||||
onSelected: _onLanguageSelected,
|
||||
onBack: () => setState(() => _showLanguagePicker = false),
|
||||
onBack: _hideLanguagePickerView,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -268,8 +268,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
final propertyName = isSubtitle ? 'sub-delay' : 'audio-delay';
|
||||
final initialOffset = isSubtitle ? _subtitleSyncOffset : _audioSyncOffset;
|
||||
|
||||
// Created here so we can pass it as initialFocusNode to the overlay sheet,
|
||||
// ensuring the slider gets focus when the bar opens. Disposed by _CompactSyncBar.
|
||||
// Created here so it can be passed as the overlay's initial focus target.
|
||||
// The creator disposes it after the overlay's lifecycle completes.
|
||||
final sliderFocusNode = FocusNode(debugLabel: 'SyncSlider');
|
||||
|
||||
// show() with new alignment replaces the current sheet (completing the
|
||||
@@ -299,6 +299,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
),
|
||||
)
|
||||
.whenComplete(() {
|
||||
sliderFocusNode.dispose();
|
||||
widget.onStartAutoHide?.call();
|
||||
});
|
||||
|
||||
@@ -1073,7 +1074,6 @@ class _CompactSyncBarState extends State<_CompactSyncBar> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.sliderFocusNode.dispose();
|
||||
_resetFocusNode.dispose();
|
||||
_closeFocusNode.dispose();
|
||||
super.dispose();
|
||||
|
||||
@@ -70,6 +70,7 @@ class SleepTimerActiveStatus extends StatelessWidget {
|
||||
sleepTimer.cancelTimer();
|
||||
onCancel?.call();
|
||||
},
|
||||
useBackgroundFocus: true,
|
||||
child: FilledButton.icon(
|
||||
icon: const AppIcon(Symbols.cancel_rounded, fill: 1),
|
||||
label: Text(t.common.cancel),
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../focus/dpad_navigator.dart';
|
||||
import '../../../focus/focusable_slider.dart';
|
||||
import '../../../focus/focusable_button.dart';
|
||||
import '../../../focus/focusable_wrapper.dart';
|
||||
import '../../../mpv/mpv.dart';
|
||||
@@ -233,35 +232,19 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
||||
onLongPressStart: _startLongPressDecrement,
|
||||
),
|
||||
Expanded(
|
||||
child: Focus(
|
||||
onKeyEvent: (node, event) {
|
||||
// Select/enter on the slider jumps focus to the close button
|
||||
if (event.logicalKey.isSelectKey && event is KeyDownEvent) {
|
||||
widget.closeFocusNode?.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
canRequestFocus: false,
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(tickMarkShape: SliderTickMarkShape.noTickMark),
|
||||
child: Slider(
|
||||
focusNode: widget.sliderFocusNode,
|
||||
value: sliderValue,
|
||||
min: _sliderMin,
|
||||
max: _sliderMax,
|
||||
divisions: _sliderDivisions,
|
||||
activeColor: Theme.of(context).colorScheme.primary,
|
||||
inactiveColor: Theme.of(context).colorScheme.outlineVariant,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_currentOffset = value;
|
||||
});
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
_applyOffset(value);
|
||||
},
|
||||
),
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(tickMarkShape: SliderTickMarkShape.noTickMark),
|
||||
child: FocusableSlider(
|
||||
focusNode: widget.sliderFocusNode,
|
||||
value: sliderValue,
|
||||
min: _sliderMin,
|
||||
max: _sliderMax,
|
||||
divisions: _sliderDivisions,
|
||||
activeColor: Theme.of(context).colorScheme.primary,
|
||||
inactiveColor: Theme.of(context).colorScheme.outlineVariant,
|
||||
onSelect: widget.closeFocusNode?.requestFocus,
|
||||
onChanged: (value) => setState(() => _currentOffset = value),
|
||||
onChangeEnd: _applyOffset,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -373,6 +356,7 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
|
||||
// Reset button
|
||||
FocusableButton(
|
||||
onPressed: _currentOffset != 0 ? _resetOffset : null,
|
||||
useBackgroundFocus: true,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _currentOffset != 0 ? _resetOffset : null,
|
||||
icon: const AppIcon(Symbols.restart_alt_rounded, fill: 1),
|
||||
|
||||
@@ -30,6 +30,8 @@ class VideoControlsHeader extends StatelessWidget {
|
||||
|
||||
/// Optional callback for back button. If null, defaults to Navigator.pop(true).
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback? onCancelAutoHide;
|
||||
final VoidCallback? onStartAutoHide;
|
||||
|
||||
const VideoControlsHeader({
|
||||
super.key,
|
||||
@@ -37,6 +39,8 @@ class VideoControlsHeader extends StatelessWidget {
|
||||
this.style = VideoHeaderStyle.multiLine,
|
||||
this.trailing,
|
||||
this.onBack,
|
||||
this.onCancelAutoHide,
|
||||
this.onStartAutoHide,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -54,7 +58,13 @@ class VideoControlsHeader extends StatelessWidget {
|
||||
selector: (_, p) => p.isInSession,
|
||||
builder: (context, inSession, child) {
|
||||
if (!inSession) return const SizedBox.shrink();
|
||||
return const Padding(padding: .only(right: 8), child: WatchTogetherSessionIndicator());
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: WatchTogetherSessionIndicator(
|
||||
onCancelAutoHide: onCancelAutoHide,
|
||||
onStartAutoHide: onStartAutoHide,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
?trailing,
|
||||
|
||||
Reference in New Issue
Block a user