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" } ] } diff --git a/assets/images/components/sauce.png b/assets/images/components/sauce.png deleted file mode 100644 index 743a920a..00000000 Binary files a/assets/images/components/sauce.png and /dev/null differ 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/lib/game/components/baseboard.dart b/lib/game/components/baseboard.dart index 60d0ebe7..d9dc3512 100644 --- a/lib/game/components/baseboard.dart +++ b/lib/game/components/baseboard.dart @@ -5,7 +5,7 @@ import 'package:pinball/game/game.dart'; import 'package:pinball_components/pinball_components.dart'; /// {@template baseboard} -/// Straight, angled board piece to corral the [Ball] towards the [Flipper]s. +/// Wing-shaped board piece to corral the [Ball] towards the [Flipper]s. /// {@endtemplate} class Baseboard extends BodyComponent with InitialPosition { /// {@macro baseboard} @@ -13,41 +13,68 @@ class Baseboard extends BodyComponent with InitialPosition { required BoardSide side, }) : _side = side; - /// The width of the [Baseboard]. - static const width = 10.0; - - /// The height of the [Baseboard]. - static const height = 2.0; + /// The size of the [Baseboard]. + static final size = Vector2(24.2, 13.5); /// Whether the [Baseboard] is on the left or right side of the board. final BoardSide _side; List _createFixtureDefs() { final fixturesDef = []; + final direction = _side.direction; + final arcsAngle = -1.11 * direction; + const arcsRotation = math.pi / 2.08; + + final topCircleShape = CircleShape()..radius = 0.7; + topCircleShape.position.setValues(11.39 * direction, 6.05); + final topCircleFixtureDef = FixtureDef(topCircleShape); + fixturesDef.add(topCircleFixtureDef); + + final innerEdgeShape = EdgeShape() + ..set( + Vector2(10.86 * direction, 6.45), + Vector2(6.96 * direction, 0.25), + ); + final innerEdgeShapeFixtureDef = FixtureDef(innerEdgeShape); + fixturesDef.add(innerEdgeShapeFixtureDef); + + final outerEdgeShape = EdgeShape() + ..set( + Vector2(11.96 * direction, 5.85), + Vector2(5.48 * direction, -4.85), + ); + final outerEdgeShapeFixtureDef = FixtureDef(outerEdgeShape); + fixturesDef.add(outerEdgeShapeFixtureDef); + + final upperArcFixtureDefs = Pathway.arc( + center: Vector2(1.76 * direction, 3.25), + width: 0, + radius: 6.1, + angle: arcsAngle, + rotation: arcsRotation, + singleWall: true, + ).createFixtureDefs(); + fixturesDef.addAll(upperArcFixtureDefs); + + final lowerArcFixtureDefs = Pathway.arc( + center: Vector2(1.85 * direction, -2.15), + width: 0, + radius: 4.5, + angle: arcsAngle, + rotation: arcsRotation, + singleWall: true, + ).createFixtureDefs(); + fixturesDef.addAll(lowerArcFixtureDefs); - final circleShape1 = CircleShape()..radius = Baseboard.height / 2; - circleShape1.position.setValues( - -(Baseboard.width / 2) + circleShape1.radius, - 0, - ); - final circle1FixtureDef = FixtureDef(circleShape1); - fixturesDef.add(circle1FixtureDef); - - final circleShape2 = CircleShape()..radius = Baseboard.height / 2; - circleShape2.position.setValues( - (Baseboard.width / 2) - circleShape2.radius, - 0, - ); - final circle2FixtureDef = FixtureDef(circleShape2); - fixturesDef.add(circle2FixtureDef); - - final rectangle = PolygonShape() - ..setAsBoxXY( - (Baseboard.width - Baseboard.height) / 2, - Baseboard.height / 2, + final bottomRectangle = PolygonShape() + ..setAsBox( + 7, + 2, + Vector2(-5.14 * direction, -4.75), + 0, ); - final rectangleFixtureDef = FixtureDef(rectangle); - fixturesDef.add(rectangleFixtureDef); + final bottomRectangleFixtureDef = FixtureDef(bottomRectangle); + fixturesDef.add(bottomRectangleFixtureDef); return fixturesDef; } @@ -56,7 +83,7 @@ class Baseboard extends BodyComponent with InitialPosition { Body createBody() { // TODO(allisonryan0002): share sweeping angle with flipper when components // are grouped. - const angle = math.pi / 7; + const angle = math.pi / 5; final bodyDef = BodyDef() ..position = initialPosition diff --git a/lib/game/components/board.dart b/lib/game/components/board.dart index 6e895d6e..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, ]); } } @@ -134,8 +95,8 @@ class _BottomGroupSide extends Component { final baseboard = Baseboard(side: _side) ..initialPosition = _position + Vector2( - (Flipper.size.x * direction) - direction, - Flipper.size.y, + (Baseboard.size.x / 1.6 * direction), + Baseboard.size.y - 2, ); final kicker = Kicker( side: _side, diff --git a/lib/game/components/bonus_word.dart b/lib/game/components/bonus_word.dart index d97a9bd1..e7f1626a 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 @@ -77,18 +73,32 @@ class BonusWord extends Component with BlocComponent { @override Future onLoad() async { await super.onLoad(); - final letters = GameBloc.bonusWord.split(''); - - for (var i = 0; i < letters.length; i++) { - unawaited( - add( - BonusLetter( - letter: letters[i], - index: i, - )..initialPosition = _position - Vector2(16 - (i * 6), -30), - ), + + final offsets = [ + Vector2(-12.92, -1.82), + Vector2(-8.33, 0.65), + Vector2(-2.88, 1.75), + ]; + offsets.addAll( + offsets.reversed + .map( + (offset) => Vector2(-offset.x, offset.y), + ) + .toList(), + ); + assert(offsets.length == GameBloc.bonusWord.length, 'Invalid positions'); + + final letters = []; + for (var i = 0; i < GameBloc.bonusWord.length; i++) { + letters.add( + BonusLetter( + letter: GameBloc.bonusWord[i], + index: i, + )..initialPosition = _position + offsets[i], ); } + + await addAll(letters); } } @@ -107,8 +117,8 @@ class BonusLetter extends BodyComponent paint = Paint()..color = _disableColor; } - /// The area size of this [BonusLetter]. - static final areaSize = Vector2.all(4); + /// The size of the [BonusLetter]. + static final size = Vector2.all(3.7); static const _activeColor = Colors.green; static const _disableColor = Colors.red; @@ -140,7 +150,7 @@ class BonusLetter extends BodyComponent @override Body createBody() { - final shape = CircleShape()..radius = areaSize.x / 2; + final shape = CircleShape()..radius = size.x / 2; final fixtureDef = FixtureDef(shape)..isSensor = true; 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/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index 681a6431..39cecb61 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -74,8 +74,8 @@ class PinballGame extends Forge2DGame await add( BonusWord( position: Vector2( - boardBounds.center.dx, - boardBounds.bottom + 10, + boardBounds.center.dx - 3.07, + boardBounds.center.dy - 2.4, ), ), ); 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/gen/assets.gen.dart b/lib/gen/assets.gen.dart index ba75412b..6e81fe77 100644 --- a/lib/gen/assets.gen.dart +++ b/lib/gen/assets.gen.dart @@ -17,8 +17,6 @@ class $AssetsImagesComponentsGen { AssetGenImage get flipper => const AssetGenImage('assets/images/components/flipper.png'); - AssetGenImage get sauce => - const AssetGenImage('assets/images/components/sauce.png'); $AssetsImagesComponentsSpaceshipGen get spaceship => const $AssetsImagesComponentsSpaceshipGen(); } 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/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 { 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/components/baseboard_test.dart b/test/game/components/baseboard_test.dart index 75cc62cc..f834a41e 100644 --- a/test/game/components/baseboard_test.dart +++ b/test/game/components/baseboard_test.dart @@ -61,14 +61,14 @@ void main() { group('fixtures', () { flameTester.test( - 'has three', + 'has six', (game) async { final baseboard = Baseboard( side: BoardSide.left, ); await game.ensureAdd(baseboard); - expect(baseboard.body.fixtures.length, equals(3)); + expect(baseboard.body.fixtures.length, equals(6)); }, ); }); 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..17b702dc 100644 --- a/test/game/components/bonus_word_test.dart +++ b/test/game/components/bonus_word_test.dart @@ -187,13 +187,14 @@ void main() { final fixture = bonusLetter.body.fixtures[0]; expect(fixture.shape.shapeType, equals(ShapeType.circle)); - expect(fixture.shape.radius, equals(2)); + expect(fixture.shape.radius, equals(1.85)); }, ); }); 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/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 bd9f82cf..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 { @@ -68,3 +73,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 {} 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); + }); + }); +}