Files
plezy/lib/navigation/settings_shortcut.dart
edde746 958c27350d feat(navigation): open settings with Cmd+, / Ctrl+, on desktop
Pressing Cmd+, on macOS did nothing: the stock Flutter template left a
disabled Preferences menu item holding the key equivalent, and nothing
in the app handled the chord.

MainScreen now handles Cmd+, (macOS) / Ctrl+, (Windows/Linux) beside
the existing Cmd+F search shortcut, reusing its tab-aware open path. A
SettingsShortcut fallback above the profile navigator covers pushed
content routes, pushing a named settings route; a navigator observer
blocks the chord while settings is already in the stack, and the video
player route keeps ownership of the keyboard. The dead xib menu item
is removed.

close #1909
2026-08-20 05:28:37 +02:00

136 lines
4.8 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../screens/settings/settings_screen.dart';
import '../utils/platform_detector.dart';
import '../utils/video_player_navigation.dart';
/// Route name of a pushed [SettingsScreen] — lets the settings keyboard
/// shortcut recognize settings already in the stack instead of stacking a
/// duplicate.
const String kSettingsRouteName = '/settings';
/// The route every settings push site uses, so a pushed settings screen is
/// identifiable by [kSettingsRouteName].
MaterialPageRoute<void> buildSettingsRoute() {
return MaterialPageRoute<void>(
settings: const RouteSettings(name: kSettingsRouteName),
builder: (_) => const SettingsScreen(),
);
}
/// True when [event] is the desktop "open settings" chord — Cmd+, on macOS
/// (per the HIG, #1909) or Ctrl+, on Windows/Linux. Never matches on
/// non-desktop form factors: TVs and phones have no settings-shortcut
/// convention. Uses [defaultTargetPlatform] rather than `Platform.isMacOS`
/// so tests can exercise both chords from one host.
bool isSettingsShortcut(KeyEvent event) {
if (event is! KeyDownEvent) return false;
if (event.logicalKey != LogicalKeyboardKey.comma) return false;
if (!PlatformDetector.isDesktopOS() || PlatformDetector.isTV()) return false;
final isMetaPressed = HardwareKeyboard.instance.isMetaPressed;
final isControlPressed = HardwareKeyboard.instance.isControlPressed;
return defaultTargetPlatform == TargetPlatform.macOS
? isMetaPressed && !isControlPressed
: isControlPressed && !isMetaPressed;
}
/// Tracks whether a settings-named route is anywhere in a navigator's stack.
/// Registered as a navigator observer and consulted by [SettingsShortcut] so
/// the chord can never stack settings over settings, including while an
/// unnamed settings sub-page is on top of the settings route.
class SettingsRouteTracker extends NavigatorObserver {
int _liveSettingsRoutes = 0;
bool get hasSettingsRoute => _liveSettingsRoutes > 0;
static bool _isSettings(Route<dynamic>? route) => route?.settings.name == kSettingsRouteName;
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
if (_isSettings(route)) _liveSettingsRoutes++;
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
if (_isSettings(route)) _liveSettingsRoutes--;
}
@override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
if (_isSettings(route)) _liveSettingsRoutes--;
}
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
if (_isSettings(oldRoute)) _liveSettingsRoutes--;
if (_isSettings(newRoute)) _liveSettingsRoutes++;
}
}
/// Session-wide handler for the settings keyboard shortcut.
///
/// Wraps the profile-session navigator so the chord works from any pushed
/// content route (detail pages, downloads, now playing). MainScreen carries
/// its own nearer handler that reuses its tab-aware open behavior; this
/// fallback only sees the chord while focus is in a route above MainScreen.
/// No-ops while a settings route is anywhere in the stack (a settings
/// sub-page may be on top of it) or the video player is current — the player
/// owns the keyboard.
class SettingsShortcut extends StatelessWidget {
const SettingsShortcut({
super.key,
required this.navigatorKey,
required this.settingsRoutes,
this.routeBuilder = buildSettingsRoute,
required this.child,
});
/// The profile-session navigator settings routes are pushed onto.
final GlobalKey<NavigatorState> navigatorKey;
/// The tracker registered in that navigator's observers.
final SettingsRouteTracker settingsRoutes;
/// Defaults to [buildSettingsRoute]; injectable so tests can push a
/// stand-in page without the full settings provider graph.
final Route<void> Function() routeBuilder;
final Widget child;
@override
Widget build(BuildContext context) {
return Focus(
canRequestFocus: false,
skipTraversal: true,
includeSemantics: false,
onKeyEvent: (node, event) {
if (!isSettingsShortcut(event)) return KeyEventResult.ignored;
_openSettings();
return KeyEventResult.handled;
},
child: child,
);
}
void _openSettings() {
final navigator = navigatorKey.currentState;
if (navigator == null) return;
if (settingsRoutes.hasSettingsRoute) return;
if (_isVideoPlayerOnTop(navigator)) return;
navigator.push(routeBuilder());
}
static bool _isVideoPlayerOnTop(NavigatorState navigator) {
var onTop = false;
navigator.popUntil((route) {
if (route.isCurrent) onTop = route.settings.name == kVideoPlayerRouteName;
return true; // inspect only — never pops
});
return onTop;
}
}