From e00567e17b002ce2836a23c028e90f943698364b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20Moli=C5=84ski?= <47773413+damian-molinski@users.noreply.github.com> Date: Wed, 18 Sep 2024 16:03:29 +0200 Subject: [PATCH] feat(cat-voices): spaces shortcuts (#830) * feat: spaces keyboard shortcuts * chore: docs * chore: remove unused code --- catalyst_voices/lib/common/ext/space_ext.dart | 16 +++++ .../pages/spaces/drawer/spaces_drawer.dart | 37 +++++----- .../lib/pages/spaces/spaces_shell_page.dart | 62 +++++++++++++---- .../buttons/voices_keyboard_key_button.dart | 69 +++++++++++++++++++ .../voices_logical_keyboard_key_button.dart | 47 +++++++++++++ .../common/shortcut_activator_view.dart | 31 +++++++++ .../drawer/voices_drawer_space_chooser.dart | 21 ++++-- .../tooltips/voices_plain_tooltip.dart | 24 +++++-- catalyst_voices/lib/widgets/widgets.dart | 3 + 9 files changed, 263 insertions(+), 47 deletions(-) create mode 100644 catalyst_voices/lib/widgets/buttons/voices_keyboard_key_button.dart create mode 100644 catalyst_voices/lib/widgets/buttons/voices_logical_keyboard_key_button.dart create mode 100644 catalyst_voices/lib/widgets/common/shortcut_activator_view.dart diff --git a/catalyst_voices/lib/common/ext/space_ext.dart b/catalyst_voices/lib/common/ext/space_ext.dart index 891000499a4..3d234f86fd9 100644 --- a/catalyst_voices/lib/common/ext/space_ext.dart +++ b/catalyst_voices/lib/common/ext/space_ext.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_voices/routes/routes.dart'; import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; @@ -5,6 +6,21 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:flutter/material.dart'; extension SpaceExt on Space { + void go(BuildContext context) { + switch (this) { + case Space.treasury: + const TreasuryRoute().go(context); + case Space.discovery: + const DiscoveryRoute().go(context); + case Space.workspace: + const WorkspaceRoute().go(context); + case Space.voting: + const VotingRoute().go(context); + case Space.fundedProjects: + const FundedProjectsRoute().go(context); + } + } + String localizedName(VoicesLocalizations localizations) { return switch (this) { Space.treasury => localizations.spaceTreasuryName, diff --git a/catalyst_voices/lib/pages/spaces/drawer/spaces_drawer.dart b/catalyst_voices/lib/pages/spaces/drawer/spaces_drawer.dart index 39c25420123..187d3543308 100644 --- a/catalyst_voices/lib/pages/spaces/drawer/spaces_drawer.dart +++ b/catalyst_voices/lib/pages/spaces/drawer/spaces_drawer.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:catalyst_voices/common/ext/ext.dart'; import 'package:catalyst_voices/pages/spaces/drawer/discovery_menu.dart'; import 'package:catalyst_voices/pages/spaces/drawer/guest_menu.dart'; import 'package:catalyst_voices/pages/spaces/drawer/individual_private_campaigns.dart'; @@ -7,16 +8,19 @@ import 'package:catalyst_voices/pages/spaces/drawer/my_private_proposals.dart'; import 'package:catalyst_voices/pages/spaces/drawer/voting_rounds.dart'; import 'package:catalyst_voices/routes/routes.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:flutter/material.dart'; class SpacesDrawer extends StatelessWidget { final Space space; + final Map spacesShortcutsActivators; final bool isUnlocked; const SpacesDrawer({ super.key, required this.space, + this.spacesShortcutsActivators = const {}, this.isUnlocked = false, }); @@ -25,13 +29,22 @@ class SpacesDrawer extends StatelessWidget { return VoicesDrawer( bottom: VoicesDrawerSpaceChooser( currentSpace: space, - onChanged: (space) { - _goTo(context, space: space); - }, + onChanged: (space) => space.go(context), onOverallTap: () { Scaffold.of(context).closeDrawer(); unawaited(const OverallSpacesRoute().push(context)); }, + builder: (context, value, child) { + final shortcutActivator = spacesShortcutsActivators[value]; + + return VoicesPlainTooltip( + message: value.localizedName(context.l10n), + trailing: shortcutActivator != null + ? ShortcutActivatorView(activator: shortcutActivator) + : null, + child: child!, + ); + }, ), children: [ _menuBuilder(), @@ -49,22 +62,4 @@ class SpacesDrawer extends StatelessWidget { Space.fundedProjects => const SizedBox.shrink(), }; } - - void _goTo( - BuildContext context, { - required Space space, - }) { - switch (space) { - case Space.treasury: - const TreasuryRoute().go(context); - case Space.discovery: - const DiscoveryRoute().go(context); - case Space.workspace: - const WorkspaceRoute().go(context); - case Space.voting: - const VotingRoute().go(context); - case Space.fundedProjects: - const FundedProjectsRoute().go(context); - } - } } diff --git a/catalyst_voices/lib/pages/spaces/spaces_shell_page.dart b/catalyst_voices/lib/pages/spaces/spaces_shell_page.dart index 6ab05319ed0..26b10d01de6 100644 --- a/catalyst_voices/lib/pages/spaces/spaces_shell_page.dart +++ b/catalyst_voices/lib/pages/spaces/spaces_shell_page.dart @@ -1,14 +1,39 @@ +import 'package:catalyst_voices/common/ext/ext.dart'; import 'package:catalyst_voices/pages/spaces/drawer/spaces_drawer.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SpacesShellPage extends StatelessWidget { final Space space; final Widget child; + static final Map _spacesShortcutsActivators = { + Space.discovery: LogicalKeySet( + LogicalKeyboardKey.control, + LogicalKeyboardKey.digit1, + ), + Space.workspace: LogicalKeySet( + LogicalKeyboardKey.control, + LogicalKeyboardKey.digit2, + ), + Space.voting: LogicalKeySet( + LogicalKeyboardKey.control, + LogicalKeyboardKey.digit3, + ), + Space.fundedProjects: LogicalKeySet( + LogicalKeyboardKey.control, + LogicalKeyboardKey.digit4, + ), + Space.treasury: LogicalKeySet( + LogicalKeyboardKey.control, + LogicalKeyboardKey.keyT, + ), + }; + const SpacesShellPage({ super.key, required this.space, @@ -21,22 +46,29 @@ class SpacesShellPage extends StatelessWidget { final isVisitor = sessionBloc.state is VisitorSessionState; final isUnlocked = sessionBloc.state is ActiveUserSessionState; - return Scaffold( - appBar: VoicesAppBar( - leading: isVisitor ? null : const DrawerToggleButton(), - automaticallyImplyLeading: false, - actions: const [ - SessionActionHeader(), - SessionStateHeader(), - ], + return CallbackShortcuts( + bindings: { + for (final entry in _spacesShortcutsActivators.entries) + entry.value: () => entry.key.go(context), + }, + child: Scaffold( + appBar: VoicesAppBar( + leading: isVisitor ? null : const DrawerToggleButton(), + automaticallyImplyLeading: false, + actions: const [ + SessionActionHeader(), + SessionStateHeader(), + ], + ), + drawer: isVisitor + ? null + : SpacesDrawer( + space: space, + spacesShortcutsActivators: _spacesShortcutsActivators, + isUnlocked: isUnlocked, + ), + body: child, ), - drawer: isVisitor - ? null - : SpacesDrawer( - space: space, - isUnlocked: isUnlocked, - ), - body: child, ); } } diff --git a/catalyst_voices/lib/widgets/buttons/voices_keyboard_key_button.dart b/catalyst_voices/lib/widgets/buttons/voices_keyboard_key_button.dart new file mode 100644 index 00000000000..b835450f794 --- /dev/null +++ b/catalyst_voices/lib/widgets/buttons/voices_keyboard_key_button.dart @@ -0,0 +1,69 @@ +import 'package:catalyst_voices/widgets/tooltips/voices_plain_tooltip.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; + +/// Widget that is meant to be used as singular keyboard key indication. +/// +/// Common use case is to show list of such keys that together means +/// shortcut of some sorts. +/// +/// See: +/// * [VoicesPlainTooltip] as good starting points for used of this button. +class VoicesKeyboardKeyButton extends StatelessWidget { + /// Usually [Icon], [CatalystSvgIcon] or [Text]. + final Widget child; + + const VoicesKeyboardKeyButton({ + super.key, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final iconTheme = IconThemeData( + size: 14, + color: theme.colors.iconsForeground, + ); + + final textStyle = TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + height: 1, + color: theme.colors.textPrimary, + ); + + return IconTheme( + data: iconTheme, + child: DefaultTextStyle( + style: textStyle, + textAlign: TextAlign.center, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 23.3, minHeight: 23.3), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4.6), + color: const Color(0xFFD9D9D9), + ), + child: Container( + margin: const EdgeInsets.only( + left: 2.3, + top: 1.17, + right: 2.3, + bottom: 3.5, + ), + decoration: BoxDecoration( + color: theme.colorScheme.onPrimary, + borderRadius: BorderRadius.circular(2.3), + ), + alignment: Alignment.center, + child: child, + ), + ), + ), + ), + ); + } +} diff --git a/catalyst_voices/lib/widgets/buttons/voices_logical_keyboard_key_button.dart b/catalyst_voices/lib/widgets/buttons/voices_logical_keyboard_key_button.dart new file mode 100644 index 00000000000..5654a05ff2a --- /dev/null +++ b/catalyst_voices/lib/widgets/buttons/voices_logical_keyboard_key_button.dart @@ -0,0 +1,47 @@ +import 'package:catalyst_voices/widgets/buttons/voices_keyboard_key_button.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +final Map _keyIconMapping = { + LogicalKeyboardKey.control.keyId: VoicesAssets.icons.chevronUp, +}; + +/// Wrapper for [VoicesKeyboardKeyButton] and [LogicalKeyboardKey]. +/// +/// This widget takes care or proper mapping of [data] to corresponding +/// widget that should indicate given key. +/// +/// See [_keyIconMapping] for list of icons with iconographic representation. If +/// no icon is found for such [LogicalKeyboardKey] first letter +/// of [LogicalKeyboardKey.keyLabel] will be used as fallback. +class VoicesLogicalKeyboardKeyButton extends StatelessWidget { + final LogicalKeyboardKey data; + + const VoicesLogicalKeyboardKeyButton( + this.data, { + super.key, + }); + + @override + Widget build(BuildContext context) { + return VoicesKeyboardKeyButton( + child: _buildKeyIndicator(context), + ); + } + + Widget _buildKeyIndicator(BuildContext context) { + final keyIcon = _keyIconMapping[data.keyId]; + if (keyIcon != null) { + return keyIcon.buildIcon(); + } + + final keyLabel = data.keyLabel; + final letter = keyLabel.isNotEmpty ? keyLabel.substring(0, 1) : null; + if (letter != null) { + return Text(letter.toUpperCase()); + } + + return const SizedBox.shrink(); + } +} diff --git a/catalyst_voices/lib/widgets/common/shortcut_activator_view.dart b/catalyst_voices/lib/widgets/common/shortcut_activator_view.dart new file mode 100644 index 00000000000..cc19505c8a5 --- /dev/null +++ b/catalyst_voices/lib/widgets/common/shortcut_activator_view.dart @@ -0,0 +1,31 @@ +import 'package:catalyst_voices/widgets/buttons/voices_logical_keyboard_key_button.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// Grouping all [LogicalKeyboardKey] that correspond with given [activator] +/// and shows them in a [Row] as [VoicesLogicalKeyboardKeyButton]. +/// +/// This widget also uses [LogicalKeyboardKey.collapseSynonyms] to remove +/// any synonyms key activator such as left Control and right Control. +class ShortcutActivatorView extends StatelessWidget { + final ShortcutActivator activator; + + const ShortcutActivatorView({ + super.key, + required this.activator, + }); + + @override + Widget build(BuildContext context) { + final triggers = {...?activator.triggers}; + + return Row( + mainAxisSize: MainAxisSize.min, + children: LogicalKeyboardKey.collapseSynonyms(triggers) + .map(VoicesLogicalKeyboardKeyButton.new) + .separatedBy(const SizedBox(width: 4)) + .toList(), + ); + } +} diff --git a/catalyst_voices/lib/widgets/drawer/voices_drawer_space_chooser.dart b/catalyst_voices/lib/widgets/drawer/voices_drawer_space_chooser.dart index 4381af96a38..8bc36c01470 100644 --- a/catalyst_voices/lib/widgets/drawer/voices_drawer_space_chooser.dart +++ b/catalyst_voices/lib/widgets/drawer/voices_drawer_space_chooser.dart @@ -9,12 +9,14 @@ class VoicesDrawerSpaceChooser extends StatelessWidget { final Space currentSpace; final ValueChanged onChanged; final VoidCallback? onOverallTap; + final ValueWidgetBuilder? builder; const VoicesDrawerSpaceChooser({ super.key, required this.currentSpace, required this.onChanged, this.onOverallTap, + this.builder, }); @override @@ -36,13 +38,18 @@ class VoicesDrawerSpaceChooser extends StatelessWidget { required Space item, required bool isSelected, }) { - if (isSelected) { - return SpaceAvatar( - item, - key: ValueKey('DrawerChooser${item}AvatarKey'), - ); - } else { - return const VoicesDrawerChooserItemPlaceholder(); + Widget child = isSelected + ? SpaceAvatar( + item, + key: ValueKey('DrawerChooser${item}AvatarKey'), + ) + : const VoicesDrawerChooserItemPlaceholder(); + + final builder = this.builder; + if (builder != null) { + child = builder(context, item, child); } + + return child; } } diff --git a/catalyst_voices/lib/widgets/tooltips/voices_plain_tooltip.dart b/catalyst_voices/lib/widgets/tooltips/voices_plain_tooltip.dart index 3ea8724555a..e188541507d 100644 --- a/catalyst_voices/lib/widgets/tooltips/voices_plain_tooltip.dart +++ b/catalyst_voices/lib/widgets/tooltips/voices_plain_tooltip.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_voices/widgets/common/affix_decorator.dart'; import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:flutter/material.dart'; @@ -18,12 +19,20 @@ class VoicesPlainTooltip extends StatelessWidget { /// The text message to display in the tooltip. final String message; + /// Optional widget that will be shown before [message]. + final Widget? leading; + + /// Optional widget that will be shown after [message]. + final Widget? trailing; + /// The widget that triggers tooltip visibility. final Widget child; const VoicesPlainTooltip({ super.key, required this.message, + this.leading, + this.trailing, required this.child, }); @@ -31,15 +40,22 @@ class VoicesPlainTooltip extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); + final textStyle = (theme.textTheme.bodySmall ?? const TextStyle()).copyWith( + color: theme.colors.iconsBackground, + ); + return Tooltip( richMessage: WidgetSpan( child: ConstrainedBox( key: const ValueKey('VoicesPlainTooltipContentKey'), constraints: const BoxConstraints(maxWidth: 200), - child: Text( - message, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colors.iconsBackground, + child: DefaultTextStyle( + style: textStyle, + child: AffixDecorator( + prefix: leading, + suffix: trailing, + gap: 12, + child: Text(message), ), ), ), diff --git a/catalyst_voices/lib/widgets/widgets.dart b/catalyst_voices/lib/widgets/widgets.dart index a4a35ca2039..a2761b94e0c 100644 --- a/catalyst_voices/lib/widgets/widgets.dart +++ b/catalyst_voices/lib/widgets/widgets.dart @@ -6,6 +6,8 @@ export 'avatars/voices_avatar.dart'; export 'buttons/voices_buttons.dart'; export 'buttons/voices_filled_button.dart'; export 'buttons/voices_icon_button.dart'; +export 'buttons/voices_keyboard_key_button.dart'; +export 'buttons/voices_logical_keyboard_key_button.dart'; export 'buttons/voices_outlined_button.dart'; export 'buttons/voices_segmented_button.dart'; export 'buttons/voices_text_button.dart'; @@ -13,6 +15,7 @@ export 'cards/funded_proposal_card.dart'; export 'chips/voices_chip.dart'; export 'common/link_text.dart'; export 'common/navigation_location.dart'; +export 'common/shortcut_activator_view.dart'; export 'common/simple_tree_view.dart'; export 'common/tab_bar_stack_view.dart'; export 'containers/sidebar_scaffold.dart';