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 00000000..11bbb26f Binary files /dev/null and b/packages/pinball_ui/assets/images/button/dpad_down.png differ diff --git a/packages/pinball_ui/assets/images/button/dpad_left.png b/packages/pinball_ui/assets/images/button/dpad_left.png new file mode 100644 index 00000000..943cacc4 Binary files /dev/null and b/packages/pinball_ui/assets/images/button/dpad_left.png differ 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 00000000..724b9f3e Binary files /dev/null and b/packages/pinball_ui/assets/images/button/dpad_right.png differ diff --git a/packages/pinball_ui/assets/images/button/dpad_up.png b/packages/pinball_ui/assets/images/button/dpad_up.png new file mode 100644 index 00000000..d1175d57 Binary files /dev/null and b/packages/pinball_ui/assets/images/button/dpad_up.png differ 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); + }); + }); +}