From db73590115de94095e0121db0921130cd9ad1009 Mon Sep 17 00:00:00 2001 From: Erick Zanardo Date: Thu, 5 May 2022 18:01:18 -0300 Subject: [PATCH] feat: adding leaderboard display --- lib/game/components/backbox/backbox.dart | 5 + .../components/backbox/bloc/backbox_bloc.dart | 17 +++ .../backbox/bloc/backbox_event.dart | 6 + .../backbox/bloc/backbox_state.dart | 10 +- .../components/backbox/displays/displays.dart | 1 + .../backbox/displays/leaderboard_display.dart | 119 ++++++++++++++++++ .../game/components/backbox/backbox_test.dart | 40 +++++- .../backbox/bloc/backbox_bloc_test.dart | 36 ++++++ .../backbox/bloc/backbox_event_test.dart | 10 ++ .../backbox/bloc/backbox_state_test.dart | 24 +++- .../displays/leaderboard_display_test.dart | 98 +++++++++++++++ 11 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 lib/game/components/backbox/displays/leaderboard_display.dart create mode 100644 test/game/components/backbox/displays/leaderboard_display_test.dart diff --git a/lib/game/components/backbox/backbox.dart b/lib/game/components/backbox/backbox.dart index 30b2a1aa..3dd97311 100644 --- a/lib/game/components/backbox/backbox.dart +++ b/lib/game/components/backbox/backbox.dart @@ -35,8 +35,11 @@ class Backbox extends PositionComponent with HasGameRef, ZIndex { anchor = Anchor.bottomCenter; zIndex = ZIndexes.backbox; + _bloc.add(LeaderboardRequested()); + await add(_BackboxSpriteComponent()); await add(_display = Component()); + _build(_bloc.state); _subscription = _bloc.stream.listen((state) { _display.children.removeWhere((_) => true); @@ -53,6 +56,8 @@ class Backbox extends PositionComponent with HasGameRef, ZIndex { void _build(BackboxState state) { if (state is LoadingState) { _display.add(LoadingDisplay()); + } else if (state is LeaderboardSuccessState) { + _display.add(LeaderboardDisplay(entries: state.entries)); } else if (state is InitialsFormState) { _display.add( InitialsInputDisplay( diff --git a/lib/game/components/backbox/bloc/backbox_bloc.dart b/lib/game/components/backbox/bloc/backbox_bloc.dart index f315189e..b3952a0c 100644 --- a/lib/game/components/backbox/bloc/backbox_bloc.dart +++ b/lib/game/components/backbox/bloc/backbox_bloc.dart @@ -18,6 +18,7 @@ class BackboxBloc extends Bloc { super(LoadingState()) { on(_onPlayerInitialsRequested); on(_onPlayerInitialsSubmitted); + on(_onLeaderboardRequested); } final LeaderboardRepository _leaderboardRepository; @@ -53,4 +54,20 @@ class BackboxBloc extends Bloc { emit(InitialsFailureState()); } } + + Future _onLeaderboardRequested( + LeaderboardRequested event, + Emitter emit, + ) async { + try { + emit(LoadingState()); + + final entries = await _leaderboardRepository.fetchTop10Leaderboard(); + + emit(LeaderboardSuccessState(entries: entries)); + } catch (error, stackTrace) { + addError(error, stackTrace); + emit(LeaderboardFailureState()); + } + } } diff --git a/lib/game/components/backbox/bloc/backbox_event.dart b/lib/game/components/backbox/bloc/backbox_event.dart index 42203cdc..40ad4bfb 100644 --- a/lib/game/components/backbox/bloc/backbox_event.dart +++ b/lib/game/components/backbox/bloc/backbox_event.dart @@ -51,3 +51,9 @@ class PlayerInitialsSubmitted extends BackboxEvent { @override List get props => [score, initials, character]; } + +/// Event that triggers the fetching of the leaderboard +class LeaderboardRequested extends BackboxEvent { + @override + List get props => []; +} diff --git a/lib/game/components/backbox/bloc/backbox_state.dart b/lib/game/components/backbox/bloc/backbox_state.dart index e1f2c801..482bb298 100644 --- a/lib/game/components/backbox/bloc/backbox_state.dart +++ b/lib/game/components/backbox/bloc/backbox_state.dart @@ -14,10 +14,18 @@ class LoadingState extends BackboxState { List get props => []; } +/// {@template leaderboard_success_state} /// State when the leaderboard was successfully loaded. +/// {@endtemplate} class LeaderboardSuccessState extends BackboxState { + /// {@macro leaderboard_success_state} + const LeaderboardSuccessState({required this.entries}); + + /// Current entries + final List entries; + @override - List get props => []; + List get props => [entries]; } /// State when the leaderboard failed to load. diff --git a/lib/game/components/backbox/displays/displays.dart b/lib/game/components/backbox/displays/displays.dart index a516587d..4dda7048 100644 --- a/lib/game/components/backbox/displays/displays.dart +++ b/lib/game/components/backbox/displays/displays.dart @@ -1,4 +1,5 @@ export 'initials_input_display.dart'; export 'initials_submission_failure_display.dart'; export 'initials_submission_success_display.dart'; +export 'leaderboard_display.dart'; export 'loading_display.dart'; diff --git a/lib/game/components/backbox/displays/leaderboard_display.dart b/lib/game/components/backbox/displays/leaderboard_display.dart new file mode 100644 index 00000000..f756becb --- /dev/null +++ b/lib/game/components/backbox/displays/leaderboard_display.dart @@ -0,0 +1,119 @@ +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:pinball/game/pinball_game.dart'; +import 'package:pinball/leaderboard/models/leader_board_entry.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_ui/pinball_ui.dart'; + +final _title = TextPaint( + style: const TextStyle( + fontSize: 2, + color: PinballColors.red, + fontFamily: PinballFonts.pixeloidSans, + ), +); + +final _bodyTextPaint = TextPaint( + style: const TextStyle( + fontSize: 1.8, + color: PinballColors.white, + fontFamily: PinballFonts.pixeloidSans, + ), +); + +/// {@template leaderboard_display} +/// Component that builds the leaderboard list of the Backbox. +/// {@endtemplate} +class LeaderboardDisplay extends PositionComponent + with HasGameRef { + /// {@macro leaderboard_display} + LeaderboardDisplay({required List entries}) + : _entries = entries; + + final List _entries; + + double _calcY(int i) => (i * 3.2) + 3.2; + + static const _columns = [-15.0, 0.0, 15.0]; + + String _rank(int number) { + switch (number) { + case 1: + return '${number}st'; + case 2: + return '${number}nd'; + case 3: + return '${number}rd'; + default: + return '${number}th'; + } + } + + @override + Future onLoad() async { + position = Vector2(0, -30); + + final ranking = _entries.take(5).toList(); + await add( + PositionComponent( + position: Vector2(0, 4), + children: [ + PositionComponent( + children: [ + TextComponent( + text: gameRef.l10n.rank, + textRenderer: _title, + position: Vector2(_columns[0], 0), + anchor: Anchor.center, + ), + TextComponent( + text: gameRef.l10n.score, + textRenderer: _title, + position: Vector2(_columns[1], 0), + anchor: Anchor.center, + ), + TextComponent( + text: gameRef.l10n.name, + textRenderer: _title, + position: Vector2(_columns[2], 0), + anchor: Anchor.center, + ), + ], + ), + for (var i = 0; i < ranking.length; i++) + PositionComponent( + children: [ + TextComponent( + text: _rank(i + 1), + textRenderer: _bodyTextPaint, + position: Vector2(_columns[0], _calcY(i)), + anchor: Anchor.center, + ), + TextComponent( + text: ranking[i].score.formatScore(), + textRenderer: _bodyTextPaint, + position: Vector2(_columns[1], _calcY(i)), + anchor: Anchor.center, + ), + SpriteComponent.fromImage( + gameRef.images.fromCache( + ranking[i].character.toTheme.leaderboardIcon.keyName, + ), + anchor: Anchor.center, + size: Vector2(1.8, 1.8), + position: Vector2(_columns[2] - 2.5, _calcY(i) + .25), + ), + TextComponent( + text: ranking[i].playerInitials, + textRenderer: _bodyTextPaint, + position: Vector2(_columns[2] + 1, _calcY(i)), + anchor: Anchor.center, + ), + ], + ), + ], + ), + ); + } +} diff --git a/test/game/components/backbox/backbox_test.dart b/test/game/components/backbox/backbox_test.dart index 33d43aa8..d58703ca 100644 --- a/test/game/components/backbox/backbox_test.dart +++ b/test/game/components/backbox/backbox_test.dart @@ -44,6 +44,9 @@ class _MockAppLocalizations extends Mock implements AppLocalizations { @override String get name => ''; + @override + String get rank => ''; + @override String get enterInitials => ''; @@ -84,7 +87,7 @@ void main() { bloc = _MockBackboxBloc(); whenListen( bloc, - Stream.value(LoadingState()), + Stream.empty(), initialState: LoadingState(), ); }); @@ -100,6 +103,16 @@ void main() { }, ); + flameTester.test( + 'adds LeaderboardRequested when loaded', + (game) async { + final backbox = Backbox.test(bloc: bloc); + await game.ensureAdd(backbox); + + verify(() => bloc.add(LeaderboardRequested())).called(1); + }, + ); + flameTester.testGameWidget( 'renders correctly', setUp: (game, tester) async { @@ -152,7 +165,7 @@ void main() { ); whenListen( bloc, - Stream.value(state), + Stream.empty(), initialState: state, ); final backbox = Backbox.test(bloc: bloc); @@ -176,7 +189,7 @@ void main() { (game) async { whenListen( bloc, - Stream.value(InitialsSuccessState()), + Stream.empty(), initialState: InitialsSuccessState(), ); final backbox = Backbox.test(bloc: bloc); @@ -197,7 +210,7 @@ void main() { (game) async { whenListen( bloc, - Stream.value(InitialsFailureState()), + Stream.empty(), initialState: InitialsFailureState(), ); final backbox = Backbox.test(bloc: bloc); @@ -213,6 +226,25 @@ void main() { }, ); + flameTester.test( + 'adds LeaderboardDisplay on LeaderboardSuccessState', + (game) async { + whenListen( + bloc, + Stream.empty(), + initialState: LeaderboardSuccessState(entries: const []), + ); + + final backbox = Backbox.test(bloc: bloc); + await game.ensureAdd(backbox); + + expect( + game.descendants().whereType().length, + equals(1), + ); + }, + ); + flameTester.test( 'closes the subscription when it is removed', (game) async { diff --git a/test/game/components/backbox/bloc/backbox_bloc_test.dart b/test/game/components/backbox/bloc/backbox_bloc_test.dart index c2fbc088..3958adb5 100644 --- a/test/game/components/backbox/bloc/backbox_bloc_test.dart +++ b/test/game/components/backbox/bloc/backbox_bloc_test.dart @@ -88,5 +88,41 @@ void main() { ], ); }); + + group('LeaderboardRequested', () { + blocTest( + 'adds [LoadingState, LeaderboardSuccessState] when request succeeds', + setUp: () { + leaderboardRepository = _MockLeaderboardRepository(); + when( + () => leaderboardRepository.fetchTop10Leaderboard(), + ).thenAnswer( + (_) async => [LeaderboardEntryData.empty], + ); + }, + build: () => BackboxBloc(leaderboardRepository: leaderboardRepository), + act: (bloc) => bloc.add(LeaderboardRequested()), + expect: () => [ + LoadingState(), + LeaderboardSuccessState(entries: const [LeaderboardEntryData.empty]), + ], + ); + + blocTest( + 'adds [LoadingState, LeaderboardFailureState] when request fails', + setUp: () { + leaderboardRepository = _MockLeaderboardRepository(); + when( + () => leaderboardRepository.fetchTop10Leaderboard(), + ).thenThrow(Exception('Error')); + }, + build: () => BackboxBloc(leaderboardRepository: leaderboardRepository), + act: (bloc) => bloc.add(LeaderboardRequested()), + expect: () => [ + LoadingState(), + LeaderboardFailureState(), + ], + ); + }); }); } diff --git a/test/game/components/backbox/bloc/backbox_event_test.dart b/test/game/components/backbox/bloc/backbox_event_test.dart index 5fc766a9..80f7fbb1 100644 --- a/test/game/components/backbox/bloc/backbox_event_test.dart +++ b/test/game/components/backbox/bloc/backbox_event_test.dart @@ -122,5 +122,15 @@ void main() { ); }); }); + + group('LeaderboardRequested', () { + test('can be instantiated', () { + expect(LeaderboardRequested(), isNotNull); + }); + + test('supports value comparison', () { + expect(LeaderboardRequested(), equals(LeaderboardRequested())); + }); + }); }); } diff --git a/test/game/components/backbox/bloc/backbox_state_test.dart b/test/game/components/backbox/bloc/backbox_state_test.dart index 4708c9bb..dd262408 100644 --- a/test/game/components/backbox/bloc/backbox_state_test.dart +++ b/test/game/components/backbox/bloc/backbox_state_test.dart @@ -1,6 +1,7 @@ // ignore_for_file: prefer_const_constructors import 'package:flutter_test/flutter_test.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/game/components/backbox/bloc/backbox_bloc.dart'; import 'package:pinball_theme/pinball_theme.dart'; @@ -18,11 +19,30 @@ void main() { group('LeaderboardSuccessState', () { test('can be instantiated', () { - expect(LeaderboardSuccessState(), isNotNull); + expect( + LeaderboardSuccessState(entries: const []), + isNotNull, + ); }); test('supports value comparison', () { - expect(LeaderboardSuccessState(), equals(LeaderboardSuccessState())); + expect( + LeaderboardSuccessState(entries: const []), + equals( + LeaderboardSuccessState(entries: const []), + ), + ); + + expect( + LeaderboardSuccessState(entries: const []), + isNot( + equals( + LeaderboardSuccessState( + entries: const [LeaderboardEntryData.empty], + ), + ), + ), + ); }); }); diff --git a/test/game/components/backbox/displays/leaderboard_display_test.dart b/test/game/components/backbox/displays/leaderboard_display_test.dart new file mode 100644 index 00000000..bd5f5219 --- /dev/null +++ b/test/game/components/backbox/displays/leaderboard_display_test.dart @@ -0,0 +1,98 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/components/backbox/displays/leaderboard_display.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +import '../../../../helpers/helpers.dart'; + +class _MockAppLocalizations extends Mock implements AppLocalizations { + @override + String get rank => 'rank'; + + @override + String get score => 'score'; + + @override + String get name => 'name'; +} + +void main() { + group('LeaderboardDisplay', () { + TestWidgetsFlutterBinding.ensureInitialized(); + + final flameTester = FlameTester( + () => EmptyPinballTestGame( + l10n: _MockAppLocalizations(), + assets: [ + const AndroidTheme().leaderboardIcon.keyName, + ], + ), + ); + + flameTester.test('renders the titles', (game) async { + await game.ensureAdd(LeaderboardDisplay(entries: const [])); + + final textComponents = + game.descendants().whereType().toList(); + expect(textComponents.length, equals(3)); + expect(textComponents[0].text, equals('rank')); + expect(textComponents[1].text, equals('score')); + expect(textComponents[2].text, equals('name')); + }); + + flameTester.test('renders the entries', (game) async { + await game.ensureAdd( + LeaderboardDisplay( + entries: const [ + LeaderboardEntryData( + playerInitials: 'AAA', + score: 123, + character: CharacterType.android, + ), + LeaderboardEntryData( + playerInitials: 'BBB', + score: 1234, + character: CharacterType.android, + ), + LeaderboardEntryData( + playerInitials: 'CCC', + score: 12345, + character: CharacterType.android, + ), + LeaderboardEntryData( + playerInitials: 'DDD', + score: 12346, + character: CharacterType.android, + ), + ], + ), + ); + + for (final text in [ + 'AAA', + 'BBB', + 'CCC', + 'DDD', + '1st', + '2nd', + '3rd', + '4th' + ]) { + expect( + game + .descendants() + .whereType() + .where((textComponent) => textComponent.text == text) + .length, + equals(1), + ); + } + }); + }); +}