From 1c853b1c396329609cebf0c9ca1e61e0c985d2a3 Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 29 Nov 2023 15:59:25 -0700 Subject: [PATCH] Unify shortcuts on desktop/web, remove arrow-press for focus traversal from desktop. Add focusNode support for btns --- lib/main.dart | 3 ++ lib/ui/common/app_shortcuts.dart | 47 +++++++++++++++++++++ lib/ui/common/controls/buttons.dart | 48 +++++++++++++++++----- lib/ui/common/controls/circle_buttons.dart | 5 ++- 4 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 lib/ui/common/app_shortcuts.dart diff --git a/lib/main.dart b/lib/main.dart index 3ba7fa3d..9fd75723 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; @@ -9,6 +10,7 @@ import 'package:wonders/logic/artifact_api_service.dart'; import 'package:wonders/logic/timeline_logic.dart'; import 'package:wonders/logic/unsplash_logic.dart'; import 'package:wonders/logic/wonders_logic.dart'; +import 'package:wonders/ui/common/app_shortcuts.dart'; void main() async { WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); @@ -36,6 +38,7 @@ class WondersApp extends StatelessWidget with GetItMixin { locale: locale == null ? null : Locale(locale), debugShowCheckedModeBanner: false, routerDelegate: appRouter.routerDelegate, + shortcuts: AppShortcuts.defaults, theme: ThemeData(fontFamily: $styles.text.body.fontFamily, useMaterial3: true), localizationsDelegates: const [ AppLocalizations.delegate, diff --git a/lib/ui/common/app_shortcuts.dart b/lib/ui/common/app_shortcuts.dart new file mode 100644 index 00000000..ff3d3186 --- /dev/null +++ b/lib/ui/common/app_shortcuts.dart @@ -0,0 +1,47 @@ +import 'package:flutter/foundation.dart'; +import 'package:wonders/common_libs.dart'; + +class AppShortcuts { + static final Map _defaultWebAndDesktopShortcuts = { + // Activation + if (kIsWeb) ...{ + // On the web, enter activates buttons, but not other controls. + SingleActivator(LogicalKeyboardKey.enter): ButtonActivateIntent(), + SingleActivator(LogicalKeyboardKey.numpadEnter): ButtonActivateIntent(), + } else ...{ + SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.numpadEnter): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), + SingleActivator(LogicalKeyboardKey.gameButtonA): ActivateIntent(), + }, + + // Dismissal + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + + // Keyboard traversal. + SingleActivator(LogicalKeyboardKey.tab): NextFocusIntent(), + SingleActivator(LogicalKeyboardKey.tab, shift: true): PreviousFocusIntent(), + + // Scrolling + SingleActivator(LogicalKeyboardKey.arrowUp): ScrollIntent(direction: AxisDirection.up), + SingleActivator(LogicalKeyboardKey.arrowDown): ScrollIntent(direction: AxisDirection.down), + SingleActivator(LogicalKeyboardKey.arrowLeft): ScrollIntent(direction: AxisDirection.left), + SingleActivator(LogicalKeyboardKey.arrowRight): ScrollIntent(direction: AxisDirection.right), + SingleActivator(LogicalKeyboardKey.pageUp): + ScrollIntent(direction: AxisDirection.up, type: ScrollIncrementType.page), + SingleActivator(LogicalKeyboardKey.pageDown): + ScrollIntent(direction: AxisDirection.down, type: ScrollIncrementType.page), + }; + + static Map? get defaults { + switch (defaultTargetPlatform) { + // fall back to default shortcuts for ios and android + case TargetPlatform.iOS: + case TargetPlatform.android: + return null; + // unify shortcuts for desktop/web + default: + return _defaultWebAndDesktopShortcuts; + } + } +} diff --git a/lib/ui/common/controls/buttons.dart b/lib/ui/common/controls/buttons.dart index f90c7fc6..7001048e 100644 --- a/lib/ui/common/controls/buttons.dart +++ b/lib/ui/common/controls/buttons.dart @@ -22,6 +22,8 @@ class AppBtn extends StatelessWidget { this.minimumSize, this.bgColor, this.border, + this.focusNode, + this.onFocusChanged, }) : _builder = null, super(key: key); @@ -36,6 +38,8 @@ class AppBtn extends StatelessWidget { this.minimumSize, this.bgColor, this.border, + this.focusNode, + this.onFocusChanged, String? semanticLabel, String? text, AppIcons? icon, @@ -76,6 +80,8 @@ class AppBtn extends StatelessWidget { this.isSecondary = false, this.circular = false, this.minimumSize, + this.focusNode, + this.onFocusChanged, }) : expand = false, bgColor = Colors.transparent, border = null, @@ -86,6 +92,8 @@ class AppBtn extends StatelessWidget { final VoidCallback? onPressed; late final String semanticLabel; final bool enableFeedback; + final FocusNode? focusNode; + final void Function(bool hasFocus)? onFocusChanged; // content: late final Widget? child; @@ -129,15 +137,20 @@ class AppBtn extends StatelessWidget { ); Widget button = _CustomFocusBuilder( + focusNode: focusNode, + onFocusChanged: onFocusChanged, builder: (context, focus) => Stack( children: [ - TextButton( - onPressed: onPressed, - style: style, - focusNode: focus, - child: DefaultTextStyle( - style: DefaultTextStyle.of(context).style.copyWith(color: textColor), - child: content, + Opacity( + opacity: onPressed == null ? 0.5 : 1.0, + child: TextButton( + onPressed: onPressed, + style: style, + focusNode: focus, + child: DefaultTextStyle( + style: DefaultTextStyle.of(context).style.copyWith(color: textColor), + child: content, + ), ), ), if (focus.hasFocus) @@ -154,7 +167,7 @@ class AppBtn extends StatelessWidget { ); // add press effect: - if (pressEffect) button = _ButtonPressEffect(button); + if (pressEffect && onPressed != null) button = _ButtonPressEffect(button); // add semantics? if (semanticLabel.isEmpty) return button; @@ -199,15 +212,28 @@ class _ButtonPressEffectState extends State<_ButtonPressEffect> { } class _CustomFocusBuilder extends StatefulWidget { - const _CustomFocusBuilder({Key? key, required this.builder}) : super(key: key); + const _CustomFocusBuilder({Key? key, required this.builder, this.focusNode, this.onFocusChanged}) : super(key: key); final Widget Function(BuildContext context, FocusNode focus) builder; - + final void Function(bool hasFocus)? onFocusChanged; + final FocusNode? focusNode; @override State<_CustomFocusBuilder> createState() => _CustomFocusBuilderState(); } class _CustomFocusBuilderState extends State<_CustomFocusBuilder> { - late final _focusNode = FocusNode()..addListener(() => setState(() {})); + late final FocusNode _focusNode; + + @override + void initState() { + _focusNode = widget.focusNode ?? FocusNode(); + _focusNode.addListener(_handleFocusChanged); + super.initState(); + } + + void _handleFocusChanged() { + widget.onFocusChanged?.call(_focusNode.hasFocus); + setState(() {}); + } @override Widget build(BuildContext context) { diff --git a/lib/ui/common/controls/circle_buttons.dart b/lib/ui/common/controls/circle_buttons.dart index 3bddf06d..342aa3df 100644 --- a/lib/ui/common/controls/circle_buttons.dart +++ b/lib/ui/common/controls/circle_buttons.dart @@ -14,7 +14,7 @@ class CircleBtn extends StatelessWidget { static double defaultSize = 48; - final VoidCallback onPressed; + final VoidCallback? onPressed; final Color? bgColor; final BorderSide? border; final Widget child; @@ -54,7 +54,7 @@ class CircleIconBtn extends StatelessWidget { static double defaultSize = 28; final AppIcons icon; - final VoidCallback onPressed; + final VoidCallback? onPressed; final BorderSide? border; final Color? bgColor; final Color? color; @@ -103,6 +103,7 @@ class BackBtn extends StatelessWidget { semanticLabel: $strings.circleButtonsSemanticClose, bgColor: bgColor, iconColor: iconColor); + @override Widget build(BuildContext context) { return CircleIconBtn(