feat: adding leaderboard display (#352)

* feat: adding leaderboard display

* feat: pr suggestions

* fix: conflicts

* lint

Co-authored-by: Alejandro Santiago <dev@alestiago.com>
pull/367/head
Erick 2 years ago committed by GitHub
parent 2f94cf84d6
commit c8a2150787
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -34,8 +34,11 @@ class Backbox extends PositionComponent with 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);
@ -52,6 +55,8 @@ class Backbox extends PositionComponent with 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(

@ -18,6 +18,7 @@ class BackboxBloc extends Bloc<BackboxEvent, BackboxState> {
super(LoadingState()) {
on<PlayerInitialsRequested>(_onPlayerInitialsRequested);
on<PlayerInitialsSubmitted>(_onPlayerInitialsSubmitted);
on<LeaderboardRequested>(_onLeaderboardRequested);
}
final LeaderboardRepository _leaderboardRepository;
@ -53,4 +54,20 @@ class BackboxBloc extends Bloc<BackboxEvent, BackboxState> {
emit(InitialsFailureState());
}
}
Future<void> _onLeaderboardRequested(
LeaderboardRequested event,
Emitter<BackboxState> emit,
) async {
try {
emit(LoadingState());
final entries = await _leaderboardRepository.fetchTop10Leaderboard();
emit(LeaderboardSuccessState(entries: entries));
} catch (error, stackTrace) {
addError(error, stackTrace);
emit(LeaderboardFailureState());
}
}
}

@ -51,3 +51,9 @@ class PlayerInitialsSubmitted extends BackboxEvent {
@override
List<Object?> get props => [score, initials, character];
}
/// Event that triggers the fetching of the leaderboard
class LeaderboardRequested extends BackboxEvent {
@override
List<Object?> get props => [];
}

@ -14,10 +14,18 @@ class LoadingState extends BackboxState {
List<Object?> 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<LeaderboardEntryData> entries;
@override
List<Object?> get props => [];
List<Object?> get props => [entries];
}
/// State when the leaderboard failed to load.

@ -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';

@ -0,0 +1,120 @@
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/leaderboard/models/leader_board_entry.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_ui/pinball_ui.dart';
final _titleTextPaint = 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<LeaderboardEntryData> entries})
: _entries = entries;
final List<LeaderboardEntryData> _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<void> onLoad() async {
position = Vector2(0, -30);
final l10n = readProvider<AppLocalizations>();
final ranking = _entries.take(5).toList();
await add(
PositionComponent(
position: Vector2(0, 4),
children: [
PositionComponent(
children: [
TextComponent(
text: l10n.rank,
textRenderer: _titleTextPaint,
position: Vector2(_columns[0], 0),
anchor: Anchor.center,
),
TextComponent(
text: l10n.score,
textRenderer: _titleTextPaint,
position: Vector2(_columns[1], 0),
anchor: Anchor.center,
),
TextComponent(
text: l10n.name,
textRenderer: _titleTextPaint,
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,
),
],
),
],
),
);
}
}

@ -76,6 +76,9 @@ class _MockAppLocalizations extends Mock implements AppLocalizations {
@override
String get name => '';
@override
String get rank => '';
@override
String get enterInitials => '';
@ -106,7 +109,7 @@ void main() {
bloc = _MockBackboxBloc();
whenListen(
bloc,
Stream.value(LoadingState()),
Stream<BackboxState>.empty(),
initialState: LoadingState(),
);
});
@ -121,6 +124,16 @@ void main() {
},
);
flameTester.test(
'adds LeaderboardRequested when loaded',
(game) async {
final backbox = Backbox.test(bloc: bloc);
await game.pump(backbox);
verify(() => bloc.add(LeaderboardRequested())).called(1);
},
);
flameTester.testGameWidget(
'renders correctly',
setUp: (game, tester) async {
@ -173,7 +186,7 @@ void main() {
);
whenListen(
bloc,
Stream.value(state),
Stream<BackboxState>.empty(),
initialState: state,
);
final backbox = Backbox.test(bloc: bloc);
@ -197,7 +210,7 @@ void main() {
(game) async {
whenListen(
bloc,
Stream.value(InitialsSuccessState()),
Stream<BackboxState>.empty(),
initialState: InitialsSuccessState(),
);
final backbox = Backbox.test(bloc: bloc);
@ -218,7 +231,7 @@ void main() {
(game) async {
whenListen(
bloc,
Stream.value(InitialsFailureState()),
Stream<BackboxState>.empty(),
initialState: InitialsFailureState(),
);
final backbox = Backbox.test(bloc: bloc);
@ -234,6 +247,25 @@ void main() {
},
);
flameTester.test(
'adds LeaderboardDisplay on LeaderboardSuccessState',
(game) async {
whenListen(
bloc,
Stream<BackboxState>.empty(),
initialState: LeaderboardSuccessState(entries: const []),
);
final backbox = Backbox.test(bloc: bloc);
await game.pump(backbox);
expect(
game.descendants().whereType<LeaderboardDisplay>().length,
equals(1),
);
},
);
flameTester.test(
'closes the subscription when it is removed',
(game) async {

@ -88,5 +88,41 @@ void main() {
],
);
});
group('LeaderboardRequested', () {
blocTest<BackboxBloc, BackboxState>(
'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<BackboxBloc, BackboxState>(
'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(),
],
);
});
});
}

@ -122,5 +122,15 @@ void main() {
);
});
});
group('LeaderboardRequested', () {
test('can be instantiated', () {
expect(LeaderboardRequested(), isNotNull);
});
test('supports value comparison', () {
expect(LeaderboardRequested(), equals(LeaderboardRequested()));
});
});
});
}

@ -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],
),
),
),
);
});
});

@ -0,0 +1,109 @@
// ignore_for_file: cascade_invocations
import 'package:flame/components.dart';
import 'package:flame_forge2d/forge2d_game.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_flame/pinball_flame.dart';
import 'package:pinball_theme/pinball_theme.dart';
class _MockAppLocalizations extends Mock implements AppLocalizations {
@override
String get rank => 'rank';
@override
String get score => 'score';
@override
String get name => 'name';
}
class _TestGame extends Forge2DGame {
@override
Future<void> onLoad() async {
await super.onLoad();
images.prefix = '';
await images.load(const AndroidTheme().leaderboardIcon.keyName);
}
Future<void> pump(LeaderboardDisplay component) {
return ensureAdd(
FlameProvider.value(
_MockAppLocalizations(),
children: [component],
),
);
}
}
void main() {
group('LeaderboardDisplay', () {
TestWidgetsFlutterBinding.ensureInitialized();
final flameTester = FlameTester(_TestGame.new);
flameTester.test('renders the titles', (game) async {
await game.pump(LeaderboardDisplay(entries: const []));
final textComponents =
game.descendants().whereType<TextComponent>().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.pump(
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<TextComponent>()
.where((textComponent) => textComponent.text == text)
.length,
equals(1),
);
}
});
});
}
Loading…
Cancel
Save