From cfd4e790faf9e1024bfcf10bba902f323c2c4970 Mon Sep 17 00:00:00 2001 From: Alejandro Santiago Date: Thu, 24 Mar 2022 16:51:19 +0000 Subject: [PATCH 1/4] feat: bumping all `DashNestBumper`s summons a new `Ball` (#89) * refactor: rename RoundBumper to DashNestBumper * feat: implemented DashNestBumper * refactor: renamed files and tests * chore: removed unused import * refactor: renamed file to be a test file * feat: added a new Ball on new state Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> --- lib/game/components/board.dart | 47 +------ lib/game/components/bonus_word.dart | 8 +- lib/game/components/components.dart | 2 +- lib/game/components/flutter_forest.dart | 131 ++++++++++++++++++ lib/game/components/round_bumper.dart | 35 ----- test/game/components/board_test.dart | 6 +- test/game/components/bonus_word_test.dart | 3 +- test/game/components/flutter_forest_test.dart | 125 +++++++++++++++++ test/game/components/round_bumper_test.dart | 102 -------------- test/helpers/mocks.dart | 2 + 10 files changed, 269 insertions(+), 192 deletions(-) create mode 100644 lib/game/components/flutter_forest.dart delete mode 100644 lib/game/components/round_bumper.dart create mode 100644 test/game/components/flutter_forest_test.dart delete mode 100644 test/game/components/round_bumper_test.dart diff --git a/lib/game/components/board.dart b/lib/game/components/board.dart index adfbef49..c771b9d8 100644 --- a/lib/game/components/board.dart +++ b/lib/game/components/board.dart @@ -1,11 +1,9 @@ import 'package:flame/components.dart'; import 'package:pinball/game/game.dart'; -import 'package:pinball_components/pinball_components.dart'; /// {@template board} -/// The main flat surface of the [PinballGame], where the [Flipper]s, -/// [RoundBumper]s, [Kicker]s are arranged. -/// {entemplate} +/// The main flat surface of the [PinballGame]. +/// {endtemplate} class Board extends Component { /// {@macro board} Board(); @@ -21,7 +19,7 @@ class Board extends Component { spacing: 2, ); - final dashForest = _FlutterForest( + final flutterForest = FlutterForest( position: Vector2( PinballGame.boardBounds.center.dx + 20, PinballGame.boardBounds.center.dy + 48, @@ -30,44 +28,7 @@ class Board extends Component { await addAll([ bottomGroup, - dashForest, - ]); - } -} - -/// {@template flutter_forest} -/// Area positioned at the top right of the [Board] where the [Ball] -/// can bounce off [RoundBumper]s. -/// {@endtemplate} -class _FlutterForest extends Component { - /// {@macro flutter_forest} - _FlutterForest({ - required this.position, - }); - - final Vector2 position; - - @override - Future onLoad() async { - // TODO(alestiago): adjust positioning once sprites are added. - // TODO(alestiago): Use [NestBumper] instead of [RoundBumper] once provided. - final smallLeftNest = RoundBumper( - radius: 1, - points: 10, - )..initialPosition = position + Vector2(-4.8, 2.8); - final smallRightNest = RoundBumper( - radius: 1, - points: 10, - )..initialPosition = position + Vector2(0.5, -5.5); - final bigNest = RoundBumper( - radius: 2, - points: 20, - )..initialPosition = position; - - await addAll([ - smallLeftNest, - smallRightNest, - bigNest, + flutterForest, ]); } } diff --git a/lib/game/components/bonus_word.dart b/lib/game/components/bonus_word.dart index d97a9bd1..9dc9b0b0 100644 --- a/lib/game/components/bonus_word.dart +++ b/lib/game/components/bonus_word.dart @@ -21,13 +21,9 @@ class BonusWord extends Component with BlocComponent { @override bool listenWhen(GameState? previousState, GameState newState) { - if ((previousState?.bonusHistory.length ?? 0) < + return (previousState?.bonusHistory.length ?? 0) < newState.bonusHistory.length && - newState.bonusHistory.last == GameBonus.word) { - return true; - } - - return false; + newState.bonusHistory.last == GameBonus.word; } @override diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index 1ed293da..07b036f6 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -4,6 +4,7 @@ export 'board.dart'; export 'board_side.dart'; export 'bonus_word.dart'; export 'flipper.dart'; +export 'flutter_forest.dart'; export 'jetpack_ramp.dart'; export 'joint_anchor.dart'; export 'kicker.dart'; @@ -11,7 +12,6 @@ export 'launcher_ramp.dart'; export 'pathway.dart'; export 'plunger.dart'; export 'ramp_opening.dart'; -export 'round_bumper.dart'; export 'score_points.dart'; export 'spaceship.dart'; export 'wall.dart'; diff --git a/lib/game/components/flutter_forest.dart b/lib/game/components/flutter_forest.dart new file mode 100644 index 00000000..51dcd90a --- /dev/null +++ b/lib/game/components/flutter_forest.dart @@ -0,0 +1,131 @@ +// ignore_for_file: avoid_renaming_method_parameters + +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball/flame/blueprint.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; + +/// {@template flutter_forest} +/// Area positioned at the top right of the [Board] where the [Ball] +/// can bounce off [DashNestBumper]s. +/// +/// When all [DashNestBumper]s are hit at least once, the [GameBonus.dashNest] +/// is awarded, and the [BigDashNestBumper] releases a new [Ball]. +/// {@endtemplate} +// TODO(alestiago): Make a [Blueprint] once nesting [Blueprint] is implemented. +class FlutterForest extends Component + with HasGameRef, BlocComponent { + /// {@macro flutter_forest} + FlutterForest({ + required this.position, + }); + + /// The position of the [FlutterForest] on the [Board]. + final Vector2 position; + + @override + bool listenWhen(GameState? previousState, GameState newState) { + return (previousState?.bonusHistory.length ?? 0) < + newState.bonusHistory.length && + newState.bonusHistory.last == GameBonus.dashNest; + } + + @override + void onNewState(GameState state) { + super.onNewState(state); + gameRef.addFromBlueprint(BallBlueprint(position: position)); + } + + @override + Future onLoad() async { + gameRef.addContactCallback(DashNestBumperBallContactCallback()); + + // TODO(alestiago): adjust positioning once sprites are added. + final smallLeftNest = SmallDashNestBumper(id: 'small_left_nest') + ..initialPosition = position + Vector2(-4.8, 2.8); + final smallRightNest = SmallDashNestBumper(id: 'small_right_nest') + ..initialPosition = position + Vector2(0.5, -5.5); + final bigNest = BigDashNestBumper(id: 'big_nest') + ..initialPosition = position; + + await addAll([ + smallLeftNest, + smallRightNest, + bigNest, + ]); + } +} + +/// {@template dash_nest_bumper} +/// Bumper located in the [FlutterForest]. +/// {@endtemplate} +@visibleForTesting +abstract class DashNestBumper extends BodyComponent + with ScorePoints, InitialPosition { + /// {@macro dash_nest_bumper} + DashNestBumper({required this.id}); + + /// Unique identifier for this [DashNestBumper]. + /// + /// Used to identify [DashNestBumper]s in [GameState.activatedDashNests]. + final String id; +} + +/// Listens when a [Ball] bounces bounces against a [DashNestBumper]. +@visibleForTesting +class DashNestBumperBallContactCallback + extends ContactCallback { + @override + void begin(DashNestBumper dashNestBumper, Ball ball, Contact _) { + dashNestBumper.gameRef.read().add( + DashNestActivated(dashNestBumper.id), + ); + } +} + +/// {@macro dash_nest_bumper} +@visibleForTesting +class BigDashNestBumper extends DashNestBumper { + /// {@macro dash_nest_bumper} + BigDashNestBumper({required String id}) : super(id: id); + + @override + int get points => 20; + + @override + Body createBody() { + final shape = CircleShape()..radius = 2.5; + final fixtureDef = FixtureDef(shape); + + final bodyDef = BodyDef() + ..position = initialPosition + ..userData = this; + + return world.createBody(bodyDef)..createFixture(fixtureDef); + } +} + +/// {@macro dash_nest_bumper} +@visibleForTesting +class SmallDashNestBumper extends DashNestBumper { + /// {@macro dash_nest_bumper} + SmallDashNestBumper({required String id}) : super(id: id); + + @override + int get points => 10; + + @override + Body createBody() { + final shape = CircleShape()..radius = 1; + final fixtureDef = FixtureDef(shape); + + final bodyDef = BodyDef() + ..position = initialPosition + ..userData = this; + + return world.createBody(bodyDef)..createFixture(fixtureDef); + } +} diff --git a/lib/game/components/round_bumper.dart b/lib/game/components/round_bumper.dart deleted file mode 100644 index 969bddbe..00000000 --- a/lib/game/components/round_bumper.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:pinball/game/game.dart'; -import 'package:pinball_components/pinball_components.dart'; - -/// {@template round_bumper} -/// Circular body that repels a [Ball] on contact, increasing the score. -/// {@endtemplate} -class RoundBumper extends BodyComponent with ScorePoints, InitialPosition { - /// {@macro round_bumper} - RoundBumper({ - required double radius, - required int points, - }) : _radius = radius, - _points = points; - - /// The radius of the [RoundBumper]. - final double _radius; - - /// Points awarded from hitting this [RoundBumper]. - final int _points; - - @override - int get points => _points; - - @override - Body createBody() { - final shape = CircleShape()..radius = _radius; - - final fixtureDef = FixtureDef(shape)..restitution = 1; - - final bodyDef = BodyDef()..position = initialPosition; - - return world.createBody(bodyDef)..createFixture(fixtureDef); - } -} diff --git a/test/game/components/board_test.dart b/test/game/components/board_test.dart index f0cd0e16..5a4b95dc 100644 --- a/test/game/components/board_test.dart +++ b/test/game/components/board_test.dart @@ -75,15 +75,15 @@ void main() { ); flameTester.test( - 'has three RoundBumpers', + 'has one FlutterForest', (game) async { // TODO(alestiago): change to [NestBumpers] once provided. final board = Board(); await game.ready(); await game.ensureAdd(board); - final roundBumpers = board.descendants().whereType(); - expect(roundBumpers.length, equals(3)); + final flutterForest = board.descendants().whereType(); + expect(flutterForest.length, equals(1)); }, ); }); diff --git a/test/game/components/bonus_word_test.dart b/test/game/components/bonus_word_test.dart index a12a5a74..afd69935 100644 --- a/test/game/components/bonus_word_test.dart +++ b/test/game/components/bonus_word_test.dart @@ -194,6 +194,7 @@ void main() { group('bonus letter activation', () { final gameBloc = MockGameBloc(); + final tester = flameBlocTester(gameBloc: () => gameBloc); BonusLetter _getBonusLetter(PinballGame game) { return game.children @@ -212,8 +213,6 @@ void main() { ); }); - final tester = flameBlocTester(gameBloc: () => gameBloc); - tester.widgetTest( 'adds BonusLetterActivated to GameBloc when not activated', (game, tester) async { diff --git a/test/game/components/flutter_forest_test.dart b/test/game/components/flutter_forest_test.dart new file mode 100644 index 00000000..f960796c --- /dev/null +++ b/test/game/components/flutter_forest_test.dart @@ -0,0 +1,125 @@ +// ignore_for_file: cascade_invocations + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final flameTester = FlameTester(PinballGameTest.create); + + group('FlutterForest', () { + flameTester.test( + 'loads correctly', + (game) async { + await game.ready(); + final flutterForest = FlutterForest(position: Vector2(0, 0)); + await game.ensureAdd(flutterForest); + + expect(game.contains(flutterForest), isTrue); + }, + ); + + flameTester.test( + 'onNewState adds a new ball', + (game) async { + final flutterForest = FlutterForest(position: Vector2(0, 0)); + await game.ready(); + await game.ensureAdd(flutterForest); + + final previousBalls = game.descendants().whereType().length; + flutterForest.onNewState(MockGameState()); + await game.ready(); + + expect( + game.descendants().whereType().length, + greaterThan(previousBalls), + ); + }, + ); + + group('listenWhen', () { + final gameBloc = MockGameBloc(); + final tester = flameBlocTester(gameBloc: () => gameBloc); + + setUp(() { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + }); + + tester.widgetTest( + 'listens when a Bonus.dashNest is added', + (game, tester) async { + await game.ready(); + final flutterForest = + game.descendants().whereType().first; + + const state = GameState( + score: 0, + balls: 3, + activatedBonusLetters: [], + activatedDashNests: {}, + bonusHistory: [GameBonus.dashNest], + ); + + expect( + flutterForest.listenWhen(const GameState.initial(), state), + isTrue, + ); + }, + ); + }); + }); + + group('DashNestBumperBallContactCallback', () { + final gameBloc = MockGameBloc(); + final tester = flameBlocTester(gameBloc: () => gameBloc); + + setUp(() { + whenListen( + gameBloc, + const Stream.empty(), + initialState: const GameState.initial(), + ); + }); + + tester.widgetTest( + 'adds a DashNestActivated event with DashNestBumper.id', + (game, tester) async { + final contactCallback = DashNestBumperBallContactCallback(); + const id = '0'; + final dashNestBumper = MockDashNestBumper(); + when(() => dashNestBumper.id).thenReturn(id); + when(() => dashNestBumper.gameRef).thenReturn(game); + + contactCallback.begin(dashNestBumper, MockBall(), MockContact()); + + verify(() => gameBloc.add(DashNestActivated(dashNestBumper.id))) + .called(1); + }, + ); + }); + + group('BigDashNestBumper', () { + test('has points', () { + final dashNestBumper = BigDashNestBumper(id: ''); + expect(dashNestBumper.points, greaterThan(0)); + }); + }); + + group('SmallDashNestBumper', () { + test('has points', () { + final dashNestBumper = SmallDashNestBumper(id: ''); + expect(dashNestBumper.points, greaterThan(0)); + }); + }); +} diff --git a/test/game/components/round_bumper_test.dart b/test/game/components/round_bumper_test.dart deleted file mode 100644 index 437167ad..00000000 --- a/test/game/components/round_bumper_test.dart +++ /dev/null @@ -1,102 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:flame_test/flame_test.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:pinball/game/game.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('RoundBumper', () { - final flameTester = FlameTester(Forge2DGame.new); - const radius = 1.0; - const points = 1; - - flameTester.test( - 'loads correctly', - (game) async { - await game.ready(); - final roundBumper = RoundBumper( - radius: radius, - points: points, - ); - await game.ensureAdd(roundBumper); - - expect(game.contains(roundBumper), isTrue); - }, - ); - - flameTester.test( - 'has points', - (game) async { - final roundBumper = RoundBumper( - radius: radius, - points: points, - ); - await game.ensureAdd(roundBumper); - - expect(roundBumper.points, equals(points)); - }, - ); - - group('body', () { - flameTester.test( - 'is static', - (game) async { - final roundBumper = RoundBumper( - radius: radius, - points: points, - ); - await game.ensureAdd(roundBumper); - - expect(roundBumper.body.bodyType, equals(BodyType.static)); - }, - ); - }); - - group('fixture', () { - flameTester.test( - 'exists', - (game) async { - final roundBumper = RoundBumper( - radius: radius, - points: points, - ); - await game.ensureAdd(roundBumper); - - expect(roundBumper.body.fixtures[0], isA()); - }, - ); - - flameTester.test( - 'has restitution', - (game) async { - final roundBumper = RoundBumper( - radius: radius, - points: points, - ); - await game.ensureAdd(roundBumper); - - final fixture = roundBumper.body.fixtures[0]; - expect(fixture.restitution, greaterThan(0)); - }, - ); - - flameTester.test( - 'shape is circular', - (game) async { - final roundBumper = RoundBumper( - radius: radius, - points: points, - ); - await game.ensureAdd(roundBumper); - - final fixture = roundBumper.body.fixtures[0]; - expect(fixture.shape.shapeType, equals(ShapeType.circle)); - expect(fixture.shape.radius, equals(1)); - }, - ); - }); - }); -} diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index bd9f82cf..8ddab690 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -68,3 +68,5 @@ class MockSpaceshipEntrance extends Mock implements SpaceshipEntrance {} class MockSpaceshipHole extends Mock implements SpaceshipHole {} class MockComponentSet extends Mock implements ComponentSet {} + +class MockDashNestBumper extends Mock implements DashNestBumper {} From cf92856dc1ba7ae3c34e2b3019ce83c13d0f3421 Mon Sep 17 00:00:00 2001 From: Erick Date: Thu, 24 Mar 2022 14:36:57 -0300 Subject: [PATCH 2/4] feat: implementing composable blueprints (#92) * feat: implementing composable blueprints * fix: coverage --- lib/flame/blueprint.dart | 22 +++++++++++++++++- test/flame/blueprint_test.dart | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/lib/flame/blueprint.dart b/lib/flame/blueprint.dart index d536d650..57af7d6d 100644 --- a/lib/flame/blueprint.dart +++ b/lib/flame/blueprint.dart @@ -14,6 +14,8 @@ const _attachedErrorMessage = "Can't add to attached Blueprints"; /// the [FlameGame] level. abstract class Blueprint { final List _components = []; + final List _blueprints = []; + bool _isAttached = false; /// Called before the the [Component]s managed @@ -25,7 +27,10 @@ abstract class Blueprint { @mustCallSuper Future attach(T game) async { build(game); - await game.addAll(_components); + await Future.wait([ + game.addAll(_components), + ..._blueprints.map(game.addFromBlueprint).toList(), + ]); _isAttached = true; } @@ -41,8 +46,23 @@ abstract class Blueprint { _components.add(component); } + /// Adds a list of [Blueprint]s to this blueprint. + void addAllBlueprints(List blueprints) { + assert(!_isAttached, _attachedErrorMessage); + _blueprints.addAll(blueprints); + } + + /// Adds a single [Blueprint] to this blueprint. + void addBlueprint(Blueprint blueprint) { + assert(!_isAttached, _attachedErrorMessage); + _blueprints.add(blueprint); + } + /// Returns a copy of the components built by this blueprint List get components => List.unmodifiable(_components); + + /// Returns a copy of the children blueprints + List get blueprints => List.unmodifiable(_blueprints); } /// A [Blueprint] that provides additional diff --git a/test/flame/blueprint_test.dart b/test/flame/blueprint_test.dart index 3a9f5ed3..e5fc2c4f 100644 --- a/test/flame/blueprint_test.dart +++ b/test/flame/blueprint_test.dart @@ -14,6 +14,28 @@ class MyBlueprint extends Blueprint { } } +class MyOtherBlueprint extends Blueprint { + @override + void build(_) { + add(Component()); + } +} + +class YetMyOtherBlueprint extends Blueprint { + @override + void build(_) { + add(Component()); + } +} + +class MyComposedBlueprint extends Blueprint { + @override + void build(_) { + addBlueprint(MyBlueprint()); + addAllBlueprints([MyOtherBlueprint(), YetMyOtherBlueprint()]); + } +} + class MyForge2dBlueprint extends Forge2DBlueprint { @override void build(_) { @@ -24,12 +46,23 @@ class MyForge2dBlueprint extends Forge2DBlueprint { void main() { group('Blueprint', () { + setUpAll(() { + registerFallbackValue(MyBlueprint()); + registerFallbackValue(Component()); + }); + test('components can be added to it', () { final blueprint = MyBlueprint()..build(MockPinballGame()); expect(blueprint.components.length, equals(3)); }); + test('blueprints can be added to it', () { + final blueprint = MyComposedBlueprint()..build(MockPinballGame()); + + expect(blueprint.blueprints.length, equals(3)); + }); + test('adds the components to a game on attach', () { final mockGame = MockPinballGame(); when(() => mockGame.addAll(any())).thenAnswer((_) async {}); @@ -38,6 +71,14 @@ void main() { verify(() => mockGame.addAll(any())).called(1); }); + test('adds components from a child Blueprint the to a game on attach', () { + final mockGame = MockPinballGame(); + when(() => mockGame.addAll(any())).thenAnswer((_) async {}); + MyComposedBlueprint().attach(mockGame); + + verify(() => mockGame.addAll(any())).called(4); + }); + test( 'throws assertion error when adding to an already attached blueprint', () async { From ae9e6453ce60e9e08a0c0776d4051c31fe292172 Mon Sep 17 00:00:00 2001 From: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> Date: Thu, 24 Mar 2022 12:52:29 -0500 Subject: [PATCH 3/4] feat: add launch config for sandbox (#95) * feat: launch config for sandbox * refactor: match existing naming --- .vscode/launch.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.vscode/launch.json b/.vscode/launch.json index b4e33cec..1b855b10 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -29,6 +29,12 @@ "type": "dart", "program": "lib/main_production.dart", "args": ["--flavor", "production", "--target", "lib/main_production.dart"] + }, + { + "name": "Launch component sandbox", + "request": "launch", + "type": "dart", + "program": "packages/pinball_components/sandbox/lib/main.dart" } ] } From 1f0a0c2f0472e91d5fa4e948f1c811cc2cdc2159 Mon Sep 17 00:00:00 2001 From: Rui Miguel Alonso Date: Thu, 24 Mar 2022 19:02:45 +0100 Subject: [PATCH 4/4] feat: leaderboard screen (#51) * feat: leader board page * feat: strings for leader board ui * feat: navigate to leaderboard from gameover dialog * feat: set character theme for leaderboard * test: test leaderboard page * chore: removed unused var from tests * chore: leaderboard misspelling, doc and minor fixes * chore: doc * chore: api doc * refactor: pass theme to leaderboard widgets * Update lib/leaderboard/view/leaderboard_page.dart Co-authored-by: Alejandro Santiago * chore: removed ios files * refactor: leaderboard screen now uses leaderboard_repository models * test: added tests for extensions * chore: added todo to move model * feat: added navigator helper method * feat: add Flame compatibility to test navigator helper * chore: removed unused import * test: modify test to avoid time out * chore: test method name changed * refactor: changes from pr * refactor: removed themecubit * feat: provide leaderbloc * feat: added leaderbloc to screen * chore: removed unused imports * chore: strings names * fix: fixed test with model changed * refactor: removed multirepositoryprovider * test: remove unnecessary tests * chore: unused variable * chore: unused imports Co-authored-by: Alejandro Santiago --- lib/game/view/pinball_game_page.dart | 2 +- lib/game/view/widgets/game_over_dialog.dart | 25 +- lib/l10n/arb/app_en.arb | 28 ++ lib/leaderboard/leaderboard.dart | 1 + lib/leaderboard/view/leaderboard_page.dart | 306 ++++++++++++++++++ test/app/view/app_test.dart | 3 +- test/game/components/ball_test.dart | 1 + test/game/view/pinball_game_page_test.dart | 5 +- .../view/widgets/game_over_dialog_test.dart | 44 +++ test/helpers/helpers.dart | 1 + test/helpers/mocks.dart | 5 + test/helpers/navigator.dart | 37 +++ test/helpers/pump_app.dart | 39 ++- .../view/leaderboard_page_test.dart | 150 +++++++++ 14 files changed, 620 insertions(+), 27 deletions(-) create mode 100644 lib/leaderboard/view/leaderboard_page.dart create mode 100644 test/game/view/widgets/game_over_dialog_test.dart create mode 100644 test/helpers/navigator.dart create mode 100644 test/leaderboard/view/leaderboard_page_test.dart diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index 21bd4074..579d830b 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -70,7 +70,7 @@ class _PinballGameViewState extends State { showDialog( context: context, builder: (_) { - return const GameOverDialog(); + return GameOverDialog(theme: widget.theme.characterTheme); }, ); } diff --git a/lib/game/view/widgets/game_over_dialog.dart b/lib/game/view/widgets/game_over_dialog.dart index 9d1c61b0..29164a62 100644 --- a/lib/game/view/widgets/game_over_dialog.dart +++ b/lib/game/view/widgets/game_over_dialog.dart @@ -1,21 +1,40 @@ import 'package:flutter/material.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball/leaderboard/leaderboard.dart'; +import 'package:pinball_theme/pinball_theme.dart'; /// {@template game_over_dialog} /// [Dialog] displayed when the [PinballGame] is over. /// {@endtemplate} class GameOverDialog extends StatelessWidget { /// {@macro game_over_dialog} - const GameOverDialog({Key? key}) : super(key: key); + const GameOverDialog({Key? key, required this.theme}) : super(key: key); + + /// Current [CharacterTheme] to customize dialog + final CharacterTheme theme; @override Widget build(BuildContext context) { - return const Dialog( + final l10n = context.l10n; + + return Dialog( child: SizedBox( width: 200, height: 200, child: Center( - child: Text('Game Over'), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(l10n.gameOver), + TextButton( + onPressed: () => Navigator.of(context).push( + LeaderboardPage.route(theme: theme), + ), + child: Text(l10n.leaderboard), + ), + ], + ), ), ), ); diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index a118501e..235c8f2e 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -23,5 +23,33 @@ "characterSelectionTitle": "Choose your character!", "@characterSelectionTitle": { "description": "Title text displayed on the character selection page" + }, + "gameOver": "Game Over", + "@gameOver": { + "description": "Text displayed on the ending dialog when game finishes" + }, + "leaderboard": "Leaderboard", + "@leaderboard": { + "description": "Text displayed on the ending dialog leaderboard button" + }, + "rank": "Rank", + "@rank": { + "description": "Text displayed on the leaderboard page header rank column" + }, + "character": "Character", + "@character": { + "description": "Text displayed on the leaderboard page header character column" + }, + "username": "Username", + "@username": { + "description": "Text displayed on the leaderboard page header userName column" + }, + "score": "Score", + "@score": { + "description": "Text displayed on the leaderboard page header score column" + }, + "retry": "Retry", + "@retry": { + "description": "Text displayed on the retry button leaders board page" } } \ No newline at end of file diff --git a/lib/leaderboard/leaderboard.dart b/lib/leaderboard/leaderboard.dart index 156b7f78..08765743 100644 --- a/lib/leaderboard/leaderboard.dart +++ b/lib/leaderboard/leaderboard.dart @@ -1,2 +1,3 @@ export 'bloc/leaderboard_bloc.dart'; export 'models/leader_board_entry.dart'; +export 'view/leaderboard_page.dart'; diff --git a/lib/leaderboard/view/leaderboard_page.dart b/lib/leaderboard/view/leaderboard_page.dart new file mode 100644 index 00000000..54b364e9 --- /dev/null +++ b/lib/leaderboard/view/leaderboard_page.dart @@ -0,0 +1,306 @@ +// ignore_for_file: public_member_api_docs + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball/leaderboard/leaderboard.dart'; +import 'package:pinball/theme/theme.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +class LeaderboardPage extends StatelessWidget { + const LeaderboardPage({Key? key, required this.theme}) : super(key: key); + + final CharacterTheme theme; + + static Route route({required CharacterTheme theme}) { + return MaterialPageRoute( + builder: (_) => LeaderboardPage(theme: theme), + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => LeaderboardBloc( + context.read(), + )..add(const Top10Fetched()), + child: LeaderboardView(theme: theme), + ); + } +} + +class LeaderboardView extends StatelessWidget { + const LeaderboardView({Key? key, required this.theme}) : super(key: key); + + final CharacterTheme theme; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Scaffold( + body: Center( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 80), + Text( + l10n.leaderboard, + style: Theme.of(context).textTheme.headline3, + ), + const SizedBox(height: 80), + BlocBuilder( + builder: (context, state) { + switch (state.status) { + case LeaderboardStatus.loading: + return _LeaderboardLoading(theme: theme); + case LeaderboardStatus.success: + return _LeaderboardRanking( + ranking: state.leaderboard, + theme: theme, + ); + case LeaderboardStatus.error: + return _LeaderboardError(theme: theme); + } + }, + ), + const SizedBox(height: 20), + TextButton( + onPressed: () => Navigator.of(context).push( + CharacterSelectionPage.route(), + ), + child: Text(l10n.retry), + ), + ], + ), + ), + ), + ); + } +} + +class _LeaderboardLoading extends StatelessWidget { + const _LeaderboardLoading({Key? key, required this.theme}) : super(key: key); + + final CharacterTheme theme; + + @override + Widget build(BuildContext context) { + return const Center( + child: CircularProgressIndicator(), + ); + } +} + +class _LeaderboardError extends StatelessWidget { + const _LeaderboardError({Key? key, required this.theme}) : super(key: key); + + final CharacterTheme theme; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(20), + child: Text( + 'There was en error loading data!', + style: + Theme.of(context).textTheme.headline6?.copyWith(color: Colors.red), + ), + ); + } +} + +class _LeaderboardRanking extends StatelessWidget { + const _LeaderboardRanking({ + Key? key, + required this.ranking, + required this.theme, + }) : super(key: key); + + final List ranking; + final CharacterTheme theme; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _LeaderboardHeaders(theme: theme), + _LeaderboardList( + ranking: ranking, + theme: theme, + ), + ], + ), + ); + } +} + +class _LeaderboardHeaders extends StatelessWidget { + const _LeaderboardHeaders({Key? key, required this.theme}) : super(key: key); + + final CharacterTheme theme; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _LeaderboardHeaderItem(title: l10n.rank, theme: theme), + _LeaderboardHeaderItem(title: l10n.character, theme: theme), + _LeaderboardHeaderItem(title: l10n.username, theme: theme), + _LeaderboardHeaderItem(title: l10n.score, theme: theme), + ], + ); + } +} + +class _LeaderboardHeaderItem extends StatelessWidget { + const _LeaderboardHeaderItem({ + Key? key, + required this.title, + required this.theme, + }) : super(key: key); + + final CharacterTheme theme; + final String title; + + @override + Widget build(BuildContext context) { + return Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.ballColor, + ), + child: Text( + title, + style: Theme.of(context).textTheme.headline5, + ), + ), + ); + } +} + +class _LeaderboardList extends StatelessWidget { + const _LeaderboardList({ + Key? key, + required this.ranking, + required this.theme, + }) : super(key: key); + + final List ranking; + final CharacterTheme theme; + + @override + Widget build(BuildContext context) { + return ListView.builder( + shrinkWrap: true, + itemBuilder: (_, index) => _LeaderBoardCompetitor( + entry: ranking[index], + theme: theme, + ), + itemCount: ranking.length, + ); + } +} + +class _LeaderBoardCompetitor extends StatelessWidget { + const _LeaderBoardCompetitor({ + Key? key, + required this.entry, + required this.theme, + }) : super(key: key); + + final CharacterTheme theme; + + final LeaderboardEntry entry; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _LeaderboardCompetitorField( + text: entry.rank, + theme: theme, + ), + _LeaderboardCompetitorCharacter( + characterAsset: entry.character, + theme: theme, + ), + _LeaderboardCompetitorField( + text: entry.playerInitials, + theme: theme, + ), + _LeaderboardCompetitorField( + text: entry.score.toString(), + theme: theme, + ), + ], + ); + } +} + +class _LeaderboardCompetitorField extends StatelessWidget { + const _LeaderboardCompetitorField({ + Key? key, + required this.text, + required this.theme, + }) : super(key: key); + + final CharacterTheme theme; + final String text; + + @override + Widget build(BuildContext context) { + return Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: theme.ballColor, + width: 2, + ), + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Text(text), + ), + ), + ); + } +} + +class _LeaderboardCompetitorCharacter extends StatelessWidget { + const _LeaderboardCompetitorCharacter({ + Key? key, + required this.characterAsset, + required this.theme, + }) : super(key: key); + + final CharacterTheme theme; + final AssetGenImage characterAsset; + + @override + Widget build(BuildContext context) { + return Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: theme.ballColor, + width: 2, + ), + ), + child: SizedBox( + height: 30, + child: characterAsset.image(), + ), + ), + ); + } +} diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart index 5a6a249f..f8415a58 100644 --- a/test/app/view/app_test.dart +++ b/test/app/view/app_test.dart @@ -7,11 +7,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart'; -import 'package:mocktail/mocktail.dart'; import 'package:pinball/app/app.dart'; import 'package:pinball/landing/landing.dart'; -class MockLeaderboardRepository extends Mock implements LeaderboardRepository {} +import '../../helpers/mocks.dart'; void main() { group('App', () { diff --git a/test/game/components/ball_test.dart b/test/game/components/ball_test.dart index 6419eef2..a872dc1f 100644 --- a/test/game/components/ball_test.dart +++ b/test/game/components/ball_test.dart @@ -4,6 +4,7 @@ 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_components/pinball_components.dart'; import '../../helpers/helpers.dart'; diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index 5298d6ac..f16b8ef1 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -104,10 +104,7 @@ void main() { ); await tester.pump(); - expect( - find.text('Game Over'), - findsOneWidget, - ); + expect(find.byType(GameOverDialog), findsOneWidget); }, ); diff --git a/test/game/view/widgets/game_over_dialog_test.dart b/test/game/view/widgets/game_over_dialog_test.dart new file mode 100644 index 00000000..8150bcd5 --- /dev/null +++ b/test/game/view/widgets/game_over_dialog_test.dart @@ -0,0 +1,44 @@ +// 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/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + group('GameOverDialog', () { + testWidgets('renders correctly', (tester) async { + final l10n = await AppLocalizations.delegate.load(Locale('en')); + await tester.pumpApp( + const GameOverDialog( + theme: DashTheme(), + ), + ); + + expect(find.text(l10n.gameOver), findsOneWidget); + expect(find.text(l10n.leaderboard), findsOneWidget); + }); + + testWidgets('tapping on leaderboard button navigates to LeaderBoardPage', + (tester) async { + final l10n = await AppLocalizations.delegate.load(Locale('en')); + final navigator = MockNavigator(); + when(() => navigator.push(any())).thenAnswer((_) async {}); + + await tester.pumpApp( + const GameOverDialog( + theme: DashTheme(), + ), + navigator: navigator, + ); + + await tester.tap(find.widgetWithText(TextButton, l10n.leaderboard)); + + verify(() => navigator.push(any())).called(1); + }); + }); +} diff --git a/test/helpers/helpers.dart b/test/helpers/helpers.dart index 88b9c04d..223ec627 100644 --- a/test/helpers/helpers.dart +++ b/test/helpers/helpers.dart @@ -8,4 +8,5 @@ export 'builders.dart'; export 'extensions.dart'; export 'key_testers.dart'; export 'mocks.dart'; +export 'navigator.dart'; export 'pump_app.dart'; diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 8ddab690..c658c531 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -1,3 +1,4 @@ +import 'package:bloc_test/bloc_test.dart'; import 'package:flame/components.dart'; import 'package:flame/input.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; @@ -6,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball/leaderboard/leaderboard.dart'; import 'package:pinball/theme/theme.dart'; import 'package:pinball_components/pinball_components.dart'; @@ -37,6 +39,9 @@ class MockGameState extends Mock implements GameState {} class MockThemeCubit extends Mock implements ThemeCubit {} +class MockLeaderboardBloc extends MockBloc + implements LeaderboardBloc {} + class MockLeaderboardRepository extends Mock implements LeaderboardRepository {} class MockRawKeyDownEvent extends Mock implements RawKeyDownEvent { diff --git a/test/helpers/navigator.dart b/test/helpers/navigator.dart new file mode 100644 index 00000000..5a8ea52e --- /dev/null +++ b/test/helpers/navigator.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'helpers.dart'; + +Future expectNavigatesToRoute( + WidgetTester tester, + Route route, { + bool hasFlameGameInside = false, +}) async { + // ignore: avoid_dynamic_calls + await tester.pumpApp( + Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + Navigator.of(context).push(route); + }, + child: const Text('Tap me'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Tap me')); + if (hasFlameGameInside) { + // We can't use pumpAndSettle here because the page renders a Flame game + // which is an infinity animation, so it will timeout + await tester.pump(); // Runs the button action + await tester.pump(); // Runs the navigation + } else { + await tester.pumpAndSettle(); + } + + expect(find.byType(Type), findsOneWidget); +} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index e0b953d2..d5e819b4 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:mockingjay/mockingjay.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; @@ -22,26 +23,30 @@ extension PumpApp on WidgetTester { MockNavigator? navigator, GameBloc? gameBloc, ThemeCubit? themeCubit, + LeaderboardRepository? leaderboardRepository, }) { return pumpWidget( - MultiBlocProvider( - providers: [ - BlocProvider.value( - value: themeCubit ?? MockThemeCubit(), - ), - BlocProvider.value( - value: gameBloc ?? MockGameBloc(), - ), - ], - child: MaterialApp( - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, + RepositoryProvider.value( + value: leaderboardRepository ?? MockLeaderboardRepository(), + child: MultiBlocProvider( + providers: [ + BlocProvider.value( + value: themeCubit ?? MockThemeCubit(), + ), + BlocProvider.value( + value: gameBloc ?? MockGameBloc(), + ), ], - supportedLocales: AppLocalizations.supportedLocales, - home: navigator != null - ? MockNavigatorProvider(navigator: navigator, child: widget) - : widget, + child: MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: navigator != null + ? MockNavigatorProvider(navigator: navigator, child: widget) + : widget, + ), ), ), ); diff --git a/test/leaderboard/view/leaderboard_page_test.dart b/test/leaderboard/view/leaderboard_page_test.dart new file mode 100644 index 00000000..9460818d --- /dev/null +++ b/test/leaderboard/view/leaderboard_page_test.dart @@ -0,0 +1,150 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:mockingjay/mockingjay.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball/leaderboard/leaderboard.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('LeaderboardPage', () { + testWidgets('renders LeaderboardView', (tester) async { + await tester.pumpApp( + LeaderboardPage( + theme: DashTheme(), + ), + ); + + expect(find.byType(LeaderboardView), findsOneWidget); + }); + + testWidgets('route returns a valid navigation route', (tester) async { + await expectNavigatesToRoute( + tester, + LeaderboardPage.route( + theme: DashTheme(), + ), + ); + }); + }); + + group('LeaderboardView', () { + late LeaderboardBloc leaderboardBloc; + + setUp(() { + leaderboardBloc = MockLeaderboardBloc(); + }); + + testWidgets('renders correctly', (tester) async { + final l10n = await AppLocalizations.delegate.load(Locale('en')); + when(() => leaderboardBloc.state).thenReturn(LeaderboardState.initial()); + + await tester.pumpApp( + BlocProvider.value( + value: leaderboardBloc, + child: LeaderboardView( + theme: DashTheme(), + ), + ), + ); + + expect(find.text(l10n.leaderboard), findsOneWidget); + expect(find.text(l10n.retry), findsOneWidget); + }); + + testWidgets('renders loading view when bloc emits [loading]', + (tester) async { + when(() => leaderboardBloc.state).thenReturn(LeaderboardState.initial()); + + await tester.pumpApp( + BlocProvider.value( + value: leaderboardBloc, + child: LeaderboardView( + theme: DashTheme(), + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('There was en error loading data!'), findsNothing); + expect(find.byType(ListView), findsNothing); + }); + + testWidgets('renders error view when bloc emits [error]', (tester) async { + when(() => leaderboardBloc.state).thenReturn( + LeaderboardState.initial().copyWith(status: LeaderboardStatus.error), + ); + + await tester.pumpApp( + BlocProvider.value( + value: leaderboardBloc, + child: LeaderboardView( + theme: DashTheme(), + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('There was en error loading data!'), findsOneWidget); + expect(find.byType(ListView), findsNothing); + }); + + testWidgets('renders success view when bloc emits [success]', + (tester) async { + final l10n = await AppLocalizations.delegate.load(Locale('en')); + when(() => leaderboardBloc.state).thenReturn( + LeaderboardState( + status: LeaderboardStatus.success, + ranking: LeaderboardRanking(ranking: 0, outOf: 0), + leaderboard: [ + LeaderboardEntry( + rank: '1', + playerInitials: 'ABC', + score: 10000, + character: DashTheme().characterAsset, + ), + ], + ), + ); + + await tester.pumpApp( + BlocProvider.value( + value: leaderboardBloc, + child: LeaderboardView( + theme: DashTheme(), + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('There was en error loading data!'), findsNothing); + expect(find.text(l10n.rank), findsOneWidget); + expect(find.text(l10n.character), findsOneWidget); + expect(find.text(l10n.username), findsOneWidget); + expect(find.text(l10n.score), findsOneWidget); + expect(find.byType(ListView), findsOneWidget); + }); + + testWidgets('navigates to CharacterSelectionPage when retry is tapped', + (tester) async { + final navigator = MockNavigator(); + when(() => navigator.push(any())).thenAnswer((_) async {}); + + await tester.pumpApp( + LeaderboardPage( + theme: DashTheme(), + ), + navigator: navigator, + ); + await tester.ensureVisible(find.byType(TextButton)); + await tester.tap(find.byType(TextButton)); + + verify(() => navigator.push(any())).called(1); + }); + }); +}