diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 97cfec9b..8165b2f4 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -14,6 +14,7 @@ import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball/start_game/start_game.dart'; import 'package:pinball_audio/pinball_audio.dart'; class App extends StatelessWidget { @@ -35,8 +36,11 @@ class App extends StatelessWidget { RepositoryProvider.value(value: _leaderboardRepository), RepositoryProvider.value(value: _pinballAudio), ], - child: BlocProvider( - create: (context) => CharacterThemeCubit(), + child: MultiBlocProvider( + providers: [ + BlocProvider(create: (context) => CharacterThemeCubit()), + BlocProvider(create: (context) => StartGameBloc()), + ], child: const MaterialApp( title: 'I/O Pinball', localizationsDelegates: [ diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index be11a15c..2fd10424 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -48,7 +48,6 @@ class PinballGamePage extends StatelessWidget { return MultiBlocProvider( providers: [ - BlocProvider(create: (_) => StartGameBloc(game: game)), BlocProvider(create: (_) => GameBloc()), BlocProvider( create: (_) => AssetsManagerCubit(loadables)..load(), @@ -114,36 +113,43 @@ class PinballGameLoadedView extends StatelessWidget { @override Widget build(BuildContext context) { + final isPlaying = context.select( + (StartGameBloc bloc) => bloc.state.status == StartGameStatus.play, + ); final gameWidgetWidth = MediaQuery.of(context).size.height * 9 / 16; final screenWidth = MediaQuery.of(context).size.width; final leftMargin = (screenWidth / 2) - (gameWidgetWidth / 1.8); - return Stack( - children: [ - Positioned.fill( - child: GameWidget( - game: game, - initialActiveOverlays: const [PinballGame.playButtonOverlay], - overlayBuilderMap: { - PinballGame.playButtonOverlay: (context, game) { - return Positioned( - bottom: 20, - right: 0, - left: 0, - child: PlayButtonOverlay(game: game), - ); + return StartGameListener( + game: game, + child: Stack( + children: [ + Positioned.fill( + child: GameWidget( + game: game, + initialActiveOverlays: const [PinballGame.playButtonOverlay], + overlayBuilderMap: { + PinballGame.playButtonOverlay: (context, game) { + return const Positioned( + bottom: 20, + right: 0, + left: 0, + child: PlayButtonOverlay(), + ); + }, }, - }, + ), ), - ), - // TODO(arturplaczek): add Visibility to GameHud based on StartGameBloc - // status - Positioned( - top: 16, - left: leftMargin, - child: const GameHud(), - ), - ], + Positioned( + top: 16, + left: leftMargin, + child: Visibility( + visible: isPlaying, + child: const GameHud(), + ), + ), + ], + ), ); } } diff --git a/lib/game/view/widgets/play_button_overlay.dart b/lib/game/view/widgets/play_button_overlay.dart index 3db62a50..21493ca2 100644 --- a/lib/game/view/widgets/play_button_overlay.dart +++ b/lib/game/view/widgets/play_button_overlay.dart @@ -1,20 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:pinball/game/pinball_game.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:pinball/l10n/l10n.dart'; -import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball/start_game/start_game.dart'; /// {@template play_button_overlay} /// [Widget] that renders the button responsible to starting the game /// {@endtemplate} class PlayButtonOverlay extends StatelessWidget { /// {@macro play_button_overlay} - const PlayButtonOverlay({ - Key? key, - required PinballGame game, - }) : _game = game, - super(key: key); - - final PinballGame _game; + const PlayButtonOverlay({Key? key}) : super(key: key); @override Widget build(BuildContext context) { @@ -23,23 +17,7 @@ class PlayButtonOverlay extends StatelessWidget { return Center( child: ElevatedButton( onPressed: () { - _game.gameFlowController.start(); - showDialog( - context: context, - barrierDismissible: false, - builder: (_) { - // TODO(arturplaczek): remove after merge StarBlocListener - final height = MediaQuery.of(context).size.height * 0.5; - - return Center( - child: SizedBox( - height: height, - width: height * 1.4, - child: const CharacterSelectionDialog(), - ), - ); - }, - ); + context.read().add(const PlayTapped()); }, child: Text(l10n.play), ), diff --git a/lib/select_character/view/character_selection_page.dart b/lib/select_character/view/character_selection_page.dart index 83dc6ee6..80a184ab 100644 --- a/lib/select_character/view/character_selection_page.dart +++ b/lib/select_character/view/character_selection_page.dart @@ -47,19 +47,7 @@ class CharacterSelectionView extends StatelessWidget { TextButton( onPressed: () { Navigator.of(context).pop(); - // TODO(arturplaczek): remove after merge StarBlocListener - final height = MediaQuery.of(context).size.height * 0.5; - - showDialog( - context: context, - builder: (_) => Center( - child: SizedBox( - height: height, - width: height * 1.4, - child: const HowToPlayDialog(), - ), - ), - ); + context.read().add(const CharacterSelected()); }, child: Text(l10n.start), ), diff --git a/lib/start_game/bloc/start_game_bloc.dart b/lib/start_game/bloc/start_game_bloc.dart index ba44d88c..3a96b57b 100644 --- a/lib/start_game/bloc/start_game_bloc.dart +++ b/lib/start_game/bloc/start_game_bloc.dart @@ -1,6 +1,5 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; -import 'package:pinball/game/game.dart'; part 'start_game_event.dart'; part 'start_game_state.dart'; @@ -10,23 +9,16 @@ part 'start_game_state.dart'; /// {@endtemplate} class StartGameBloc extends Bloc { /// {@macro start_game_bloc} - StartGameBloc({ - required PinballGame game, - }) : _game = game, - super(const StartGameState.initial()) { + StartGameBloc() : super(const StartGameState.initial()) { on(_onPlayTapped); on(_onCharacterSelected); on(_onHowToPlayFinished); } - final PinballGame _game; - void _onPlayTapped( PlayTapped event, Emitter emit, ) { - _game.gameFlowController.start(); - emit( state.copyWith( status: StartGameStatus.selectCharacter, diff --git a/lib/start_game/widgets/how_to_play_dialog.dart b/lib/start_game/widgets/how_to_play_dialog.dart index bc5166e4..79959669 100644 --- a/lib/start_game/widgets/how_to_play_dialog.dart +++ b/lib/start_game/widgets/how_to_play_dialog.dart @@ -5,22 +5,33 @@ import 'package:pinball/l10n/l10n.dart'; import 'package:pinball_ui/pinball_ui.dart'; class HowToPlayDialog extends StatelessWidget { - const HowToPlayDialog({Key? key}) : super(key: key); + const HowToPlayDialog({ + Key? key, + required this.onDismissCallback, + }) : super(key: key); + + final VoidCallback onDismissCallback; @override Widget build(BuildContext context) { final l10n = context.l10n; const spacing = SizedBox(height: 16); - return PixelatedDecoration( - header: Text(l10n.howToPlay), - body: ListView( - children: const [ - spacing, - _LaunchControls(), - spacing, - _FlipperControls(), - ], + return WillPopScope( + onWillPop: () { + onDismissCallback.call(); + return Future.value(true); + }, + child: PixelatedDecoration( + header: Text(l10n.howToPlay), + body: ListView( + children: const [ + spacing, + _LaunchControls(), + spacing, + _FlipperControls(), + ], + ), ), ); } @@ -66,9 +77,7 @@ class _FlipperControls extends StatelessWidget { const SizedBox(height: 10), Column( children: [ - Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, + Wrap( children: const [ KeyIndicator.fromIcon(keyIcon: Icons.keyboard_arrow_left), rowSpacing, diff --git a/lib/start_game/widgets/start_game_listener.dart b/lib/start_game/widgets/start_game_listener.dart new file mode 100644 index 00000000..2afe1a40 --- /dev/null +++ b/lib/start_game/widgets/start_game_listener.dart @@ -0,0 +1,90 @@ +// ignore_for_file: public_member_api_docs + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball/start_game/start_game.dart'; +import 'package:pinball/theme/theme.dart'; + +class StartGameListener extends StatelessWidget { + const StartGameListener({ + Key? key, + required Widget child, + required PinballGame game, + }) : _child = child, + _game = game, + super(key: key); + + final Widget _child; + final PinballGame _game; + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + switch (state.status) { + case StartGameStatus.initial: + break; + case StartGameStatus.selectCharacter: + _onSelectCharacter(context); + break; + case StartGameStatus.howToPlay: + _onHowToPlay(context); + break; + case StartGameStatus.play: + _game.gameFlowController.start(); + break; + } + }, + child: _child, + ); + } + + void _onSelectCharacter(BuildContext context) { + _showPinballDialog( + context: context, + child: const CharacterSelectionDialog(), + barrierDismissible: false, + ); + } +} + +Future _onHowToPlay(BuildContext context) async { + _showPinballDialog( + context: context, + child: HowToPlayDialog( + onDismissCallback: () { + // We need to add a delay between closing the dialog and starting the + // game. + Future.delayed( + kThemeAnimationDuration, + () => context.read().add(const HowToPlayFinished()), + ); + }, + ), + ); +} + +void _showPinballDialog({ + required BuildContext context, + required Widget child, + bool barrierDismissible = true, +}) { + final gameWidgetWidth = MediaQuery.of(context).size.height * 9 / 16; + + showDialog( + context: context, + barrierColor: AppColors.transparent, + barrierDismissible: barrierDismissible, + builder: (_) { + return Center( + child: SizedBox( + height: gameWidgetWidth, + width: gameWidgetWidth, + child: child, + ), + ); + }, + ); +} diff --git a/lib/start_game/widgets/widgets.dart b/lib/start_game/widgets/widgets.dart index bad2c6b5..fa7c7253 100644 --- a/lib/start_game/widgets/widgets.dart +++ b/lib/start_game/widgets/widgets.dart @@ -1 +1,2 @@ export 'how_to_play_dialog.dart'; +export 'start_game_listener.dart'; diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index f8b62d05..1795b88e 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -198,12 +198,11 @@ void main() { find.byWidgetPredicate((w) => w is GameWidget), findsOneWidget, ); - // TODO(arturplaczek): add Visibility to GameHud based on StartGameBloc - // status - // expect( - // find.byType(GameHud), - // findsNothing, - // ); + + expect( + find.byType(GameHud), + findsNothing, + ); }); testWidgets('renders a hud on play state', (tester) async { diff --git a/test/game/view/widgets/play_button_overlay_test.dart b/test/game/view/widgets/play_button_overlay_test.dart index 0345978d..a4d53617 100644 --- a/test/game/view/widgets/play_button_overlay_test.dart +++ b/test/game/view/widgets/play_button_overlay_test.dart @@ -1,46 +1,42 @@ +import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; -import 'package:pinball/select_character/select_character.dart'; +import 'package:pinball/start_game/bloc/start_game_bloc.dart'; import '../../../helpers/helpers.dart'; void main() { group('PlayButtonOverlay', () { - late PinballGame game; - late GameFlowController gameFlowController; + late StartGameBloc startGameBloc; setUp(() { - game = MockPinballGame(); - gameFlowController = MockGameFlowController(); + startGameBloc = MockStartGameBloc(); - when(() => game.gameFlowController).thenReturn(gameFlowController); - when(gameFlowController.start).thenAnswer((_) {}); + whenListen( + startGameBloc, + Stream.value(const StartGameState.initial()), + initialState: const StartGameState.initial(), + ); }); testWidgets('renders correctly', (tester) async { - await tester.pumpApp(PlayButtonOverlay(game: game)); + await tester.pumpApp(const PlayButtonOverlay()); expect(find.text('Play'), findsOneWidget); }); - testWidgets('calls gameFlowController.start when taped', (tester) async { - await tester.pumpApp(PlayButtonOverlay(game: game)); - - await tester.tap(find.text('Play')); - await tester.pump(); - - verify(gameFlowController.start).called(1); - }); - - testWidgets('displays CharacterSelectionDialog when tapped', + testWidgets('adds PlayTapped event to StartGameBloc when taped', (tester) async { - await tester.pumpApp(PlayButtonOverlay(game: game)); + await tester.pumpApp( + const PlayButtonOverlay(), + startGameBloc: startGameBloc, + ); await tester.tap(find.text('Play')); await tester.pump(); - expect(find.byType(CharacterSelectionDialog), findsOneWidget); + verify(() => startGameBloc.add(const PlayTapped())).called(1); }); }); } diff --git a/test/select_character/view/character_selection_page_test.dart b/test/select_character/view/character_selection_page_test.dart index 0dda92d7..5cd22f54 100644 --- a/test/select_character/view/character_selection_page_test.dart +++ b/test/select_character/view/character_selection_page_test.dart @@ -12,9 +12,12 @@ import '../../helpers/helpers.dart'; void main() { late CharacterThemeCubit characterThemeCubit; + late StartGameBloc startGameBloc; setUp(() { characterThemeCubit = MockCharacterThemeCubit(); + startGameBloc = MockStartGameBloc(); + whenListen( characterThemeCubit, const Stream.empty(), @@ -84,17 +87,24 @@ void main() { .called(1); }); - testWidgets('displays how to play dialog when start is tapped', + testWidgets('adds CharacterSelected event when start is tapped', (tester) async { + whenListen( + startGameBloc, + Stream.value(const StartGameState.initial()), + initialState: const StartGameState.initial(), + ); + await tester.pumpApp( CharacterSelectionView(), characterThemeCubit: characterThemeCubit, + startGameBloc: startGameBloc, ); await tester.ensureVisible(find.byType(TextButton)); await tester.tap(find.byType(TextButton)); await tester.pumpAndSettle(); - expect(find.byType(HowToPlayDialog), findsOneWidget); + verify(() => startGameBloc.add(CharacterSelected())).called(1); }); }); diff --git a/test/start_game/bloc/start_game_bloc_test.dart b/test/start_game/bloc/start_game_bloc_test.dart index ec1b3ced..0300d1f0 100644 --- a/test/start_game/bloc/start_game_bloc_test.dart +++ b/test/start_game/bloc/start_game_bloc_test.dart @@ -22,9 +22,7 @@ void main() { group('StartGameBloc', () { blocTest( 'on PlayTapped changes status to selectCharacter', - build: () => StartGameBloc( - game: pinballGame, - ), + build: StartGameBloc.new, act: (bloc) => bloc.add(const PlayTapped()), expect: () => [ const StartGameState( @@ -35,9 +33,7 @@ void main() { blocTest( 'on CharacterSelected changes status to howToPlay', - build: () => StartGameBloc( - game: pinballGame, - ), + build: StartGameBloc.new, act: (bloc) => bloc.add(const CharacterSelected()), expect: () => [ const StartGameState( @@ -48,9 +44,7 @@ void main() { blocTest( 'on HowToPlayFinished changes status to play', - build: () => StartGameBloc( - game: pinballGame, - ), + build: StartGameBloc.new, act: (bloc) => bloc.add(const HowToPlayFinished()), expect: () => [ const StartGameState( diff --git a/test/start_game/widgets/how_to_play_dialog_test.dart b/test/start_game/widgets/how_to_play_dialog_test.dart index c31ac1a3..bd09f021 100644 --- a/test/start_game/widgets/how_to_play_dialog_test.dart +++ b/test/start_game/widgets/how_to_play_dialog_test.dart @@ -12,7 +12,11 @@ void main() { testWidgets('displays content', (tester) async { final l10n = await AppLocalizations.delegate.load(Locale('en')); - await tester.pumpApp(HowToPlayDialog()); + await tester.pumpApp( + HowToPlayDialog( + onDismissCallback: () {}, + ), + ); expect(find.text(l10n.launchControls), findsOneWidget); }); diff --git a/test/start_game/widgets/start_game_listener_test.dart b/test/start_game/widgets/start_game_listener_test.dart new file mode 100644 index 00000000..5ce4ca94 --- /dev/null +++ b/test/start_game/widgets/start_game_listener_test.dart @@ -0,0 +1,178 @@ +import 'package:bloc_test/bloc_test.dart'; +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/select_character/select_character.dart'; +import 'package:pinball/start_game/start_game.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + late StartGameBloc startGameBloc; + late PinballGame pinballGame; + + group('StartGameListener', () { + setUp(() { + startGameBloc = MockStartGameBloc(); + pinballGame = MockPinballGame(); + }); + + testWidgets( + 'on selectCharacter status shows SelectCharacter dialog', + (tester) async { + whenListen( + startGameBloc, + Stream.value( + const StartGameState(status: StartGameStatus.selectCharacter), + ), + initialState: const StartGameState.initial(), + ); + + await tester.pumpApp( + StartGameListener( + game: pinballGame, + child: const SizedBox.shrink(), + ), + startGameBloc: startGameBloc, + ); + + await tester.pumpAndSettle(); + + expect( + find.byType(CharacterSelectionDialog), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'on howToPlay status shows HowToPlay dialog', + (tester) async { + whenListen( + startGameBloc, + Stream.value( + const StartGameState(status: StartGameStatus.howToPlay), + ), + initialState: const StartGameState.initial(), + ); + + await tester.pumpApp( + StartGameListener( + game: pinballGame, + child: const SizedBox.shrink(), + ), + startGameBloc: startGameBloc, + ); + + await tester.pumpAndSettle(); + + expect( + find.byType(HowToPlayDialog), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'on play status call start on game controller', + (tester) async { + whenListen( + startGameBloc, + Stream.value( + const StartGameState(status: StartGameStatus.play), + ), + initialState: const StartGameState.initial(), + ); + + final gameController = MockGameFlowController(); + when(() => pinballGame.gameFlowController) + .thenAnswer((invocation) => gameController); + + await tester.pumpApp( + StartGameListener( + game: pinballGame, + child: const SizedBox.shrink(), + ), + startGameBloc: startGameBloc, + ); + + await tester.pumpAndSettle(kThemeAnimationDuration); + await tester.pumpAndSettle(kThemeAnimationDuration); + + verify(gameController.start).called(1); + }, + ); + + testWidgets( + 'do nothing on initial status', + (tester) async { + whenListen( + startGameBloc, + Stream.value( + const StartGameState(status: StartGameStatus.initial), + ), + initialState: const StartGameState.initial(), + ); + + await tester.pumpApp( + StartGameListener( + game: pinballGame, + child: const SizedBox.shrink(), + ), + startGameBloc: startGameBloc, + ); + + await tester.pumpAndSettle(); + + expect( + find.byType(HowToPlayDialog), + findsNothing, + ); + expect( + find.byType(CharacterSelectionDialog), + findsNothing, + ); + }, + ); + + testWidgets( + 'adds HowToPlayFinished event after closing HowToPlayDialog', + (tester) async { + whenListen( + startGameBloc, + Stream.value( + const StartGameState(status: StartGameStatus.howToPlay), + ), + initialState: const StartGameState.initial(), + ); + + await tester.pumpApp( + StartGameListener( + game: pinballGame, + child: const SizedBox.shrink(), + ), + startGameBloc: startGameBloc, + ); + await tester.pumpAndSettle(); + + expect( + find.byType(HowToPlayDialog), + findsOneWidget, + ); + await tester.tapAt(const Offset(1, 1)); + await tester.pumpAndSettle(); + + expect( + find.byType(HowToPlayDialog), + findsNothing, + ); + await tester.pumpAndSettle(); + + verify( + () => startGameBloc.add(const HowToPlayFinished()), + ).called(1); + }, + ); + }); +}