From 78a616ccd115eda413f66331dc3f9d6073a6bca8 Mon Sep 17 00:00:00 2001 From: Erick Date: Mon, 14 Mar 2022 16:52:13 -0300 Subject: [PATCH 1/2] feat: adding effect on completing bonus word (#44) * feat: adding effect on completing bonus word * Apply suggestions from code review Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> * feat: pr suggestions Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> --- lib/game/components/bonus_word.dart | 49 ++++++++++++- test/game/components/bonus_word_test.dart | 89 +++++++++++++++++++++++ test/helpers/mocks.dart | 2 + 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/lib/game/components/bonus_word.dart b/lib/game/components/bonus_word.dart index 35412ecf..49a1da1d 100644 --- a/lib/game/components/bonus_word.dart +++ b/lib/game/components/bonus_word.dart @@ -12,12 +12,59 @@ import 'package:pinball/game/game.dart'; /// {@template bonus_word} /// Loads all [BonusLetter]s to compose a [BonusWord]. /// {@endtemplate} -class BonusWord extends Component { +class BonusWord extends Component with BlocComponent { /// {@macro bonus_word} BonusWord({required Vector2 position}) : _position = position; final Vector2 _position; + @override + bool listenWhen(GameState? previousState, GameState newState) { + if ((previousState?.bonusHistory.length ?? 0) < + newState.bonusHistory.length && + newState.bonusHistory.last == GameBonus.word) { + return true; + } + + return false; + } + + @override + void onNewState(GameState state) { + if (state.bonusHistory.last == GameBonus.word) { + final letters = children.whereType().toList(); + + for (var i = 0; i < letters.length; i++) { + final letter = letters[i]; + letter.add( + SequenceEffect( + [ + ColorEffect( + i.isOdd ? BonusLetter._activeColor : BonusLetter._disableColor, + const Offset(0, 1), + EffectController(duration: 0.25), + ), + ColorEffect( + i.isOdd ? BonusLetter._disableColor : BonusLetter._activeColor, + const Offset(0, 1), + EffectController(duration: 0.25), + ), + ], + repeatCount: 4, + )..onFinishCallback = () { + letter.add( + ColorEffect( + BonusLetter._disableColor, + const Offset(0, 1), + EffectController(duration: 0.25), + ), + ); + }, + ); + } + } + } + @override Future onLoad() async { await super.onLoad(); diff --git a/test/game/components/bonus_word_test.dart b/test/game/components/bonus_word_test.dart index 129f68d1..012ef2d8 100644 --- a/test/game/components/bonus_word_test.dart +++ b/test/game/components/bonus_word_test.dart @@ -26,6 +26,95 @@ void main() { expect(letters.length, equals(GameBloc.bonusWord.length)); }, ); + + group('listenWhen', () { + final previousState = MockGameState(); + final currentState = MockGameState(); + test( + 'returns true when there is a new word bonus awarded', + () { + when(() => previousState.bonusHistory).thenReturn([]); + when(() => currentState.bonusHistory).thenReturn([GameBonus.word]); + + expect( + BonusWord(position: Vector2.zero()).listenWhen( + previousState, + currentState, + ), + isTrue, + ); + }, + ); + + test( + 'returns false when there is no new word bonus awarded', + () { + when(() => previousState.bonusHistory).thenReturn([GameBonus.word]); + when(() => currentState.bonusHistory).thenReturn([GameBonus.word]); + + expect( + BonusWord(position: Vector2.zero()).listenWhen( + previousState, + currentState, + ), + isFalse, + ); + }, + ); + }); + + group('onNewState', () { + final state = MockGameState(); + flameTester.test( + 'adds sequence effect to the letters when the player receives a bonus', + (game) async { + when(() => state.bonusHistory).thenReturn([GameBonus.word]); + + final bonusWord = BonusWord(position: Vector2.zero()); + await game.ensureAdd(bonusWord); + await game.ready(); + + bonusWord.onNewState(state); + game.update(0); // Run one frame so the effects are added + + final letters = bonusWord.children.whereType(); + expect(letters.length, equals(GameBloc.bonusWord.length)); + + for (final letter in letters) { + expect( + letter.children.whereType().length, + equals(1), + ); + } + }, + ); + + flameTester.test( + 'adds a color effect to reset the color when the sequence is finished', + (game) async { + when(() => state.bonusHistory).thenReturn([GameBonus.word]); + + final bonusWord = BonusWord(position: Vector2.zero()); + await game.ensureAdd(bonusWord); + await game.ready(); + + bonusWord.onNewState(state); + // Run the amount of time necessary for the animation to finish + game.update(3); + game.update(0); // Run one additional frame so the effects are added + + final letters = bonusWord.children.whereType(); + expect(letters.length, equals(GameBloc.bonusWord.length)); + + for (final letter in letters) { + expect( + letter.children.whereType().length, + equals(1), + ); + } + }, + ); + }); }); group('BonusLetter', () { diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index c1c59377..80820c1b 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -18,6 +18,8 @@ class MockContact extends Mock implements Contact {} class MockGameBloc extends Mock implements GameBloc {} +class MockGameState extends Mock implements GameState {} + class MockThemeCubit extends Mock implements ThemeCubit {} class MockRawKeyDownEvent extends Mock implements RawKeyDownEvent { From 45f1c6e48bf09ee8529ce762c3a70721abf9363a Mon Sep 17 00:00:00 2001 From: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> Date: Mon, 14 Mar 2022 15:27:03 -0500 Subject: [PATCH 2/2] feat: add how to play dialog (#45) * feat: add how to play dialog * style: comma for readability --- lib/l10n/arb/app_en.arb | 12 ++ lib/landing/view/landing_page.dart | 178 ++++++++++++++++++++++- test/landing/view/landing_page_test.dart | 58 ++++++-- 3 files changed, 234 insertions(+), 14 deletions(-) diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index f12ccf7d..a118501e 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -4,6 +4,18 @@ "@play": { "description": "Text displayed on the landing page play button" }, + "howToPlay": "How to Play", + "@howToPlay": { + "description": "Text displayed on the landing page how to play button" + }, + "launchControls": "Launch Controls", + "@launchControls": { + "description": "Text displayed on the how to play dialog with the launch controls" + }, + "flipperControls": "Flipper Controls", + "@flipperControls": { + "description": "Text displayed on the how to play dialog with the flipper controls" + }, "start": "Start", "@start": { "description": "Text displayed on the character selection page start button" diff --git a/lib/landing/view/landing_page.dart b/lib/landing/view/landing_page.dart index 38951da6..5b0474b6 100644 --- a/lib/landing/view/landing_page.dart +++ b/lib/landing/view/landing_page.dart @@ -13,12 +13,182 @@ class LandingPage extends StatelessWidget { return Scaffold( body: Center( - child: TextButton( - onPressed: () => - Navigator.of(context).push(CharacterSelectionPage.route()), - child: Text(l10n.play), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + onPressed: () => Navigator.of(context).push( + CharacterSelectionPage.route(), + ), + child: Text(l10n.play), + ), + TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => const _HowToPlayDialog(), + ), + child: Text(l10n.howToPlay), + ), + ], ), ), ); } } + +class _HowToPlayDialog extends StatelessWidget { + const _HowToPlayDialog({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + const spacing = SizedBox(height: 16); + + return Dialog( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.howToPlay), + spacing, + const _LaunchControls(), + spacing, + const _FlipperControls(), + ], + ), + ), + ); + } +} + +class _LaunchControls extends StatelessWidget { + const _LaunchControls({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + const spacing = SizedBox(width: 10); + + return Column( + children: [ + Text(l10n.launchControls), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + KeyIndicator.fromIcon(keyIcon: Icons.keyboard_arrow_down), + spacing, + KeyIndicator.fromKeyName(keyName: 'SPACE'), + spacing, + KeyIndicator.fromKeyName(keyName: 'S'), + ], + ) + ], + ); + } +} + +class _FlipperControls extends StatelessWidget { + const _FlipperControls({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + const rowSpacing = SizedBox(width: 20); + + return Column( + children: [ + Text(l10n.flipperControls), + const SizedBox(height: 10), + Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + KeyIndicator.fromIcon(keyIcon: Icons.keyboard_arrow_left), + rowSpacing, + KeyIndicator.fromIcon(keyIcon: Icons.keyboard_arrow_right), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + KeyIndicator.fromKeyName(keyName: 'A'), + rowSpacing, + KeyIndicator.fromKeyName(keyName: 'D'), + ], + ) + ], + ) + ], + ); + } +} + +// TODO(allisonryan0002): remove visibility when adding final UI. +@visibleForTesting +class KeyIndicator extends StatelessWidget { + const KeyIndicator._({ + Key? key, + required String keyName, + required IconData keyIcon, + required bool fromIcon, + }) : _keyName = keyName, + _keyIcon = keyIcon, + _fromIcon = fromIcon, + super(key: key); + + const KeyIndicator.fromKeyName({Key? key, required String keyName}) + : this._( + key: key, + keyName: keyName, + keyIcon: Icons.keyboard_arrow_down, + fromIcon: false, + ); + + const KeyIndicator.fromIcon({Key? key, required IconData keyIcon}) + : this._( + key: key, + keyName: '', + keyIcon: keyIcon, + fromIcon: true, + ); + + final String _keyName; + + final IconData _keyIcon; + + final bool _fromIcon; + + @override + Widget build(BuildContext context) { + const iconPadding = EdgeInsets.all(15); + const textPadding = EdgeInsets.symmetric(vertical: 20, horizontal: 22); + final boarderColor = Colors.blue.withOpacity(0.5); + final color = Colors.blue.withOpacity(0.7); + + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + border: Border.all( + color: boarderColor, + width: 3, + ), + ), + child: _fromIcon + ? Padding( + padding: iconPadding, + child: Icon(_keyIcon, color: color), + ) + : Padding( + padding: textPadding, + child: Text(_keyName, style: TextStyle(color: color)), + ), + ); + } +} diff --git a/test/landing/view/landing_page_test.dart b/test/landing/view/landing_page_test.dart index ab036f9c..369f8cab 100644 --- a/test/landing/view/landing_page_test.dart +++ b/test/landing/view/landing_page_test.dart @@ -1,33 +1,71 @@ +// ignore_for_file: prefer_const_constructors + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockingjay/mockingjay.dart'; +import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/landing/landing.dart'; import '../../helpers/helpers.dart'; void main() { group('LandingPage', () { - testWidgets('renders TextButton', (tester) async { - await tester.pumpApp(const LandingPage()); - expect(find.byType(TextButton), findsOneWidget); + testWidgets('renders correctly', (tester) async { + final l10n = await AppLocalizations.delegate.load(Locale('en')); + await tester.pumpApp(LandingPage()); + + expect(find.byType(TextButton), findsNWidgets(2)); + expect(find.text(l10n.play), findsOneWidget); + expect(find.text(l10n.howToPlay), findsOneWidget); }); - testWidgets('tapping on TextButton navigates to CharacterSelectionPage', + testWidgets('tapping on play button navigates to CharacterSelectionPage', (tester) async { + final l10n = await AppLocalizations.delegate.load(Locale('en')); final navigator = MockNavigator(); when(() => navigator.push(any())).thenAnswer((_) async {}); await tester.pumpApp( - const LandingPage(), + LandingPage(), navigator: navigator, ); - await tester.tap( - find.byType( - TextButton, - ), - ); + + await tester.tap(find.widgetWithText(TextButton, l10n.play)); verify(() => navigator.push(any())).called(1); }); + + testWidgets('tapping on how to play button displays dialog with controls', + (tester) async { + final l10n = await AppLocalizations.delegate.load(Locale('en')); + await tester.pumpApp(LandingPage()); + + await tester.tap(find.widgetWithText(TextButton, l10n.howToPlay)); + await tester.pump(); + + expect(find.byType(Dialog), findsOneWidget); + }); + }); + + group('KeyIndicator', () { + testWidgets('fromKeyName renders correctly', (tester) async { + const keyName = 'A'; + + await tester.pumpApp( + KeyIndicator.fromKeyName(keyName: keyName), + ); + + expect(find.text(keyName), findsOneWidget); + }); + + testWidgets('fromIcon renders correctly', (tester) async { + const keyIcon = Icons.keyboard_arrow_down; + + await tester.pumpApp( + KeyIndicator.fromIcon(keyIcon: keyIcon), + ); + + expect(find.byIcon(keyIcon), findsOneWidget); + }); }); }