From 9d184fedf981bcec4e88d424deeda66ad0d958e6 Mon Sep 17 00:00:00 2001 From: Erick Date: Sat, 7 May 2022 14:59:01 -0300 Subject: [PATCH] feat: adding mobile controls (#377) * feat: adding mobile controls * adding tests for pinball dpad button * feat: tests * lint * Apply suggestions from code review Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> * suggestions Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> --- lib/game/components/backbox/backbox.dart | 15 +- lib/game/pinball_game.dart | 3 + lib/game/view/pinball_game_page.dart | 8 ++ lib/game/view/widgets/mobile_controls.dart | 46 ++++++ lib/game/view/widgets/mobile_dpad.dart | 75 ++++++++++ lib/game/view/widgets/widgets.dart | 2 + lib/l10n/arb/app_en.arb | 4 + .../lib/src/keyboard_input_controller.dart | 27 ++++ .../src/keyboard_input_controller_test.dart | 36 +++++ .../assets/images/button/dpad_down.png | Bin 0 -> 1956 bytes .../assets/images/button/dpad_left.png | Bin 0 -> 1983 bytes .../assets/images/button/dpad_right.png | Bin 0 -> 1988 bytes .../assets/images/button/dpad_up.png | Bin 0 -> 1855 bytes packages/pinball_ui/lib/gen/assets.gen.dart | 12 +- packages/pinball_ui/lib/gen/fonts.gen.dart | 5 - .../lib/src/widgets/pinball_dpad_button.dart | 66 +++++++++ .../pinball_ui/lib/src/widgets/widgets.dart | 1 + .../src/widgets/pinball_dpad_button_test.dart | 122 ++++++++++++++++ .../game/components/backbox/backbox_test.dart | 75 ++++++++-- test/game/view/pinball_game_page_test.dart | 14 ++ .../view/widgets/mobile_controls_test.dart | 131 ++++++++++++++++++ test/game/view/widgets/mobile_dpad_test.dart | 113 +++++++++++++++ 22 files changed, 735 insertions(+), 20 deletions(-) create mode 100644 lib/game/view/widgets/mobile_controls.dart create mode 100644 lib/game/view/widgets/mobile_dpad.dart create mode 100644 packages/pinball_ui/assets/images/button/dpad_down.png create mode 100644 packages/pinball_ui/assets/images/button/dpad_left.png create mode 100644 packages/pinball_ui/assets/images/button/dpad_right.png create mode 100644 packages/pinball_ui/assets/images/button/dpad_up.png create mode 100644 packages/pinball_ui/lib/src/widgets/pinball_dpad_button.dart create mode 100644 packages/pinball_ui/test/src/widgets/pinball_dpad_button_test.dart create mode 100644 test/game/view/widgets/mobile_controls_test.dart create mode 100644 test/game/view/widgets/mobile_dpad_test.dart diff --git a/lib/game/components/backbox/backbox.dart b/lib/game/components/backbox/backbox.dart index b3743df3..8590db44 100644 --- a/lib/game/components/backbox/backbox.dart +++ b/lib/game/components/backbox/backbox.dart @@ -5,27 +5,33 @@ import 'package:flutter/material.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/game/components/backbox/bloc/backbox_bloc.dart'; import 'package:pinball/game/components/backbox/displays/displays.dart'; +import 'package:pinball/game/game.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_theme/pinball_theme.dart' hide Assets; +import 'package:platform_helper/platform_helper.dart'; /// {@template backbox} /// The [Backbox] of the pinball machine. /// {@endtemplate} -class Backbox extends PositionComponent with ZIndex { +class Backbox extends PositionComponent with ZIndex, HasGameRef { /// {@macro backbox} Backbox({ required LeaderboardRepository leaderboardRepository, - }) : _bloc = BackboxBloc(leaderboardRepository: leaderboardRepository); + }) : _bloc = BackboxBloc(leaderboardRepository: leaderboardRepository), + _platformHelper = PlatformHelper(); /// {@macro backbox} @visibleForTesting Backbox.test({ required BackboxBloc bloc, - }) : _bloc = bloc; + required PlatformHelper platformHelper, + }) : _bloc = bloc, + _platformHelper = platformHelper; late final Component _display; final BackboxBloc _bloc; + final PlatformHelper _platformHelper; late StreamSubscription _subscription; @override @@ -58,6 +64,9 @@ class Backbox extends PositionComponent with ZIndex { } else if (state is LeaderboardSuccessState) { _display.add(LeaderboardDisplay(entries: state.entries)); } else if (state is InitialsFormState) { + if (_platformHelper.isMobile) { + gameRef.overlays.add(PinballGame.mobileControlsOverlay); + } _display.add( InitialsInputDisplay( score: state.score, diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index c6ca8033..09d8da23 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -37,6 +37,9 @@ class PinballGame extends PinballForge2DGame /// Identifier of the play button overlay static const playButtonOverlay = 'play_button'; + /// Identifier of the mobile controls overlay + static const mobileControlsOverlay = 'mobile_controls'; + @override Color backgroundColor() => Colors.transparent; diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index c67b2d10..7e2ec85f 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -122,6 +122,14 @@ class PinballGameLoadedView extends StatelessWidget { child: PlayButtonOverlay(), ); }, + PinballGame.mobileControlsOverlay: (context, game) { + return Positioned( + bottom: 0, + left: 0, + right: 0, + child: MobileControls(game: game), + ); + }, }, ), ), diff --git a/lib/game/view/widgets/mobile_controls.dart b/lib/game/view/widgets/mobile_controls.dart new file mode 100644 index 00000000..c5761eb6 --- /dev/null +++ b/lib/game/view/widgets/mobile_controls.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball_flame/pinball_flame.dart'; +import 'package:pinball_ui/pinball_ui.dart'; + +/// {@template mobile_controls} +/// Widget with the controls used to enable the user initials input on mobile. +/// {@endtemplate} +class MobileControls extends StatelessWidget { + /// {@macro mobile_controls} + const MobileControls({ + Key? key, + required this.game, + }) : super(key: key); + + /// Game instance + final PinballGame game; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + MobileDpad( + onTapUp: () => game.triggerVirtualKeyUp(LogicalKeyboardKey.arrowUp), + onTapDown: () => game.triggerVirtualKeyUp( + LogicalKeyboardKey.arrowDown, + ), + onTapLeft: () => game.triggerVirtualKeyUp( + LogicalKeyboardKey.arrowLeft, + ), + onTapRight: () => game.triggerVirtualKeyUp( + LogicalKeyboardKey.arrowRight, + ), + ), + PinballButton( + text: l10n.enter, + onTap: () => game.triggerVirtualKeyUp(LogicalKeyboardKey.enter), + ), + ], + ); + } +} diff --git a/lib/game/view/widgets/mobile_dpad.dart b/lib/game/view/widgets/mobile_dpad.dart new file mode 100644 index 00000000..abad496b --- /dev/null +++ b/lib/game/view/widgets/mobile_dpad.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:pinball_ui/pinball_ui.dart'; + +/// {@template mobile_dpad} +/// Widget rendering 4 directional input arrows. +/// {@endtemplate} +class MobileDpad extends StatelessWidget { + /// {@template mobile_dpad} + const MobileDpad({ + Key? key, + required this.onTapUp, + required this.onTapDown, + required this.onTapLeft, + required this.onTapRight, + }) : super(key: key); + + static const _size = 180.0; + + /// Called when dpad up is pressed + final VoidCallback onTapUp; + + /// Called when dpad down is pressed + final VoidCallback onTapDown; + + /// Called when dpad left is pressed + final VoidCallback onTapLeft; + + /// Called when dpad right is pressed + final VoidCallback onTapRight; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: _size, + height: _size, + child: Column( + children: [ + Row( + children: [ + const Spacer(), + PinballDpadButton( + direction: PinballDpadDirection.up, + onTap: onTapUp, + ), + const Spacer(), + ], + ), + Row( + children: [ + PinballDpadButton( + direction: PinballDpadDirection.left, + onTap: onTapLeft, + ), + const Spacer(), + PinballDpadButton( + direction: PinballDpadDirection.right, + onTap: onTapRight, + ), + ], + ), + Row( + children: [ + const Spacer(), + PinballDpadButton( + direction: PinballDpadDirection.down, + onTap: onTapDown, + ), + const Spacer(), + ], + ), + ], + ), + ); + } +} diff --git a/lib/game/view/widgets/widgets.dart b/lib/game/view/widgets/widgets.dart index 5d1fccf8..2a04670f 100644 --- a/lib/game/view/widgets/widgets.dart +++ b/lib/game/view/widgets/widgets.dart @@ -1,5 +1,7 @@ export 'bonus_animation.dart'; export 'game_hud.dart'; +export 'mobile_controls.dart'; +export 'mobile_dpad.dart'; export 'play_button_overlay.dart'; export 'round_count_display.dart'; export 'score_view.dart'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index aa1a24f6..839da492 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -151,5 +151,9 @@ "ioPinball": "I/O Pinball", "@ioPinball": { "description": "I/O Pinball - Name of the game" + }, + "enter": "Enter", + "@enter": { + "description": "Text shown on the mobile controls enter button" } } diff --git a/packages/pinball_flame/lib/src/keyboard_input_controller.dart b/packages/pinball_flame/lib/src/keyboard_input_controller.dart index 8249e599..b0d64ee6 100644 --- a/packages/pinball_flame/lib/src/keyboard_input_controller.dart +++ b/packages/pinball_flame/lib/src/keyboard_input_controller.dart @@ -1,4 +1,5 @@ import 'package:flame/components.dart'; +import 'package:flame/game.dart'; import 'package:flutter/services.dart'; /// The signature for a key handle function @@ -18,6 +19,17 @@ class KeyboardInputController extends Component with KeyboardHandler { final Map _keyUp; final Map _keyDown; + /// Trigger a virtual key up event. + bool onVirtualKeyUp(LogicalKeyboardKey key) { + final handler = _keyUp[key]; + + if (handler != null) { + return handler(); + } + + return true; + } + @override bool onKeyEvent(RawKeyEvent event, Set keysPressed) { final isUp = event is RawKeyUpEvent; @@ -32,3 +44,18 @@ class KeyboardInputController extends Component with KeyboardHandler { return true; } } + +/// Add the ability to virtually trigger key events to a [FlameGame]'s +/// [KeyboardInputController]. +extension VirtualKeyEvents on FlameGame { + /// Trigger a key up + void triggerVirtualKeyUp(LogicalKeyboardKey key) { + final keyControllers = descendants().whereType(); + + for (final controller in keyControllers) { + if (!controller.onVirtualKeyUp(key)) { + break; + } + } + } +} diff --git a/packages/pinball_flame/test/src/keyboard_input_controller_test.dart b/packages/pinball_flame/test/src/keyboard_input_controller_test.dart index 7b554e8c..f3c783ad 100644 --- a/packages/pinball_flame/test/src/keyboard_input_controller_test.dart +++ b/packages/pinball_flame/test/src/keyboard_input_controller_test.dart @@ -1,11 +1,36 @@ // ignore_for_file: cascade_invocations, one_member_abstracts +import 'package:flame/game.dart'; +import 'package:flame_test/flame_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball_flame/pinball_flame.dart'; +class _TestGame extends FlameGame { + bool pressed = false; + + @override + Future? onLoad() async { + await super.onLoad(); + + await add( + KeyboardInputController( + keyUp: { + LogicalKeyboardKey.enter: () { + pressed = true; + return true; + }, + LogicalKeyboardKey.escape: () { + return false; + }, + }, + ), + ); + } +} + abstract class _KeyCall { bool onCall(); } @@ -75,4 +100,15 @@ void main() { }, ); }); + group('VirtualKeyEvents', () { + final flameTester = FlameTester(_TestGame.new); + + group('onVirtualKeyUp', () { + flameTester.test('triggers the event', (game) async { + await game.ready(); + game.triggerVirtualKeyUp(LogicalKeyboardKey.enter); + expect(game.pressed, isTrue); + }); + }); + }); } diff --git a/packages/pinball_ui/assets/images/button/dpad_down.png b/packages/pinball_ui/assets/images/button/dpad_down.png new file mode 100644 index 0000000000000000000000000000000000000000..11bbb26f1baf58f021f4b80bc6ec2e03433926f6 GIT binary patch literal 1956 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 R>mL9B002ovPDHLkV1mD}%$5KE literal 0 HcmV?d00001 diff --git a/packages/pinball_ui/assets/images/button/dpad_right.png b/packages/pinball_ui/assets/images/button/dpad_right.png new file mode 100644 index 0000000000000000000000000000000000000000..724b9f3eda6306c318ca0e084482beb15a3e62da GIT binary patch literal 1988 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 literal 0 HcmV?d00001 diff --git a/packages/pinball_ui/lib/gen/assets.gen.dart b/packages/pinball_ui/lib/gen/assets.gen.dart index 8972e8e0..9b09b254 100644 --- a/packages/pinball_ui/lib/gen/assets.gen.dart +++ b/packages/pinball_ui/lib/gen/assets.gen.dart @@ -3,8 +3,6 @@ /// FlutterGen /// ***************************************************** -// ignore_for_file: directives_ordering,unnecessary_import - import 'package:flutter/widgets.dart'; class $AssetsImagesGen { @@ -17,7 +15,14 @@ class $AssetsImagesGen { class $AssetsImagesButtonGen { const $AssetsImagesButtonGen(); - /// File path: assets/images/button/pinball_button.png + AssetGenImage get dpadDown => + const AssetGenImage('assets/images/button/dpad_down.png'); + AssetGenImage get dpadLeft => + const AssetGenImage('assets/images/button/dpad_left.png'); + AssetGenImage get dpadRight => + const AssetGenImage('assets/images/button/dpad_right.png'); + AssetGenImage get dpadUp => + const AssetGenImage('assets/images/button/dpad_up.png'); AssetGenImage get pinballButton => const AssetGenImage('assets/images/button/pinball_button.png'); } @@ -25,7 +30,6 @@ class $AssetsImagesButtonGen { class $AssetsImagesDialogGen { const $AssetsImagesDialogGen(); - /// File path: assets/images/dialog/background.png AssetGenImage get background => const AssetGenImage('assets/images/dialog/background.png'); } diff --git a/packages/pinball_ui/lib/gen/fonts.gen.dart b/packages/pinball_ui/lib/gen/fonts.gen.dart index 5f77da16..b15f2dd0 100644 --- a/packages/pinball_ui/lib/gen/fonts.gen.dart +++ b/packages/pinball_ui/lib/gen/fonts.gen.dart @@ -3,14 +3,9 @@ /// FlutterGen /// ***************************************************** -// ignore_for_file: directives_ordering,unnecessary_import - class FontFamily { FontFamily._(); - /// Font family: PixeloidMono static const String pixeloidMono = 'PixeloidMono'; - - /// Font family: PixeloidSans static const String pixeloidSans = 'PixeloidSans'; } diff --git a/packages/pinball_ui/lib/src/widgets/pinball_dpad_button.dart b/packages/pinball_ui/lib/src/widgets/pinball_dpad_button.dart new file mode 100644 index 00000000..6d929f53 --- /dev/null +++ b/packages/pinball_ui/lib/src/widgets/pinball_dpad_button.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:pinball_ui/gen/gen.dart'; +import 'package:pinball_ui/pinball_ui.dart'; + +/// Enum with all possibile directions of a [PinballDpadButton]. +enum PinballDpadDirection { + /// Up + up, + + /// Down + down, + + /// Left + left, + + /// Right + right, +} + +extension _PinballDpadDirectionX on PinballDpadDirection { + String toAsset() { + switch (this) { + case PinballDpadDirection.up: + return Assets.images.button.dpadUp.keyName; + case PinballDpadDirection.down: + return Assets.images.button.dpadDown.keyName; + case PinballDpadDirection.left: + return Assets.images.button.dpadLeft.keyName; + case PinballDpadDirection.right: + return Assets.images.button.dpadRight.keyName; + } + } +} + +/// {@template pinball_dpad_button} +/// Widget that renders a Dpad button with a given direction. +/// {@endtemplate} +class PinballDpadButton extends StatelessWidget { + /// {@macro pinball_dpad_button} + const PinballDpadButton({ + Key? key, + required this.direction, + required this.onTap, + }) : super(key: key); + + /// Which [PinballDpadDirection] this button is. + final PinballDpadDirection direction; + + /// The function executed when the button is pressed. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: PinballColors.transparent, + child: InkWell( + onTap: onTap, + child: Image.asset( + direction.toAsset(), + width: 60, + height: 60, + ), + ), + ); + } +} diff --git a/packages/pinball_ui/lib/src/widgets/widgets.dart b/packages/pinball_ui/lib/src/widgets/widgets.dart index 3aa96c3e..45a6daad 100644 --- a/packages/pinball_ui/lib/src/widgets/widgets.dart +++ b/packages/pinball_ui/lib/src/widgets/widgets.dart @@ -1,4 +1,5 @@ export 'animated_ellipsis_text.dart'; export 'crt_background.dart'; export 'pinball_button.dart'; +export 'pinball_dpad_button.dart'; export 'pinball_loading_indicator.dart'; diff --git a/packages/pinball_ui/test/src/widgets/pinball_dpad_button_test.dart b/packages/pinball_ui/test/src/widgets/pinball_dpad_button_test.dart new file mode 100644 index 00000000..a7e89534 --- /dev/null +++ b/packages/pinball_ui/test/src/widgets/pinball_dpad_button_test.dart @@ -0,0 +1,122 @@ +// ignore_for_file: one_member_abstracts + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball_ui/gen/gen.dart'; +import 'package:pinball_ui/pinball_ui.dart'; + +extension _WidgetTesterX on WidgetTester { + Future pumpButton({ + required PinballDpadDirection direction, + required VoidCallback onTap, + }) async { + await pumpWidget( + MaterialApp( + home: Scaffold( + body: PinballDpadButton( + direction: direction, + onTap: onTap, + ), + ), + ), + ); + } +} + +extension _CommonFindersX on CommonFinders { + Finder byImagePath(String path) { + return find.byWidgetPredicate( + (widget) { + if (widget is Image) { + final image = widget.image; + + if (image is AssetImage) { + return image.keyName == path; + } + return false; + } + + return false; + }, + ); + } +} + +abstract class _VoidCallbackStubBase { + void onCall(); +} + +class _VoidCallbackStub extends Mock implements _VoidCallbackStubBase {} + +void main() { + group('PinballDpadButton', () { + testWidgets('can be tapped', (tester) async { + final stub = _VoidCallbackStub(); + await tester.pumpButton( + direction: PinballDpadDirection.up, + onTap: stub.onCall, + ); + + await tester.tap(find.byType(Image)); + + verify(stub.onCall).called(1); + }); + + group('up', () { + testWidgets('renders the correct image', (tester) async { + await tester.pumpButton( + direction: PinballDpadDirection.up, + onTap: () {}, + ); + + expect( + find.byImagePath(Assets.images.button.dpadUp.keyName), + findsOneWidget, + ); + }); + }); + + group('down', () { + testWidgets('renders the correct image', (tester) async { + await tester.pumpButton( + direction: PinballDpadDirection.down, + onTap: () {}, + ); + + expect( + find.byImagePath(Assets.images.button.dpadDown.keyName), + findsOneWidget, + ); + }); + }); + + group('left', () { + testWidgets('renders the correct image', (tester) async { + await tester.pumpButton( + direction: PinballDpadDirection.left, + onTap: () {}, + ); + + expect( + find.byImagePath(Assets.images.button.dpadLeft.keyName), + findsOneWidget, + ); + }); + }); + + group('right', () { + testWidgets('renders the correct image', (tester) async { + await tester.pumpButton( + direction: PinballDpadDirection.right, + onTap: () {}, + ); + + expect( + find.byImagePath(Assets.images.button.dpadRight.keyName), + findsOneWidget, + ); + }); + }); + }); +} diff --git a/test/game/components/backbox/backbox_test.dart b/test/game/components/backbox/backbox_test.dart index d61bd83a..40aaa77d 100644 --- a/test/game/components/backbox/backbox_test.dart +++ b/test/game/components/backbox/backbox_test.dart @@ -19,6 +19,7 @@ import 'package:pinball/l10n/l10n.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_theme/pinball_theme.dart' as theme; +import 'package:platform_helper/platform_helper.dart'; class _TestGame extends Forge2DGame with HasKeyboardHandlerComponents { final character = theme.DashTheme(); @@ -64,6 +65,8 @@ RawKeyUpEvent _mockKeyUp(LogicalKeyboardKey key) { return event; } +class _MockPlatformHelper extends Mock implements PlatformHelper {} + class _MockBackboxBloc extends Mock implements BackboxBloc {} class _MockLeaderboardRepository extends Mock implements LeaderboardRepository { @@ -104,21 +107,27 @@ void main() { final flameTester = FlameTester(_TestGame.new); late BackboxBloc bloc; + late PlatformHelper platformHelper; setUp(() { bloc = _MockBackboxBloc(); + platformHelper = _MockPlatformHelper(); whenListen( bloc, Stream.empty(), initialState: LoadingState(), ); + when(() => platformHelper.isMobile).thenReturn(false); }); group('Backbox', () { flameTester.test( 'loads correctly', (game) async { - final backbox = Backbox.test(bloc: bloc); + final backbox = Backbox.test( + bloc: bloc, + platformHelper: platformHelper, + ); await game.pump(backbox); expect(game.descendants(), contains(backbox)); }, @@ -127,7 +136,10 @@ void main() { flameTester.test( 'adds LeaderboardRequested when loaded', (game) async { - final backbox = Backbox.test(bloc: bloc); + final backbox = Backbox.test( + bloc: bloc, + platformHelper: platformHelper, + ); await game.pump(backbox); verify(() => bloc.add(LeaderboardRequested())).called(1); @@ -142,7 +154,10 @@ void main() { ..followVector2(Vector2(0, -130)) ..zoom = 6; await game.pump( - Backbox.test(bloc: bloc), + Backbox.test( + bloc: bloc, + platformHelper: platformHelper, + ), ); await tester.pump(); }, @@ -161,6 +176,7 @@ void main() { bloc: BackboxBloc( leaderboardRepository: _MockLeaderboardRepository(), ), + platformHelper: platformHelper, ); await game.pump(backbox); backbox.requestInitials( @@ -189,7 +205,10 @@ void main() { Stream.empty(), initialState: state, ); - final backbox = Backbox.test(bloc: bloc); + final backbox = Backbox.test( + bloc: bloc, + platformHelper: platformHelper, + ); await game.pump(backbox); game.onKeyEvent(_mockKeyUp(LogicalKeyboardKey.enter), {}); @@ -205,6 +224,34 @@ void main() { }, ); + flameTester.test( + 'adds the mobile controls overlay when platform is mobile', + (game) async { + final bloc = _MockBackboxBloc(); + final platformHelper = _MockPlatformHelper(); + final state = InitialsFormState( + score: 10, + character: game.character, + ); + whenListen( + bloc, + Stream.empty(), + initialState: state, + ); + when(() => platformHelper.isMobile).thenReturn(true); + final backbox = Backbox.test( + bloc: bloc, + platformHelper: platformHelper, + ); + await game.pump(backbox); + + expect( + game.overlays.value, + contains(PinballGame.mobileControlsOverlay), + ); + }, + ); + flameTester.test( 'adds InitialsSubmissionSuccessDisplay on InitialsSuccessState', (game) async { @@ -213,7 +260,10 @@ void main() { Stream.empty(), initialState: InitialsSuccessState(), ); - final backbox = Backbox.test(bloc: bloc); + final backbox = Backbox.test( + bloc: bloc, + platformHelper: platformHelper, + ); await game.pump(backbox); expect( @@ -234,7 +284,10 @@ void main() { Stream.empty(), initialState: InitialsFailureState(), ); - final backbox = Backbox.test(bloc: bloc); + final backbox = Backbox.test( + bloc: bloc, + platformHelper: platformHelper, + ); await game.pump(backbox); expect( @@ -256,7 +309,10 @@ void main() { initialState: LeaderboardSuccessState(entries: const []), ); - final backbox = Backbox.test(bloc: bloc); + final backbox = Backbox.test( + bloc: bloc, + platformHelper: platformHelper, + ); await game.pump(backbox); expect( @@ -276,7 +332,10 @@ void main() { initialState: LoadingState(), ); - final backbox = Backbox.test(bloc: bloc); + final backbox = Backbox.test( + bloc: bloc, + platformHelper: platformHelper, + ); await game.pump(backbox); backbox.removeFromParent(); diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index 0e23e54d..b9114244 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -335,6 +335,20 @@ void main() { expect(game.focusNode.hasFocus, isTrue); }); + testWidgets('mobile controls when the overlay is added', (tester) async { + await tester.pumpApp( + PinballGameView(game: game), + gameBloc: gameBloc, + startGameBloc: startGameBloc, + ); + + game.overlays.add(PinballGame.mobileControlsOverlay); + + await tester.pump(); + + expect(find.byType(MobileControls), findsOneWidget); + }); + group('info icon', () { testWidgets('renders on game over', (tester) async { final gameState = GameState.initial().copyWith( diff --git a/test/game/view/widgets/mobile_controls_test.dart b/test/game/view/widgets/mobile_controls_test.dart new file mode 100644 index 00000000..ab9c0b76 --- /dev/null +++ b/test/game/view/widgets/mobile_controls_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball_flame/pinball_flame.dart'; +import 'package:pinball_ui/pinball_ui.dart'; + +class _MockPinballGame extends Mock implements PinballGame {} + +extension _WidgetTesterX on WidgetTester { + Future pumpMobileControls(PinballGame game) async { + await pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + ], + home: Scaffold( + body: MobileControls(game: game), + ), + ), + ); + } +} + +extension _CommonFindersX on CommonFinders { + Finder byPinballDpadDirection(PinballDpadDirection direction) { + return byWidgetPredicate((widget) { + return widget is PinballDpadButton && widget.direction == direction; + }); + } +} + +void main() { + group('MobileControls', () { + testWidgets('renders', (tester) async { + await tester.pumpMobileControls(_MockPinballGame()); + + expect(find.byType(PinballButton), findsOneWidget); + expect(find.byType(MobileDpad), findsOneWidget); + }); + + testWidgets('correctly triggers the arrow up', (tester) async { + var pressed = false; + final component = KeyboardInputController( + keyUp: { + LogicalKeyboardKey.arrowUp: () => pressed = true, + }, + ); + final game = _MockPinballGame(); + when(game.descendants).thenReturn([component]); + + await tester.pumpMobileControls(game); + await tester.tap(find.byPinballDpadDirection(PinballDpadDirection.up)); + await tester.pump(); + + expect(pressed, isTrue); + }); + + testWidgets('correctly triggers the arrow down', (tester) async { + var pressed = false; + final component = KeyboardInputController( + keyUp: { + LogicalKeyboardKey.arrowDown: () => pressed = true, + }, + ); + final game = _MockPinballGame(); + when(game.descendants).thenReturn([component]); + + await tester.pumpMobileControls(game); + await tester.tap(find.byPinballDpadDirection(PinballDpadDirection.down)); + await tester.pump(); + + expect(pressed, isTrue); + }); + + testWidgets('correctly triggers the arrow right', (tester) async { + var pressed = false; + final component = KeyboardInputController( + keyUp: { + LogicalKeyboardKey.arrowRight: () => pressed = true, + }, + ); + final game = _MockPinballGame(); + when(game.descendants).thenReturn([component]); + + await tester.pumpMobileControls(game); + await tester.tap(find.byPinballDpadDirection(PinballDpadDirection.right)); + await tester.pump(); + + expect(pressed, isTrue); + }); + + testWidgets('correctly triggers the arrow left', (tester) async { + var pressed = false; + final component = KeyboardInputController( + keyUp: { + LogicalKeyboardKey.arrowLeft: () => pressed = true, + }, + ); + final game = _MockPinballGame(); + when(game.descendants).thenReturn([component]); + + await tester.pumpMobileControls(game); + await tester.tap(find.byPinballDpadDirection(PinballDpadDirection.left)); + await tester.pump(); + + expect(pressed, isTrue); + }); + + testWidgets('correctly triggers the enter', (tester) async { + var pressed = false; + final component = KeyboardInputController( + keyUp: { + LogicalKeyboardKey.enter: () => pressed = true, + }, + ); + final game = _MockPinballGame(); + when(game.descendants).thenReturn([component]); + + await tester.pumpMobileControls(game); + await tester.tap(find.byType(PinballButton)); + await tester.pump(); + + expect(pressed, isTrue); + }); + }); +} diff --git a/test/game/view/widgets/mobile_dpad_test.dart b/test/game/view/widgets/mobile_dpad_test.dart new file mode 100644 index 00000000..2a8d0b02 --- /dev/null +++ b/test/game/view/widgets/mobile_dpad_test.dart @@ -0,0 +1,113 @@ +// ignore_for_file: one_member_abstracts + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_ui/pinball_ui.dart'; + +extension _WidgetTesterX on WidgetTester { + Future pumpDpad({ + required VoidCallback onTapUp, + required VoidCallback onTapDown, + required VoidCallback onTapLeft, + required VoidCallback onTapRight, + }) async { + await pumpWidget( + MaterialApp( + home: Scaffold( + body: MobileDpad( + onTapUp: onTapUp, + onTapDown: onTapDown, + onTapLeft: onTapLeft, + onTapRight: onTapRight, + ), + ), + ), + ); + } +} + +extension _CommonFindersX on CommonFinders { + Finder byPinballDpadDirection(PinballDpadDirection direction) { + return byWidgetPredicate((widget) { + return widget is PinballDpadButton && widget.direction == direction; + }); + } +} + +abstract class _VoidCallbackStubBase { + void onCall(); +} + +class _VoidCallbackStub extends Mock implements _VoidCallbackStubBase {} + +void main() { + group('MobileDpad', () { + testWidgets('renders correctly', (tester) async { + await tester.pumpDpad( + onTapUp: () {}, + onTapDown: () {}, + onTapLeft: () {}, + onTapRight: () {}, + ); + + expect( + find.byType(PinballDpadButton), + findsNWidgets(4), + ); + }); + + testWidgets('can tap up', (tester) async { + final stub = _VoidCallbackStub(); + await tester.pumpDpad( + onTapUp: stub.onCall, + onTapDown: () {}, + onTapLeft: () {}, + onTapRight: () {}, + ); + + await tester.tap(find.byPinballDpadDirection(PinballDpadDirection.up)); + verify(stub.onCall).called(1); + }); + + testWidgets('can tap down', (tester) async { + final stub = _VoidCallbackStub(); + await tester.pumpDpad( + onTapUp: () {}, + onTapDown: stub.onCall, + onTapLeft: () {}, + onTapRight: () {}, + ); + + await tester.tap(find.byPinballDpadDirection(PinballDpadDirection.down)); + verify(stub.onCall).called(1); + }); + + testWidgets('can tap left', (tester) async { + final stub = _VoidCallbackStub(); + await tester.pumpDpad( + onTapUp: () {}, + onTapDown: () {}, + onTapLeft: stub.onCall, + onTapRight: () {}, + ); + + await tester.tap(find.byPinballDpadDirection(PinballDpadDirection.left)); + verify(stub.onCall).called(1); + }); + + testWidgets('can tap left', (tester) async { + final stub = _VoidCallbackStub(); + await tester.pumpDpad( + onTapUp: () {}, + onTapDown: () {}, + onTapLeft: () {}, + onTapRight: stub.onCall, + ); + + await tester.tap(find.byPinballDpadDirection(PinballDpadDirection.right)); + verify(stub.onCall).called(1); + }); + }); +}