diff --git a/doc/website/source/guides/code-generation.md b/doc/website/source/guides/code-generation.md index c2decae..2f45604 100644 --- a/doc/website/source/guides/code-generation.md +++ b/doc/website/source/guides/code-generation.md @@ -1,6 +1,6 @@ --- title: Code generation -navOrder: 10 +navOrder: 12 --- This package comes with a [mason](https://pub.dev/packages/mason) template that can be used to generate a new slide for the slide deck. diff --git a/doc/website/source/guides/custom-shortcuts.md b/doc/website/source/guides/custom-shortcuts.md new file mode 100644 index 0000000..1fe7525 --- /dev/null +++ b/doc/website/source/guides/custom-shortcuts.md @@ -0,0 +1,70 @@ +--- +title: Custom shortcuts +navOrder: 10 +--- + +# Custom shortcuts + +It is possible to define custom shortcuts to control your presentation using the keyboard. By default, it includes shortcuts for navigating between slides, toggling the marker, and opening the navigation drawer. You can add your own shortcuts to trigger specific actions. + +## Defining custom shortcuts + +To add custom shortcuts, you need to configure the `FlutterDeckShortcutsConfiguration` when setting up your `FlutterDeckApp`. This configuration requires a list of custom shortcuts that extend the abstract `FlutterDeckShortcut` class. This class binds `ShortcutActivator`s (like key presses), an `Intent`, and an `Action` together. + +### Example: Skip to the next slide + +In flutter_deck, slides can have multiple steps. This example shows how to add a custom shortcut to skip to the next slide, ignoring the steps. + +```dart +class SkipSlideIntent extends Intent { + const SkipSlideIntent(); +} + +class SkipSlideAction extends ContextAction { + @override + Object? invoke(SkipSlideIntent intent, [BuildContext? context]) { + if (context == null) return null; + + final currentSlide = context.flutterDeck.router.currentSlideIndex + 1; + + context.flutterDeck.router.goToSlide(currentSlide + 1); + + return null; + } +} + +class SkipSlideShortcut extends FlutterDeckShortcut { + const SkipSlideShortcut(); + + @override + Set get activators => const {SingleActivator(LogicalKeyboardKey.arrowRight, alt: true)}; + + @override + SkipSlideIntent get intent => const SkipSlideIntent(); + + @override + Action get action => SkipSlideAction(); +} +``` + +Then, add the shortcut to your presentation: + +```dart +FlutterDeckApp( + configuration: const FlutterDeckConfiguration( + controls: FlutterDeckControlsConfiguration( + shortcuts: FlutterDeckShortcutsConfiguration( + customShortcuts: [ + SkipSlideShortcut(), + ], + ), + ), + ), +) +``` + +In this example, pressing `Alt + →` triggers the `SkipSlideIntent`. The `SkipSlideAction`, extending `ContextAction`, gains access to the `BuildContext` allowing it to call `context.flutterDeck.router.goToSlide()` to skip to the next slide. If the shortcut doesn't require context, you can simply extend `Action`. + +## Avoiding shortcut clashes + +The flutter_deck framework automatically checks if your custom shortcuts clash with any of the default shortcuts or any other custom shortcuts. If a clash is detected, it will throw an `AssertionError` during development, explicitly stating which shortcut key is causing the problem. diff --git a/doc/website/source/guides/presentation-state.md b/doc/website/source/guides/presentation-state.md index beea654..7de340e 100644 --- a/doc/website/source/guides/presentation-state.md +++ b/doc/website/source/guides/presentation-state.md @@ -1,6 +1,6 @@ --- title: Presentation state -navOrder: 9 +navOrder: 11 --- The slide deck state is managed by the `FlutterDeck` widget. This widget is responsible for managing the state of the slide deck, its configuration, navigation, and other details. By using the `FlutterDeck` extensions, you can access the slide deck state and its methods from anywhere in the app: diff --git a/doc/website/source/playback/change-locale.md b/doc/website/source/playback/change-locale.md index c5bf7f8..3754fbf 100644 --- a/doc/website/source/playback/change-locale.md +++ b/doc/website/source/playback/change-locale.md @@ -1,6 +1,6 @@ --- title: Change locale -navOrder: 5 +navOrder: 4 --- You can change the locale of the slide deck at runtime. The updated locale will be applied to the whole slide deck. The locale can be changed using the presenter toolbar. diff --git a/doc/website/source/playback/presenter-view.md b/doc/website/source/playback/presenter-view.md index e51832c..35f6516 100644 --- a/doc/website/source/playback/presenter-view.md +++ b/doc/website/source/playback/presenter-view.md @@ -1,6 +1,6 @@ --- title: Presenter view -navOrder: 6 +navOrder: 5 --- The presenter view allows you to control your presentation from a separate screen or (even) device. It displays the current slide, speaker notes, and a timer. diff --git a/packages/flutter_deck/CHANGELOG.md b/packages/flutter_deck/CHANGELOG.md index 9daabae..2bdeaaa 100644 --- a/packages/flutter_deck/CHANGELOG.md +++ b/packages/flutter_deck/CHANGELOG.md @@ -1,5 +1,6 @@ # NEXT +- feat: add `FlutterDeckShortcut` to define custom shortcuts and actions - feat: allow defining multiple key combinations for a single shortcut - **BREAKING**: `nextSlide`, `previousSlide`, `toggleMarker` and `toggleNavigationDrawer` properties in the `FlutterDeckShortcutsConfiguration` class now accept a `Set` instead of a `SingleActivator` - **Migration**: instead of: diff --git a/packages/flutter_deck/example/lib/main.dart b/packages/flutter_deck/example/lib/main.dart index eb69b62..36b2974 100644 --- a/packages/flutter_deck/example/lib/main.dart +++ b/packages/flutter_deck/example/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_deck/flutter_deck.dart'; import 'package:flutter_deck_example/l10n/l10n.dart'; +import 'package:flutter_deck_example/shortcuts/shortcuts.dart'; import 'package:flutter_deck_example/slides/slides.dart'; import 'package:flutter_deck_example/templates/templates.dart'; import 'package:flutter_deck_pdf_export/flutter_deck_pdf_export.dart'; @@ -37,6 +38,10 @@ class FlutterDeckExample extends StatelessWidget { ), ), ), + // Update controls and add custom shortcuts. + controls: const FlutterDeckControlsConfiguration( + shortcuts: FlutterDeckShortcutsConfiguration(customShortcuts: [SkipSlideShortcut()]), + ), // Set defaults for the footer. footer: const FlutterDeckFooterConfiguration(showSlideNumbers: true, showSocialHandle: true), // Set defaults for the header. diff --git a/packages/flutter_deck/example/lib/shortcuts/shortcuts.dart b/packages/flutter_deck/example/lib/shortcuts/shortcuts.dart new file mode 100644 index 0000000..0af5c17 --- /dev/null +++ b/packages/flutter_deck/example/lib/shortcuts/shortcuts.dart @@ -0,0 +1 @@ +export 'skip_slide_shortcut.dart'; diff --git a/packages/flutter_deck/example/lib/shortcuts/skip_slide_shortcut.dart b/packages/flutter_deck/example/lib/shortcuts/skip_slide_shortcut.dart new file mode 100644 index 0000000..8f39b5a --- /dev/null +++ b/packages/flutter_deck/example/lib/shortcuts/skip_slide_shortcut.dart @@ -0,0 +1,33 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_deck/flutter_deck.dart'; + +class SkipSlideIntent extends Intent { + const SkipSlideIntent(); +} + +class SkipSlideAction extends ContextAction { + @override + Object? invoke(SkipSlideIntent intent, [BuildContext? context]) { + if (context == null) return null; + + final currentSlide = context.flutterDeck.router.currentSlideIndex + 1; + + context.flutterDeck.router.goToSlide(currentSlide + 1); + + return null; + } +} + +class SkipSlideShortcut extends FlutterDeckShortcut { + const SkipSlideShortcut(); + + @override + Set get activators => const {SingleActivator(LogicalKeyboardKey.arrowRight, alt: true)}; + + @override + SkipSlideIntent get intent => const SkipSlideIntent(); + + @override + Action get action => SkipSlideAction(); +} diff --git a/packages/flutter_deck/lib/flutter_deck.dart b/packages/flutter_deck/lib/flutter_deck.dart index 8bce9b0..31c7e32 100644 --- a/packages/flutter_deck/lib/flutter_deck.dart +++ b/packages/flutter_deck/lib/flutter_deck.dart @@ -2,6 +2,7 @@ library; export 'src/configuration/configuration.dart'; +export 'src/controls/flutter_deck_shortcut.dart'; export 'src/flutter_deck.dart'; export 'src/flutter_deck_app.dart'; export 'src/flutter_deck_slide.dart'; diff --git a/packages/flutter_deck/lib/src/controls/controls.dart b/packages/flutter_deck/lib/src/controls/controls.dart index ae54447..3be58ec 100644 --- a/packages/flutter_deck/lib/src/controls/controls.dart +++ b/packages/flutter_deck/lib/src/controls/controls.dart @@ -2,5 +2,6 @@ export 'flutter_deck_controls.dart'; export 'flutter_deck_controls_configuration.dart'; export 'flutter_deck_controls_listener.dart'; export 'flutter_deck_controls_notifier.dart'; +export 'flutter_deck_shortcut.dart'; export 'fullscreen/fullscreen.dart'; export 'l10n/l10n.dart'; diff --git a/packages/flutter_deck/lib/src/controls/flutter_deck_controls_configuration.dart b/packages/flutter_deck/lib/src/controls/flutter_deck_controls_configuration.dart index 11ad1a6..d8b8658 100644 --- a/packages/flutter_deck/lib/src/controls/flutter_deck_controls_configuration.dart +++ b/packages/flutter_deck/lib/src/controls/flutter_deck_controls_configuration.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_deck/src/controls/flutter_deck_shortcut.dart'; /// The configuration for the slide deck controls. class FlutterDeckControlsConfiguration { @@ -18,6 +19,8 @@ class FlutterDeckControlsConfiguration { /// - [SingleActivator] for more information on how to define a key /// combination. /// - [LogicalKeyboardKey] for a list of all available keys. + /// - [FlutterDeckShortcut] for more information on how to define a + /// custom shortcut. const FlutterDeckControlsConfiguration({ this.presenterToolbarVisible = true, this.gestures = const FlutterDeckGesturesConfiguration.mobileOnly(), @@ -82,12 +85,15 @@ class FlutterDeckShortcutsConfiguration { /// - [SingleActivator] for more information on how to define a key /// combination. /// - [LogicalKeyboardKey] for a list of all available keys. + /// - [FlutterDeckShortcut] for more information on how to define a + /// custom shortcut. const FlutterDeckShortcutsConfiguration({ this.enabled = true, this.nextSlide = const {SingleActivator(LogicalKeyboardKey.arrowRight)}, this.previousSlide = const {SingleActivator(LogicalKeyboardKey.arrowLeft)}, this.toggleMarker = const {SingleActivator(LogicalKeyboardKey.keyM)}, this.toggleNavigationDrawer = const {SingleActivator(LogicalKeyboardKey.period)}, + this.customShortcuts = const [], }); /// Creates a configuration for the slide deck keyboard shortcuts where they @@ -108,4 +114,10 @@ class FlutterDeckShortcutsConfiguration { /// The key combinations to use for toggling the navigation drawer. final Set toggleNavigationDrawer; + + /// Custom shortcuts for the slide deck. + /// + /// This can be used to add custom shortcuts and their corresponding actions + /// to the slide deck. + final List customShortcuts; } diff --git a/packages/flutter_deck/lib/src/controls/flutter_deck_controls_listener.dart b/packages/flutter_deck/lib/src/controls/flutter_deck_controls_listener.dart index de0f6ca..9cd7c5c 100644 --- a/packages/flutter_deck/lib/src/controls/flutter_deck_controls_listener.dart +++ b/packages/flutter_deck/lib/src/controls/flutter_deck_controls_listener.dart @@ -15,6 +15,8 @@ import 'package:flutter_deck/src/widgets/internal/internal.dart'; /// * `toggleMarker` - Toggle the slide deck's marker. /// * `toggleNavigationDrawer` - Toggle the navigation drawer. /// +/// Also, custom shortcuts and actions can be defined by the user. +/// /// Cursor visibility is also handled by this widget. The cursor will be hidden /// after 3 seconds of inactivity. /// @@ -100,6 +102,24 @@ class FlutterDeckControlsListener extends StatelessWidget { final shortcuts = controls.shortcuts; + final allShortcuts = [ + ...shortcuts.nextSlide, + ...shortcuts.previousSlide, + ...shortcuts.toggleMarker, + ...shortcuts.toggleNavigationDrawer, + for (final shortcut in shortcuts.customShortcuts) ...shortcut.activators, + ]; + + final seen = {}; + + for (final shortcut in allShortcuts) { + assert( + seen.add(shortcut), + 'Shortcuts must not clash with each other. ' + 'Multiple actions are mapped to the "$shortcut" shortcut.', + ); + } + if (controls.presenterToolbarVisible || shortcuts.enabled) { widget = Actions( actions: >{ @@ -107,6 +127,7 @@ class FlutterDeckControlsListener extends StatelessWidget { GoPreviousIntent: GoPreviousAction(controlsNotifier), ToggleDrawerIntent: ToggleDrawerAction(controlsNotifier), ToggleMarkerIntent: ToggleMarkerAction(controlsNotifier), + for (final shortcut in shortcuts.customShortcuts) shortcut.intentType: shortcut.action, }, child: widget, ); @@ -118,6 +139,8 @@ class FlutterDeckControlsListener extends StatelessWidget { for (final activator in shortcuts.previousSlide) activator: const GoPreviousIntent(), for (final activator in shortcuts.toggleMarker) activator: const ToggleMarkerIntent(), for (final activator in shortcuts.toggleNavigationDrawer) activator: const ToggleDrawerIntent(), + for (final shortcut in shortcuts.customShortcuts) + for (final activator in shortcut.activators) activator: shortcut.intent, }, child: widget, ); diff --git a/packages/flutter_deck/lib/src/controls/flutter_deck_shortcut.dart b/packages/flutter_deck/lib/src/controls/flutter_deck_shortcut.dart new file mode 100644 index 0000000..4898a52 --- /dev/null +++ b/packages/flutter_deck/lib/src/controls/flutter_deck_shortcut.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_deck/src/flutter_deck.dart'; + +/// A shortcut for the slide deck. +/// +/// This configuration is used to map a key combination to an [Intent] and an +/// [Action]. +abstract class FlutterDeckShortcut { + /// Creates a shortcut for the slide deck. + const FlutterDeckShortcut(); + + /// The action that will be invoked when the intent is triggered. + /// + /// To access [FlutterDeck] via this action, you can use a [ContextAction] + /// instead of a regular [Action]. + Action get action; + + /// The key combinations that will trigger the shortcut. + Set get activators; + + /// The intent that will be invoked when the shortcut is triggered. + T get intent; + + /// The type of the intent that will be invoked when the shortcut is + /// triggered. + /// + /// This is used to map the intent type to the action in the controls + /// listener. Do not override this, it relies on the generic type [T]. + Type get intentType => T; +} diff --git a/packages/flutter_deck/lib/src/widgets/internal/marker/flutter_deck_marker_painter.dart b/packages/flutter_deck/lib/src/widgets/internal/marker/flutter_deck_marker_painter.dart index 8650fbc..a6b5afa 100644 --- a/packages/flutter_deck/lib/src/widgets/internal/marker/flutter_deck_marker_painter.dart +++ b/packages/flutter_deck/lib/src/widgets/internal/marker/flutter_deck_marker_painter.dart @@ -7,11 +7,7 @@ class FlutterDeckMarkerPainter extends CustomPainter { /// Creates a [FlutterDeckMarkerPainter]. /// /// The [configuration] and [paths] arguments must not be null. - const FlutterDeckMarkerPainter({ - required this.configuration, - required this.paths, - this.version = 0, - }); + const FlutterDeckMarkerPainter({required this.configuration, required this.paths, this.version = 0}); /// The configuration of the marker. final FlutterDeckMarkerConfiguration configuration; diff --git a/packages/flutter_deck/test/src/controls/flutter_deck_controls_configuration_test.dart b/packages/flutter_deck/test/src/controls/flutter_deck_controls_configuration_test.dart index 813f1cd..c713855 100644 --- a/packages/flutter_deck/test/src/controls/flutter_deck_controls_configuration_test.dart +++ b/packages/flutter_deck/test/src/controls/flutter_deck_controls_configuration_test.dart @@ -68,12 +68,14 @@ void main() { expect(configuration.previousSlide, const {SingleActivator(LogicalKeyboardKey.arrowLeft)}); expect(configuration.toggleMarker, const {SingleActivator(LogicalKeyboardKey.keyM)}); expect(configuration.toggleNavigationDrawer, const {SingleActivator(LogicalKeyboardKey.period)}); + expect(configuration.customShortcuts, isEmpty); }); test('disabled factory should create disabled configuration', () { const configuration = FlutterDeckShortcutsConfiguration.disabled(); expect(configuration.enabled, false); + expect(configuration.customShortcuts, isEmpty); }); }); } diff --git a/packages/flutter_deck/test/src/controls/flutter_deck_controls_listener_test.dart b/packages/flutter_deck/test/src/controls/flutter_deck_controls_listener_test.dart index c0883f5..bb1152d 100644 --- a/packages/flutter_deck/test/src/controls/flutter_deck_controls_listener_test.dart +++ b/packages/flutter_deck/test/src/controls/flutter_deck_controls_listener_test.dart @@ -172,5 +172,134 @@ void main() { verify(mockControlsNotifier.previous()).called(1); }); + + testWidgets('should allow custom shortcut and action', (tester) async { + var invoked = false; + BuildContext? actionContext; + + final customFlutterDeck = FlutterDeck( + configuration: FlutterDeckConfiguration( + controls: FlutterDeckControlsConfiguration( + shortcuts: FlutterDeckShortcutsConfiguration( + customShortcuts: [ + _MockShortcut( + _MockAction((context) { + invoked = true; + actionContext = context; + }), + ), + ], + ), + ), + ), + router: MockFlutterDeckRouter(), + speakerInfo: null, + controlsNotifier: mockControlsNotifier, + drawerNotifier: MockFlutterDeckDrawerNotifier(), + localizationNotifier: MockFlutterDeckLocalizationNotifier(), + markerNotifier: mockMarkerNotifier, + presenterController: MockFlutterDeckPresenterController(), + themeNotifier: MockFlutterDeckThemeNotifier(), + localizationsDelegates: const [], + supportedLocales: const [Locale('en')], + plugins: const [], + ); + + await tester.pumpWidget( + MaterialApp( + home: FlutterDeckProvider( + flutterDeck: customFlutterDeck, + child: FlutterDeckControlsListener( + controlsNotifier: mockControlsNotifier, + markerNotifier: mockMarkerNotifier, + child: const SizedBox(), + ), + ), + ), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.keyA); + + expect(invoked, isTrue); + expect(actionContext?.flutterDeck, customFlutterDeck); + }); + + testWidgets('should assert when custom shortcuts clash with defaults', (tester) async { + final customFlutterDeck = FlutterDeck( + configuration: FlutterDeckConfiguration( + controls: FlutterDeckControlsConfiguration( + shortcuts: FlutterDeckShortcutsConfiguration(customShortcuts: [_MockClashingShortcut(_MockAction((_) {}))]), + ), + ), + router: MockFlutterDeckRouter(), + speakerInfo: null, + controlsNotifier: mockControlsNotifier, + drawerNotifier: MockFlutterDeckDrawerNotifier(), + localizationNotifier: MockFlutterDeckLocalizationNotifier(), + markerNotifier: mockMarkerNotifier, + presenterController: MockFlutterDeckPresenterController(), + themeNotifier: MockFlutterDeckThemeNotifier(), + localizationsDelegates: const [], + supportedLocales: const [Locale('en')], + plugins: const [], + ); + + await tester.pumpWidget( + MaterialApp( + home: FlutterDeckProvider( + flutterDeck: customFlutterDeck, + child: FlutterDeckControlsListener( + controlsNotifier: mockControlsNotifier, + markerNotifier: mockMarkerNotifier, + child: const SizedBox(), + ), + ), + ), + ); + + expect(tester.takeException(), isAssertionError); + }); }); } + +class _MockIntent extends Intent { + const _MockIntent(); +} + +class _MockAction extends ContextAction<_MockIntent> { + _MockAction(this.onInvoke); + + final void Function(BuildContext) onInvoke; + + @override + Object? invoke(_MockIntent intent, [BuildContext? context]) { + onInvoke(context!); + return null; + } +} + +class _MockShortcut extends FlutterDeckShortcut<_MockIntent> { + const _MockShortcut(this.action); + + @override + Set get activators => const {SingleActivator(LogicalKeyboardKey.keyA)}; + + @override + _MockIntent get intent => const _MockIntent(); + + @override + final Action<_MockIntent> action; +} + +class _MockClashingShortcut extends FlutterDeckShortcut<_MockIntent> { + const _MockClashingShortcut(this.action); + + @override + Set get activators => const {SingleActivator(LogicalKeyboardKey.arrowRight)}; + + @override + _MockIntent get intent => const _MockIntent(); + + @override + final Action<_MockIntent> action; +}