refactor: deduplicate sheet, key handler, and service patterns
This commit is contained in:
@@ -83,6 +83,62 @@ KeyEventResult handleBackKeyNavigation<T>(BuildContext context, KeyEvent event,
|
||||
return handleBackKeyAction(event, () => Navigator.pop(context, result));
|
||||
}
|
||||
|
||||
/// Creates a [FocusOnKeyEventCallback] that dispatches d-pad / arrow keys to
|
||||
/// the provided directional callbacks.
|
||||
///
|
||||
/// Each callback is optional. Directions without a callback are ignored
|
||||
/// (passed through to the framework). Directions mapped to a callback
|
||||
/// automatically return [KeyEventResult.handled].
|
||||
///
|
||||
/// Only [KeyDownEvent] and [KeyRepeatEvent] are handled (via [isActionable]).
|
||||
///
|
||||
/// ```dart
|
||||
/// Focus(
|
||||
/// onKeyEvent: dpadKeyHandler(
|
||||
/// onUp: () => _focusAppBar(),
|
||||
/// onDown: () => _focusContent(),
|
||||
/// onLeft: () => _navigateToSidebar(),
|
||||
/// onSelect: () => _play(),
|
||||
/// ),
|
||||
/// child: ...
|
||||
/// )
|
||||
/// ```
|
||||
FocusOnKeyEventCallback dpadKeyHandler({
|
||||
VoidCallback? onUp,
|
||||
VoidCallback? onDown,
|
||||
VoidCallback? onLeft,
|
||||
VoidCallback? onRight,
|
||||
VoidCallback? onSelect,
|
||||
}) {
|
||||
return (FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (key.isUpKey && onUp != null) {
|
||||
onUp();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isDownKey && onDown != null) {
|
||||
onDown();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isLeftKey && onLeft != null) {
|
||||
onLeft();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isRightKey && onRight != null) {
|
||||
onRight();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isSelectKey && onSelect != null) {
|
||||
onSelect();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
};
|
||||
}
|
||||
|
||||
/// Navigator observer that automatically suppresses stray back KeyUp events
|
||||
/// after any route pop caused by a back key press.
|
||||
///
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/deletion_notifier.dart';
|
||||
import 'event_aware.dart';
|
||||
|
||||
/// Mixin for screens that need to react to deletion events.
|
||||
///
|
||||
@@ -56,31 +57,14 @@ mixin DeletionAware<T extends StatefulWidget> on State<T> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_subscribeToDeletions();
|
||||
}
|
||||
|
||||
void _subscribeToDeletions() {
|
||||
_deletionSubscription = DeletionNotifier().stream.listen((event) {
|
||||
if (!mounted) return;
|
||||
|
||||
final serverId = deletionServerId;
|
||||
if (serverId != null && event.serverId != serverId) return;
|
||||
|
||||
final globalKeys = deletionGlobalKeys;
|
||||
if (globalKeys != null) {
|
||||
if (event.affectsAnyGlobalKey(globalKeys)) {
|
||||
onDeletionEvent(event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final ratingKeys = deletionRatingKeys;
|
||||
// If keys is null, receive all events
|
||||
// Otherwise, filter to events that affect our keys
|
||||
if (ratingKeys == null || event.affectsAnyOf(ratingKeys)) {
|
||||
onDeletionEvent(event);
|
||||
}
|
||||
});
|
||||
_deletionSubscription = subscribeToHierarchicalEvents<DeletionEvent>(
|
||||
notifier: DeletionNotifier(),
|
||||
mounted: () => mounted,
|
||||
serverId: () => deletionServerId,
|
||||
globalKeys: () => deletionGlobalKeys,
|
||||
ratingKeys: () => deletionRatingKeys,
|
||||
onEvent: onDeletionEvent,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'dart:async';
|
||||
import '../utils/base_notifier.dart';
|
||||
import '../utils/hierarchical_event_mixin.dart';
|
||||
|
||||
/// Creates a filtered stream subscription for hierarchical events.
|
||||
///
|
||||
/// Used internally by [DeletionAware] and [WatchStateAware] to avoid
|
||||
/// duplicating the subscription and filtering logic.
|
||||
StreamSubscription<E> subscribeToHierarchicalEvents<E extends HierarchicalEventMixin>({
|
||||
required BaseNotifier<E> notifier,
|
||||
required bool Function() mounted,
|
||||
required String? Function() serverId,
|
||||
required Set<String>? Function() globalKeys,
|
||||
required Set<String>? Function() ratingKeys,
|
||||
required void Function(E event) onEvent,
|
||||
}) {
|
||||
return notifier.stream.listen((event) {
|
||||
if (!mounted()) return;
|
||||
|
||||
final sid = serverId();
|
||||
if (sid != null && event.serverId != sid) return;
|
||||
|
||||
final gk = globalKeys();
|
||||
if (gk != null) {
|
||||
if (event.affectsAnyGlobalKey(gk)) {
|
||||
onEvent(event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final rk = ratingKeys();
|
||||
if (rk == null || event.affectsAnyOf(rk)) {
|
||||
onEvent(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/watch_state_notifier.dart';
|
||||
import 'event_aware.dart';
|
||||
|
||||
/// Mixin for screens that need to react to watch state changes.
|
||||
///
|
||||
@@ -55,31 +56,14 @@ mixin WatchStateAware<T extends StatefulWidget> on State<T> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_subscribeToWatchState();
|
||||
}
|
||||
|
||||
void _subscribeToWatchState() {
|
||||
_watchStateSubscription = WatchStateNotifier().stream.listen((event) {
|
||||
if (!mounted) return;
|
||||
|
||||
final serverId = watchStateServerId;
|
||||
if (serverId != null && event.serverId != serverId) return;
|
||||
|
||||
final globalKeys = watchedGlobalKeys;
|
||||
if (globalKeys != null) {
|
||||
if (event.affectsAnyGlobalKey(globalKeys)) {
|
||||
onWatchStateChanged(event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final keys = watchedRatingKeys;
|
||||
// If keys is null, receive all events
|
||||
// Otherwise, filter to events that affect our keys
|
||||
if (keys == null || event.affectsAnyOf(keys)) {
|
||||
onWatchStateChanged(event);
|
||||
}
|
||||
});
|
||||
_watchStateSubscription = subscribeToHierarchicalEvents<WatchStateEvent>(
|
||||
notifier: WatchStateNotifier(),
|
||||
mounted: () => mounted,
|
||||
serverId: () => watchStateServerId,
|
||||
globalKeys: () => watchedGlobalKeys,
|
||||
ratingKeys: () => watchedRatingKeys,
|
||||
onEvent: onWatchStateChanged,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -59,77 +59,68 @@ class SettingsProvider extends ChangeNotifier {
|
||||
|
||||
bool get showUnwatchedCount => _showUnwatchedCount;
|
||||
|
||||
Future<void> setLibraryDensity(LibraryDensity density) async {
|
||||
/// Helper to update a setting: ensures init, deduplicates, persists, notifies.
|
||||
Future<void> _updateSetting<T>({
|
||||
required T current,
|
||||
required T value,
|
||||
required void Function(T) setLocal,
|
||||
required Future<void> Function(T) persist,
|
||||
}) async {
|
||||
if (!_isInitialized) await _initializeSettings();
|
||||
if (_libraryDensity != density) {
|
||||
_libraryDensity = density;
|
||||
await _settingsService!.setLibraryDensity(density);
|
||||
if (current != value) {
|
||||
setLocal(value);
|
||||
await persist(value);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setViewMode(ViewMode mode) async {
|
||||
if (!_isInitialized) await _initializeSettings();
|
||||
if (_viewMode != mode) {
|
||||
_viewMode = mode;
|
||||
await _settingsService!.setViewMode(mode);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Future<void> setLibraryDensity(LibraryDensity density) => _updateSetting(
|
||||
current: _libraryDensity, value: density,
|
||||
setLocal: (v) => _libraryDensity = v,
|
||||
persist: _settingsService!.setLibraryDensity,
|
||||
);
|
||||
|
||||
Future<void> setEpisodePosterMode(EpisodePosterMode mode) async {
|
||||
if (!_isInitialized) await _initializeSettings();
|
||||
if (_episodePosterMode != mode) {
|
||||
_episodePosterMode = mode;
|
||||
await _settingsService!.setEpisodePosterMode(mode);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Future<void> setViewMode(ViewMode mode) => _updateSetting(
|
||||
current: _viewMode, value: mode,
|
||||
setLocal: (v) => _viewMode = v,
|
||||
persist: _settingsService!.setViewMode,
|
||||
);
|
||||
|
||||
Future<void> setShowHeroSection(bool value) async {
|
||||
if (!_isInitialized) await _initializeSettings();
|
||||
if (_showHeroSection != value) {
|
||||
_showHeroSection = value;
|
||||
await _settingsService!.setShowHeroSection(value);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Future<void> setEpisodePosterMode(EpisodePosterMode mode) => _updateSetting(
|
||||
current: _episodePosterMode, value: mode,
|
||||
setLocal: (v) => _episodePosterMode = v,
|
||||
persist: _settingsService!.setEpisodePosterMode,
|
||||
);
|
||||
|
||||
Future<void> setUseGlobalHubs(bool value) async {
|
||||
if (!_isInitialized) await _initializeSettings();
|
||||
if (_useGlobalHubs != value) {
|
||||
_useGlobalHubs = value;
|
||||
await _settingsService!.setUseGlobalHubs(value);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Future<void> setShowHeroSection(bool value) => _updateSetting(
|
||||
current: _showHeroSection, value: value,
|
||||
setLocal: (v) => _showHeroSection = v,
|
||||
persist: _settingsService!.setShowHeroSection,
|
||||
);
|
||||
|
||||
Future<void> setShowServerNameOnHubs(bool value) async {
|
||||
if (!_isInitialized) await _initializeSettings();
|
||||
if (_showServerNameOnHubs != value) {
|
||||
_showServerNameOnHubs = value;
|
||||
await _settingsService!.setShowServerNameOnHubs(value);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Future<void> setUseGlobalHubs(bool value) => _updateSetting(
|
||||
current: _useGlobalHubs, value: value,
|
||||
setLocal: (v) => _useGlobalHubs = v,
|
||||
persist: _settingsService!.setUseGlobalHubs,
|
||||
);
|
||||
|
||||
Future<void> setAlwaysKeepSidebarOpen(bool value) async {
|
||||
if (!_isInitialized) await _initializeSettings();
|
||||
if (_alwaysKeepSidebarOpen != value) {
|
||||
_alwaysKeepSidebarOpen = value;
|
||||
await _settingsService!.setAlwaysKeepSidebarOpen(value);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Future<void> setShowServerNameOnHubs(bool value) => _updateSetting(
|
||||
current: _showServerNameOnHubs, value: value,
|
||||
setLocal: (v) => _showServerNameOnHubs = v,
|
||||
persist: _settingsService!.setShowServerNameOnHubs,
|
||||
);
|
||||
|
||||
Future<void> setShowUnwatchedCount(bool value) async {
|
||||
if (!_isInitialized) await _initializeSettings();
|
||||
if (_showUnwatchedCount != value) {
|
||||
_showUnwatchedCount = value;
|
||||
await _settingsService!.setShowUnwatchedCount(value);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Future<void> setAlwaysKeepSidebarOpen(bool value) => _updateSetting(
|
||||
current: _alwaysKeepSidebarOpen, value: value,
|
||||
setLocal: (v) => _alwaysKeepSidebarOpen = v,
|
||||
persist: _settingsService!.setAlwaysKeepSidebarOpen,
|
||||
);
|
||||
|
||||
Future<void> setShowUnwatchedCount(bool value) => _updateSetting(
|
||||
current: _showUnwatchedCount, value: value,
|
||||
setLocal: (v) => _showUnwatchedCount = v,
|
||||
persist: _settingsService!.setShowUnwatchedCount,
|
||||
);
|
||||
|
||||
String get libraryDensityDisplayName {
|
||||
switch (_libraryDensity) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../../services/plex_client.dart';
|
||||
import '../utils/plex_image_helper.dart';
|
||||
@@ -306,211 +306,69 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
|
||||
/// Handle key events for the hero section
|
||||
KeyEventResult _handleHeroKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
// DOWN: Move to first hub
|
||||
if (key.isDownKey) {
|
||||
late final _handleHeroKeyEvent = dpadKeyHandler(
|
||||
onDown: () {
|
||||
final keys = _allHubKeys;
|
||||
if (keys.isNotEmpty) {
|
||||
keys.first.currentState?.requestFocusFromMemory();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// UP: Move to app bar (refresh button)
|
||||
if (key.isUpKey) {
|
||||
_refreshButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// LEFT: Navigate hero carousel to previous, or focus sidebar at index 0
|
||||
if (key.isLeftKey) {
|
||||
if (keys.isNotEmpty) keys.first.currentState?.requestFocusFromMemory();
|
||||
},
|
||||
onUp: () => _refreshButtonFocusNode.requestFocus(),
|
||||
onLeft: () {
|
||||
if (_currentHeroIndex > 0) {
|
||||
_heroController.previousPage(duration: tokens(context).slow, curve: Curves.easeInOut);
|
||||
} else {
|
||||
_navigateToSidebar();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// RIGHT: Navigate hero carousel to next
|
||||
if (key.isRightKey) {
|
||||
},
|
||||
onRight: () {
|
||||
if (_currentHeroIndex < _onDeck.length - 1) {
|
||||
_heroController.nextPage(duration: tokens(context).slow, curve: Curves.easeInOut);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// SELECT: Play current hero item
|
||||
if (key.isSelectKey) {
|
||||
},
|
||||
onSelect: () {
|
||||
if (_onDeck.isNotEmpty && _currentHeroIndex < _onDeck.length) {
|
||||
navigateToVideoPlayer(context, metadata: _onDeck[_currentHeroIndex]);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/// Handle key events for the refresh button in app bar
|
||||
KeyEventResult _handleRefreshKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
// DOWN: Return to hero/content
|
||||
if (key.isDownKey) {
|
||||
_focusContentFromAppBar();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// RIGHT: Move to watch together button
|
||||
if (key.isRightKey) {
|
||||
_watchTogetherButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// LEFT: Navigate to sidebar
|
||||
if (key.isLeftKey) {
|
||||
_navigateToSidebar();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// UP: Block at boundary
|
||||
if (key.isUpKey) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// SELECT: Trigger refresh
|
||||
if (key.isSelectKey) {
|
||||
_loadContent();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
late final _handleRefreshKeyEvent = dpadKeyHandler(
|
||||
onDown: _focusContentFromAppBar,
|
||||
onRight: () => _watchTogetherButtonFocusNode.requestFocus(),
|
||||
onLeft: _navigateToSidebar,
|
||||
onUp: () {}, // Block at boundary
|
||||
onSelect: _loadContent,
|
||||
);
|
||||
|
||||
/// Handle key events for the watch together button in app bar
|
||||
KeyEventResult _handleWatchTogetherKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
// DOWN: Return to hero/content
|
||||
if (key.isDownKey) {
|
||||
_focusContentFromAppBar();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// LEFT: Move to refresh button
|
||||
if (key.isLeftKey) {
|
||||
_refreshButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// RIGHT: Move to companion remote button
|
||||
if (key.isRightKey) {
|
||||
_companionRemoteButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// UP: Block at boundary
|
||||
if (key.isUpKey) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// SELECT: Navigate to Watch Together screen
|
||||
if (key.isSelectKey) {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen()));
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
late final _handleWatchTogetherKeyEvent = dpadKeyHandler(
|
||||
onDown: _focusContentFromAppBar,
|
||||
onLeft: () => _refreshButtonFocusNode.requestFocus(),
|
||||
onRight: () => _companionRemoteButtonFocusNode.requestFocus(),
|
||||
onUp: () {}, // Block at boundary
|
||||
onSelect: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())),
|
||||
);
|
||||
|
||||
/// Handle key events for the companion remote button in app bar
|
||||
KeyEventResult _handleCompanionRemoteKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
// DOWN: Return to hero
|
||||
if (key.isDownKey) {
|
||||
_heroFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// LEFT: Move to watch together button
|
||||
if (key.isLeftKey) {
|
||||
_watchTogetherButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// RIGHT: Move to user button
|
||||
if (key.isRightKey) {
|
||||
_userButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// UP: Block at boundary
|
||||
if (key.isUpKey) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// SELECT: Show companion remote dialog (host a remote session)
|
||||
if (key.isSelectKey) {
|
||||
RemoteSessionDialog.show(context);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
late final _handleCompanionRemoteKeyEvent = dpadKeyHandler(
|
||||
onDown: () => _heroFocusNode.requestFocus(),
|
||||
onLeft: () => _watchTogetherButtonFocusNode.requestFocus(),
|
||||
onRight: () => _userButtonFocusNode.requestFocus(),
|
||||
onUp: () {}, // Block at boundary
|
||||
onSelect: () => RemoteSessionDialog.show(context),
|
||||
);
|
||||
|
||||
/// Handle key events for the user button in app bar
|
||||
KeyEventResult _handleUserKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
// DOWN: Return to hero/content
|
||||
if (key.isDownKey) {
|
||||
_focusContentFromAppBar();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// LEFT: Move to companion remote button
|
||||
if (key.isLeftKey) {
|
||||
_companionRemoteButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// RIGHT/UP: Block at boundary
|
||||
if (key.isRightKey || key.isUpKey) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// SELECT: Show user menu
|
||||
if (key.isSelectKey) {
|
||||
late final _handleUserKeyEvent = dpadKeyHandler(
|
||||
onDown: _focusContentFromAppBar,
|
||||
onLeft: () => _companionRemoteButtonFocusNode.requestFocus(),
|
||||
onRight: () {}, // Block at boundary
|
||||
onUp: () {}, // Block at boundary
|
||||
onSelect: () {
|
||||
final userProvider = context.read<UserProfileProvider>();
|
||||
_showUserMenu(context, userProvider);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
||||
@@ -351,61 +351,29 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
}
|
||||
|
||||
/// Handle key events for the edit button in app bar
|
||||
KeyEventResult _handleEditKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (key.isLeftKey) {
|
||||
// Navigate back to last tab (Playlists)
|
||||
getTabChipFocusNode(3).requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isRightKey) {
|
||||
_refreshButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isDownKey) {
|
||||
_focusCurrentTab();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isUpKey) {
|
||||
return KeyEventResult.handled; // Block at boundary
|
||||
}
|
||||
if (key.isSelectKey) {
|
||||
_showLibraryManagementSheet();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
late final _handleEditKeyEvent = dpadKeyHandler(
|
||||
onLeft: () => getTabChipFocusNode(3).requestFocus(),
|
||||
onRight: () => _refreshButtonFocusNode.requestFocus(),
|
||||
onDown: _focusCurrentTab,
|
||||
onUp: () {}, // Block at boundary
|
||||
onSelect: _showLibraryManagementSheet,
|
||||
);
|
||||
|
||||
/// Handle key events for the refresh button in app bar
|
||||
KeyEventResult _handleRefreshKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (key.isLeftKey) {
|
||||
// Navigate to edit button if libraries exist, else to last tab
|
||||
late final _handleRefreshKeyEvent = dpadKeyHandler(
|
||||
onLeft: () {
|
||||
final librariesProvider = context.read<LibrariesProvider>();
|
||||
if (librariesProvider.libraries.isNotEmpty) {
|
||||
_editButtonFocusNode.requestFocus();
|
||||
} else {
|
||||
getTabChipFocusNode(3).requestFocus();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isRightKey || key.isUpKey) {
|
||||
return KeyEventResult.handled; // Block at boundary
|
||||
}
|
||||
if (key.isDownKey) {
|
||||
_focusCurrentTab();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isSelectKey) {
|
||||
_refreshCurrentTab();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
},
|
||||
onRight: () {}, // Block at boundary
|
||||
onUp: () {}, // Block at boundary
|
||||
onDown: _focusCurrentTab,
|
||||
onSelect: _refreshCurrentTab,
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -1422,52 +1390,27 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
|
||||
Future<void> _showLibraryMenuBottomSheet(BuildContext outerContext, PlexLibrary library) async {
|
||||
final menuItems = widget.getLibraryMenuItems(library);
|
||||
final controller = OverlaySheetController.maybeOf(outerContext);
|
||||
final String? selected;
|
||||
if (controller != null) {
|
||||
selected = await controller.push<String>(
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(library.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
final selected = await OverlaySheetController.pushAdaptive<String>(
|
||||
outerContext,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(library.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
...menuItems.indexed.map(
|
||||
(entry) => ListTile(
|
||||
leading: AppIcon(entry.$2.icon, fill: 1),
|
||||
title: Text(entry.$2.label),
|
||||
onTap: () => OverlaySheetController.popAdaptive(context, entry.$2.value),
|
||||
),
|
||||
...menuItems.indexed.map(
|
||||
(entry) => ListTile(
|
||||
leading: AppIcon(entry.$2.icon, fill: 1),
|
||||
title: Text(entry.$2.label),
|
||||
onTap: () => controller.pop(entry.$2.value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
selected = await showModalBottomSheet<String>(
|
||||
context: outerContext,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(library.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
...menuItems.indexed.map(
|
||||
(entry) => ListTile(
|
||||
leading: AppIcon(entry.$2.icon, fill: 1),
|
||||
title: Text(entry.$2.label),
|
||||
onTap: () => Navigator.pop(context, entry.$2.value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
|
||||
if (selected != null && mounted) {
|
||||
// Find the selected item to check if confirmation is needed
|
||||
|
||||
@@ -18,31 +18,17 @@ void showProgramDetailsSheet(
|
||||
required String? posterUrl,
|
||||
required VoidCallback? onTuneChannel,
|
||||
}) {
|
||||
final controller = OverlaySheetController.maybeOf(context);
|
||||
if (controller != null) {
|
||||
controller.show(
|
||||
builder: (sheetContext) {
|
||||
return _ProgramDetailsSheetContent(
|
||||
program: program,
|
||||
channel: channel,
|
||||
posterUrl: posterUrl,
|
||||
onTuneChannel: onTuneChannel,
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (sheetContext) {
|
||||
return _ProgramDetailsSheetContent(
|
||||
program: program,
|
||||
channel: channel,
|
||||
posterUrl: posterUrl,
|
||||
onTuneChannel: onTuneChannel,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
OverlaySheetController.showAdaptive(
|
||||
context,
|
||||
builder: (sheetContext) {
|
||||
return _ProgramDetailsSheetContent(
|
||||
program: program,
|
||||
channel: channel,
|
||||
posterUrl: posterUrl,
|
||||
onTuneChannel: onTuneChannel,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _ProgramDetailsSheetContent extends StatefulWidget {
|
||||
@@ -107,14 +93,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
|
||||
final buttons = <Widget>[];
|
||||
int buttonIndex = 0;
|
||||
|
||||
void closeSheet() {
|
||||
final controller = OverlaySheetController.maybeOf(context);
|
||||
if (controller != null) {
|
||||
controller.close();
|
||||
} else {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
void closeSheet() => OverlaySheetController.closeAdaptive(context);
|
||||
|
||||
if (program.isCurrentlyAiring && widget.onTuneChannel != null) {
|
||||
final idx = buttonIndex;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Base class for services that use SharedPreferences singleton pattern.
|
||||
@@ -33,6 +34,22 @@ abstract class BaseSharedPreferencesService {
|
||||
return _instances[T] as T;
|
||||
}
|
||||
|
||||
/// Decode a JSON string to a Map with error handling.
|
||||
///
|
||||
/// If [legacyStringOk] is true and the value is a plain string (not valid
|
||||
/// JSON), returns `{'key': jsonString, 'descending': false}` for legacy
|
||||
/// library sort compatibility.
|
||||
Map<String, dynamic> decodeJsonStringToMap(String jsonString, {bool legacyStringOk = false}) {
|
||||
try {
|
||||
return json.decode(jsonString) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
if (legacyStringOk) {
|
||||
return {'key': jsonString, 'descending': false};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Hook for subclass-specific initialization after SharedPreferences is ready.
|
||||
///
|
||||
/// Override this method to perform any setup that requires access to
|
||||
|
||||
@@ -430,7 +430,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
final jsonString = prefs.getString(_keyKeyboardShortcuts);
|
||||
if (jsonString == null) return getDefaultKeyboardShortcuts();
|
||||
|
||||
final decoded = _decodeJsonStringToMap(jsonString);
|
||||
final decoded = decodeJsonStringToMap(jsonString);
|
||||
if (decoded.isEmpty) return getDefaultKeyboardShortcuts();
|
||||
|
||||
final shortcuts = decoded.map((key, value) => MapEntry(key, value.toString()));
|
||||
@@ -494,6 +494,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
appLogger.d('Failed to parse keyboard hotkeys', error: e);
|
||||
return getDefaultKeyboardHotkeys();
|
||||
}
|
||||
}
|
||||
@@ -804,19 +805,10 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
final jsonString = prefs.getString(_keyMediaVersionPreferences);
|
||||
if (jsonString == null) return {};
|
||||
|
||||
final decoded = _decodeJsonStringToMap(jsonString);
|
||||
final decoded = decodeJsonStringToMap(jsonString);
|
||||
return decoded.map((key, value) => MapEntry(key, value as int));
|
||||
}
|
||||
|
||||
/// Helper to decode JSON string to Map with error handling
|
||||
Map<String, dynamic> _decodeJsonStringToMap(String jsonString) {
|
||||
try {
|
||||
return json.decode(jsonString) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// App Locale
|
||||
Future<void> setAppLocale(AppLocale locale) async {
|
||||
await prefs.setString(_keyAppLocale, locale.languageCode);
|
||||
@@ -1080,7 +1072,8 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
if (jsonString == null) return KnownPlayers.systemDefault;
|
||||
try {
|
||||
return ExternalPlayer.fromJsonString(jsonString);
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
appLogger.d('Failed to parse external player', error: e);
|
||||
return KnownPlayers.systemDefault;
|
||||
}
|
||||
}
|
||||
@@ -1096,7 +1089,8 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
try {
|
||||
final List<dynamic> decoded = json.decode(jsonString);
|
||||
return decoded.map((e) => ExternalPlayer.fromJson(e as Map<String, dynamic>)).toList();
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
appLogger.d('Failed to parse custom external players', error: e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ class StorageService extends BaseSharedPreferencesService {
|
||||
prefs.getString(_keyLibraryFilters);
|
||||
if (jsonString == null) return {};
|
||||
|
||||
final decoded = _decodeJsonStringToMap(jsonString);
|
||||
final decoded = decodeJsonStringToMap(jsonString);
|
||||
return decoded.map((key, value) => MapEntry(key, value.toString()));
|
||||
}
|
||||
|
||||
@@ -308,24 +308,7 @@ class StorageService extends BaseSharedPreferencesService {
|
||||
final jsonString = prefs.getString(key);
|
||||
if (jsonString == null) return null;
|
||||
|
||||
return _decodeJsonStringToMap(jsonString, legacyStringOk: legacyStringOk);
|
||||
}
|
||||
|
||||
/// Helper to decode JSON string to Map with error handling
|
||||
///
|
||||
/// [jsonString] - The JSON string to decode
|
||||
/// [legacyStringOk] - If true, returns {'key': value, 'descending': false}
|
||||
/// when value is a plain string (for legacy library sort)
|
||||
Map<String, dynamic> _decodeJsonStringToMap(String jsonString, {bool legacyStringOk = false}) {
|
||||
try {
|
||||
return json.decode(jsonString) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
if (legacyStringOk) {
|
||||
// Legacy support: if it's just a string, return it as the key
|
||||
return {'key': jsonString, 'descending': false};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
return decodeJsonStringToMap(jsonString, legacyStringOk: legacyStringOk);
|
||||
}
|
||||
|
||||
/// Remove all keys matching a prefix
|
||||
|
||||
@@ -99,14 +99,7 @@ class BottomSheetHeader extends StatelessWidget {
|
||||
child: IconButton(
|
||||
focusNode: closeFocusNode,
|
||||
icon: AppIcon(Symbols.close_rounded, fill: 1, color: iconColor),
|
||||
onPressed: onClose ?? () {
|
||||
final sheetController = OverlaySheetController.maybeOf(context);
|
||||
if (sheetController != null) {
|
||||
sheetController.close();
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
onPressed: onClose ?? () => OverlaySheetController.closeAdaptive(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -309,26 +309,14 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
_openedFromKeyboard = false;
|
||||
|
||||
if (useBottomSheet) {
|
||||
// Show overlay sheet if available, otherwise fall back to modal bottom sheet
|
||||
final overlayController = OverlaySheetController.maybeOf(context);
|
||||
if (overlayController != null) {
|
||||
selected = await overlayController.show<String>(
|
||||
builder: (context) => _FocusableContextMenuSheet(
|
||||
title: widget.item.title,
|
||||
actions: menuActions,
|
||||
focusFirstItem: openedFromKeyboard,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
selected = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (context) => _FocusableContextMenuSheet(
|
||||
title: widget.item.title,
|
||||
actions: menuActions,
|
||||
focusFirstItem: openedFromKeyboard,
|
||||
),
|
||||
);
|
||||
}
|
||||
selected = await OverlaySheetController.showAdaptive<String>(
|
||||
context,
|
||||
builder: (context) => _FocusableContextMenuSheet(
|
||||
title: widget.item.title,
|
||||
actions: menuActions,
|
||||
focusFirstItem: openedFromKeyboard,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Show custom focusable popup menu on larger screens
|
||||
// Use stored tap position or fallback to widget position
|
||||
@@ -580,19 +568,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
if (fileInfo != null && context.mounted) {
|
||||
// Show file info bottom sheet
|
||||
final overlayController = OverlaySheetController.maybeOf(context);
|
||||
if (overlayController != null) {
|
||||
await overlayController.show(
|
||||
builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.title),
|
||||
);
|
||||
} else {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.title),
|
||||
);
|
||||
}
|
||||
await OverlaySheetController.showAdaptive(
|
||||
context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.title),
|
||||
);
|
||||
} else if (context.mounted) {
|
||||
showErrorSnackBar(context, t.messages.fileInfoNotAvailable);
|
||||
}
|
||||
@@ -634,55 +615,29 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
String? selected;
|
||||
if (useBottomSheet) {
|
||||
final overlayController = OverlaySheetController.maybeOf(context);
|
||||
if (overlayController != null) {
|
||||
selected = await overlayController.push<String>(
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(t.common.addTo, style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
...submenuActions.map((action) {
|
||||
return ListTile(
|
||||
leading: AppIcon(action.icon, fill: 1),
|
||||
title: Text(action.label),
|
||||
onTap: () => overlayController.pop(action.value),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
selected = await OverlaySheetController.pushAdaptive<String>(
|
||||
context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(t.common.addTo, style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
...submenuActions.map((action) {
|
||||
return ListTile(
|
||||
leading: AppIcon(action.icon, fill: 1),
|
||||
title: Text(action.label),
|
||||
onTap: () => OverlaySheetController.popAdaptive(context, action.value),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
selected = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(t.common.addTo, style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
...submenuActions.map((action) {
|
||||
return ListTile(
|
||||
leading: AppIcon(action.icon, fill: 1),
|
||||
title: Text(action.label),
|
||||
onTap: () => Navigator.pop(context, action.value),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
} else {
|
||||
selected = await showMenu<String>(
|
||||
context: context,
|
||||
@@ -1392,14 +1347,7 @@ class _FocusableContextMenuSheetState extends State<_FocusableContextMenuSheet>
|
||||
focusNode: index == 0 ? _initialFocusNode : null,
|
||||
leading: AppIcon(action.icon, fill: 1),
|
||||
title: Text(action.label),
|
||||
onTap: () {
|
||||
final controller = OverlaySheetController.maybeOf(context);
|
||||
if (controller != null) {
|
||||
controller.close(action.value);
|
||||
} else {
|
||||
Navigator.pop(context, action.value);
|
||||
}
|
||||
},
|
||||
onTap: () => OverlaySheetController.closeAdaptive(context, action.value),
|
||||
hoverColor: action.hoverColor,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -87,6 +87,73 @@ class OverlaySheetController {
|
||||
void refocus() {
|
||||
_state._refocus();
|
||||
}
|
||||
|
||||
/// Show a sheet using the overlay system if available, otherwise fall back
|
||||
/// to [showModalBottomSheet]. Returns the result from the sheet.
|
||||
static Future<T?> showAdaptive<T>(
|
||||
BuildContext context, {
|
||||
required WidgetBuilder builder,
|
||||
BoxConstraints? constraints,
|
||||
Color? backgroundColor,
|
||||
bool barrierDismissible = true,
|
||||
bool isScrollControlled = false,
|
||||
FocusNode? initialFocusNode,
|
||||
}) {
|
||||
final controller = maybeOf(context);
|
||||
if (controller != null) {
|
||||
return controller.show<T>(
|
||||
builder: builder,
|
||||
constraints: constraints,
|
||||
backgroundColor: backgroundColor,
|
||||
barrierDismissible: barrierDismissible,
|
||||
initialFocusNode: initialFocusNode,
|
||||
);
|
||||
}
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
builder: builder,
|
||||
constraints: constraints,
|
||||
backgroundColor: backgroundColor ?? Colors.grey[900],
|
||||
barrierColor: Colors.black54,
|
||||
isScrollControlled: isScrollControlled,
|
||||
);
|
||||
}
|
||||
|
||||
/// Push a sub-page using the overlay system if available, otherwise fall
|
||||
/// back to [showModalBottomSheet]. Returns the result from the page.
|
||||
static Future<T?> pushAdaptive<T>(
|
||||
BuildContext context, {
|
||||
required WidgetBuilder builder,
|
||||
FocusNode? initialFocusNode,
|
||||
}) {
|
||||
final controller = maybeOf(context);
|
||||
if (controller != null) {
|
||||
return controller.push<T>(builder: builder, initialFocusNode: initialFocusNode);
|
||||
}
|
||||
return showModalBottomSheet<T>(context: context, builder: builder);
|
||||
}
|
||||
|
||||
/// Close the sheet entirely. Uses overlay controller if available,
|
||||
/// otherwise pops the route.
|
||||
static void closeAdaptive(BuildContext context, [dynamic result]) {
|
||||
final controller = maybeOf(context);
|
||||
if (controller != null) {
|
||||
controller.close(result);
|
||||
} else {
|
||||
Navigator.pop(context, result);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pop one level (sub-page or close if last page). Uses overlay controller
|
||||
/// if available, otherwise pops the route.
|
||||
static void popAdaptive(BuildContext context, [dynamic result]) {
|
||||
final controller = maybeOf(context);
|
||||
if (controller != null) {
|
||||
controller.pop(result);
|
||||
} else {
|
||||
Navigator.pop(context, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Host widget for the overlay-based bottom sheet system.
|
||||
|
||||
@@ -46,12 +46,7 @@ class SleepTimerDurationList extends StatelessWidget {
|
||||
// Pause playback when timer completes
|
||||
player.pause();
|
||||
});
|
||||
final sheetController = OverlaySheetController.maybeOf(context);
|
||||
if (sheetController != null) {
|
||||
sheetController.close();
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
OverlaySheetController.closeAdaptive(context);
|
||||
|
||||
// Show confirmation snackbar
|
||||
showSuccessSnackBar(context, t.messages.sleepTimerSet(label: label));
|
||||
|
||||
Reference in New Issue
Block a user