feat: adding leaderboard pagination buttons (#420)

* feat: adding leaderboard pagination buttons

* lint

* cspell
pull/423/head
Erick 2 years ago committed by GitHub
parent 96beecfc82
commit ea33c1c889
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,4 +1,6 @@
// cSpell:ignore sublist
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/l10n/l10n.dart';
@ -23,6 +25,23 @@ final _bodyTextPaint = TextPaint(
),
);
double _calcY(int i) => (i * 3.2) + 3.2;
const _columns = [-14.0, 0.0, 14.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';
}
}
/// {@template leaderboard_display}
/// Component that builds the leaderboard list of the Backbox.
/// {@endtemplate}
@ -33,21 +52,47 @@ class LeaderboardDisplay extends PositionComponent with HasGameRef {
final List<LeaderboardEntryData> _entries;
double _calcY(int i) => (i * 3.2) + 3.2;
_MovePageArrow _findArrow({required bool active}) {
return descendants()
.whereType<_MovePageArrow>()
.firstWhere((arrow) => arrow.active == active);
}
static const _columns = [-15.0, 0.0, 15.0];
void _changePage(List<LeaderboardEntryData> ranking, int offset) {
final current = descendants().whereType<_RankingPage>().single;
final activeArrow = _findArrow(active: true);
final inactiveArrow = _findArrow(active: false);
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';
}
activeArrow.active = false;
current.add(
ScaleEffect.to(
Vector2(0, 1),
EffectController(
duration: 0.5,
curve: Curves.easeIn,
),
)..onFinishCallback = () {
current.removeFromParent();
inactiveArrow.active = true;
firstChild<PositionComponent>()?.add(
_RankingPage(
ranking: ranking,
offset: offset,
)
..scale = Vector2(0, 1)
..add(
ScaleEffect.to(
Vector2(1, 1),
EffectController(
duration: 0.5,
curve: Curves.easeIn,
),
),
),
);
},
);
}
@override
@ -60,6 +105,20 @@ class LeaderboardDisplay extends PositionComponent with HasGameRef {
PositionComponent(
position: Vector2(0, 4),
children: [
_MovePageArrow(
position: Vector2(20, 9),
onTap: () {
_changePage(_entries.sublist(5), 5);
},
),
_MovePageArrow(
position: Vector2(-20, 9),
direction: ArrowIconDirection.left,
active: false,
onTap: () {
_changePage(_entries.take(5).toList(), 0);
},
),
PositionComponent(
children: [
TextComponent(
@ -82,39 +141,106 @@ class LeaderboardDisplay extends PositionComponent with HasGameRef {
),
],
),
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,
),
],
),
_RankingPage(
ranking: ranking,
offset: 0,
),
],
),
);
}
}
class _RankingPage extends PositionComponent with HasGameRef {
_RankingPage({
required this.ranking,
required this.offset,
}) : super(children: []);
final List<LeaderboardEntryData> ranking;
final int offset;
@override
Future<void> onLoad() async {
await addAll([
for (var i = 0; i < ranking.length; i++)
PositionComponent(
children: [
TextComponent(
text: _rank(i + 1 + offset),
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] - 3, _calcY(i) + .25),
),
TextComponent(
text: ranking[i].playerInitials,
textRenderer: _bodyTextPaint,
position: Vector2(_columns[2] + 1, _calcY(i)),
anchor: Anchor.center,
),
],
),
]);
}
}
class _MovePageArrow extends PositionComponent {
_MovePageArrow({
required Vector2 position,
required this.onTap,
this.direction = ArrowIconDirection.right,
bool active = true,
}) : super(
position: position,
children: [
if (active)
ArrowIcon(
position: Vector2.zero(),
direction: direction,
onTap: onTap,
),
SequenceEffect(
[
ScaleEffect.to(
Vector2.all(1.2),
EffectController(duration: 1),
),
ScaleEffect.to(Vector2.all(1), EffectController(duration: 1)),
],
infinite: true,
),
],
);
final ArrowIconDirection direction;
final VoidCallback onTap;
bool get active => children.whereType<ArrowIcon>().isNotEmpty;
set active(bool value) {
if (value) {
add(
ArrowIcon(
position: Vector2.zero(),
direction: direction,
onTap: onTap,
),
);
} else {
firstChild<ArrowIcon>()?.removeFromParent();
}
}
}

@ -141,6 +141,8 @@ extension PinballGameAssetsX on PinballGame {
images.load(components.Assets.images.skillShot.pin.keyName),
images.load(components.Assets.images.skillShot.lit.keyName),
images.load(components.Assets.images.skillShot.dimmed.keyName),
images.load(components.Assets.images.displayArrows.arrowLeft.keyName),
images.load(components.Assets.images.displayArrows.arrowRight.keyName),
images.load(androidTheme.leaderboardIcon.keyName),
images.load(androidTheme.background.keyName),
images.load(androidTheme.ball.keyName),

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 B

@ -23,6 +23,9 @@ class $AssetsImagesGen {
$AssetsImagesDashGen get dash => const $AssetsImagesDashGen();
$AssetsImagesDinoGen get dino => const $AssetsImagesDinoGen();
$AssetsImagesDisplayArrowsGen get displayArrows =>
const $AssetsImagesDisplayArrowsGen();
/// File path: assets/images/error_background.png
AssetGenImage get errorBackground =>
const AssetGenImage('assets/images/error_background.png');
@ -140,6 +143,15 @@ class $AssetsImagesDinoGen {
const AssetGenImage('assets/images/dino/top_wall_tunnel.png');
}
class $AssetsImagesDisplayArrowsGen {
const $AssetsImagesDisplayArrowsGen();
AssetGenImage get arrowLeft =>
const AssetGenImage('assets/images/display_arrows/arrow_left.png');
AssetGenImage get arrowRight =>
const AssetGenImage('assets/images/display_arrows/arrow_right.png');
}
class $AssetsImagesFlapperGen {
const $AssetsImagesFlapperGen();

@ -0,0 +1,49 @@
import 'package:flame/components.dart';
import 'package:flame/input.dart';
import 'package:flutter/material.dart';
import 'package:pinball_components/pinball_components.dart';
/// enum with the available directions for an [ArrowIcon].
enum ArrowIconDirection {
/// Left.
left,
/// Right.
right,
}
/// {@template arrow_icon}
/// A [SpriteComponent] that renders a simple arrow icon.
/// {@endtemplate}
class ArrowIcon extends SpriteComponent with Tappable, HasGameRef {
/// {@macro arrow_icon}
ArrowIcon({
required Vector2 position,
required this.direction,
required this.onTap,
}) : super(position: position);
final ArrowIconDirection direction;
final VoidCallback onTap;
@override
Future<void> onLoad() async {
anchor = Anchor.center;
final sprite = Sprite(
gameRef.images.fromCache(
direction == ArrowIconDirection.left
? Assets.images.displayArrows.arrowLeft.keyName
: Assets.images.displayArrows.arrowRight.keyName,
),
);
size = sprite.originalSize / 20;
this.sprite = sprite;
}
@override
bool onTapUp(TapUpInfo info) {
onTap();
return true;
}
}

@ -2,6 +2,7 @@ export 'android_animatronic.dart';
export 'android_bumper/android_bumper.dart';
export 'android_spaceship/android_spaceship.dart';
export 'arcade_background/arcade_background.dart';
export 'arrow_icon.dart';
export 'ball/ball.dart';
export 'baseboard.dart';
export 'board_background_sprite_component.dart';

@ -93,6 +93,7 @@ flutter:
- assets/images/backbox/button/
- assets/images/flapper/
- assets/images/skill_shot/
- assets/images/display_arrows/
flutter_gen:
line_length: 80

@ -21,6 +21,7 @@ void main() {
addScoreStories(dashbook);
addMultiballStories(dashbook);
addMultipliersStories(dashbook);
addArrowIconStories(dashbook);
runApp(dashbook);
}

@ -0,0 +1,37 @@
import 'package:flame/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:sandbox/common/games.dart';
class ArrowIconGame extends AssetsGame with HasTappables {
ArrowIconGame()
: super(
imagesFileNames: [
Assets.images.displayArrows.arrowLeft.keyName,
Assets.images.displayArrows.arrowRight.keyName,
],
);
static const description = 'Shows how ArrowIcons are rendered.';
@override
Future<void> onLoad() async {
await super.onLoad();
camera.followVector2(Vector2.zero());
await add(
ArrowIcon(
position: Vector2.zero(),
direction: ArrowIconDirection.left,
onTap: () {},
),
);
await add(
ArrowIcon(
position: Vector2(0, 20),
direction: ArrowIconDirection.right,
onTap: () {},
),
);
}
}

@ -0,0 +1,11 @@
import 'package:dashbook/dashbook.dart';
import 'package:sandbox/common/common.dart';
import 'package:sandbox/stories/arrow_icon/arrow_icon_game.dart';
void addArrowIconStories(Dashbook dashbook) {
dashbook.storiesOf('ArrowIcon').addGame(
title: 'Basic',
description: ArrowIconGame.description,
gameBuilder: (context) => ArrowIconGame(),
);
}

@ -1,4 +1,5 @@
export 'android_acres/stories.dart';
export 'arrow_icon/stories.dart';
export 'ball/stories.dart';
export 'bottom_group/stories.dart';
export 'boundaries/stories.dart';

@ -1,3 +1,4 @@
import 'package:flame/game.dart';
import 'package:flame/input.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
@ -20,3 +21,7 @@ class TestGame extends Forge2DGame {
class KeyboardTestGame extends TestGame with HasKeyboardHandlerComponents {
KeyboardTestGame([List<String>? assets]) : super(assets);
}
class TappablesTestGame extends TestGame with HasTappables {
TappablesTestGame([List<String>? assets]) : super(assets);
}

@ -0,0 +1,96 @@
// ignore_for_file: cascade_invocations, one_member_abstracts
import 'package:flame/components.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball_components/pinball_components.dart';
import '../../helpers/helpers.dart';
abstract class _VoidCallbackStubBase {
void onCall();
}
class _VoidCallbackStub extends Mock implements _VoidCallbackStubBase {}
void main() {
group('ArrowIcon', () {
TestWidgetsFlutterBinding.ensureInitialized();
final assets = [
Assets.images.displayArrows.arrowLeft.keyName,
Assets.images.displayArrows.arrowRight.keyName,
];
final flameTester = FlameTester(() => TappablesTestGame(assets));
flameTester.testGameWidget(
'is tappable',
setUp: (game, tester) async {
final stub = _VoidCallbackStub();
await game.images.loadAll(assets);
await game.ensureAdd(
ArrowIcon(
position: Vector2.zero(),
direction: ArrowIconDirection.left,
onTap: stub.onCall,
),
);
await tester.pump();
await tester.tapAt(Offset.zero);
await tester.pump();
},
verify: (game, tester) async {
final icon = game.descendants().whereType<ArrowIcon>().single;
verify(icon.onTap).called(1);
},
);
group('left', () {
flameTester.testGameWidget(
'renders correctly',
setUp: (game, tester) async {
await game.images.loadAll(assets);
game.camera.followVector2(Vector2.zero());
await game.add(
ArrowIcon(
position: Vector2.zero(),
direction: ArrowIconDirection.left,
onTap: () {},
),
);
await tester.pump();
},
verify: (game, tester) async {
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('golden/arrow_icon_left.png'),
);
},
);
});
group('right', () {
flameTester.testGameWidget(
'renders correctly',
setUp: (game, tester) async {
await game.images.loadAll(assets);
game.camera.followVector2(Vector2.zero());
await game.add(
ArrowIcon(
position: Vector2.zero(),
direction: ArrowIconDirection.right,
onTap: () {},
),
);
await tester.pump();
},
verify: (game, tester) async {
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('golden/arrow_icon_right.png'),
);
},
);
});
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@ -42,11 +42,16 @@ class _TestGame extends Forge2DGame
Assets.images.backbox.button.facebook.keyName,
Assets.images.backbox.button.twitter.keyName,
Assets.images.backbox.displayTitleDecoration.keyName,
Assets.images.displayArrows.arrowLeft.keyName,
Assets.images.displayArrows.arrowRight.keyName,
]);
}
Future<void> pump(Backbox component) {
return ensureAdd(
Future<void> pump(Backbox component) async {
// Not needed once https://github.com/flame-engine/flame/issues/1607
// is fixed
await onLoad();
await ensureAdd(
FlameBlocProvider<GameBloc, GameState>.value(
value: GameBloc(),
children: [

@ -1,6 +1,7 @@
// ignore_for_file: cascade_invocations
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame_forge2d/forge2d_game.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
@ -8,8 +9,9 @@ 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_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_theme/pinball_theme.dart';
import 'package:pinball_theme/pinball_theme.dart' hide Assets;
class _MockAppLocalizations extends Mock implements AppLocalizations {
@override
@ -22,12 +24,16 @@ class _MockAppLocalizations extends Mock implements AppLocalizations {
String get name => 'name';
}
class _TestGame extends Forge2DGame {
class _TestGame extends Forge2DGame with HasTappables {
@override
Future<void> onLoad() async {
await super.onLoad();
images.prefix = '';
await images.load(const AndroidTheme().leaderboardIcon.keyName);
await images.loadAll([
const AndroidTheme().leaderboardIcon.keyName,
Assets.images.displayArrows.arrowLeft.keyName,
Assets.images.displayArrows.arrowRight.keyName,
]);
}
Future<void> pump(LeaderboardDisplay component) {
@ -40,6 +46,59 @@ class _TestGame extends Forge2DGame {
}
}
const leaderboard = [
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,
),
LeaderboardEntryData(
playerInitials: 'EEE',
score: 123467,
character: CharacterType.android,
),
LeaderboardEntryData(
playerInitials: 'FFF',
score: 123468,
character: CharacterType.android,
),
LeaderboardEntryData(
playerInitials: 'GGG',
score: 1234689,
character: CharacterType.android,
),
LeaderboardEntryData(
playerInitials: 'HHH',
score: 12346891,
character: CharacterType.android,
),
LeaderboardEntryData(
playerInitials: 'III',
score: 123468912,
character: CharacterType.android,
),
LeaderboardEntryData(
playerInitials: 'JJJ',
score: 1234689121,
character: CharacterType.android,
),
];
void main() {
group('LeaderboardDisplay', () {
TestWidgetsFlutterBinding.ensureInitialized();
@ -57,43 +116,20 @@ void main() {
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,
),
],
),
);
flameTester.test('renders the first 5 entries', (game) async {
await game.pump(LeaderboardDisplay(entries: leaderboard));
for (final text in [
'AAA',
'BBB',
'CCC',
'DDD',
'EEE',
'1st',
'2nd',
'3rd',
'4th'
'4th',
'5th',
]) {
expect(
game
@ -105,5 +141,120 @@ void main() {
);
}
});
flameTester.test('can open the second page', (game) async {
final display = LeaderboardDisplay(entries: leaderboard);
await game.pump(display);
final arrow = game
.descendants()
.whereType<ArrowIcon>()
.where((arrow) => arrow.direction == ArrowIconDirection.right)
.single;
// Tap the arrow
arrow.onTap();
// Wait for the transition to finish
display.updateTree(5);
await game.ready();
for (final text in [
'FFF',
'GGG',
'HHH',
'III',
'JJJ',
'6th',
'7th',
'8th',
'9th',
'10th',
]) {
expect(
game
.descendants()
.whereType<TextComponent>()
.where((textComponent) => textComponent.text == text)
.length,
equals(1),
);
}
});
flameTester.test(
'can open the second page and go back to the first',
(game) async {
final display = LeaderboardDisplay(entries: leaderboard);
await game.pump(display);
var arrow = game
.descendants()
.whereType<ArrowIcon>()
.where((arrow) => arrow.direction == ArrowIconDirection.right)
.single;
// Tap the arrow
arrow.onTap();
// Wait for the transition to finish
display.updateTree(5);
await game.ready();
for (final text in [
'FFF',
'GGG',
'HHH',
'III',
'JJJ',
'6th',
'7th',
'8th',
'9th',
'10th',
]) {
expect(
game
.descendants()
.whereType<TextComponent>()
.where((textComponent) => textComponent.text == text)
.length,
equals(1),
);
}
arrow = game
.descendants()
.whereType<ArrowIcon>()
.where((arrow) => arrow.direction == ArrowIconDirection.left)
.single;
// Tap the arrow
arrow.onTap();
// Wait for the transition to finish
display.updateTree(5);
await game.ready();
for (final text in [
'AAA',
'BBB',
'CCC',
'DDD',
'EEE',
'1st',
'2nd',
'3rd',
'4th',
'5th',
]) {
expect(
game
.descendants()
.whereType<TextComponent>()
.where((textComponent) => textComponent.text == text)
.length,
equals(1),
);
}
},
);
});
}

@ -1,6 +1,7 @@
// ignore_for_file: cascade_invocations
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
@ -16,7 +17,7 @@ import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_theme/pinball_theme.dart' as theme;
import 'package:share_repository/share_repository.dart';
class _TestGame extends Forge2DGame {
class _TestGame extends Forge2DGame with HasTappables {
@override
Future<void> onLoad() async {
images.prefix = '';
@ -25,6 +26,8 @@ class _TestGame extends Forge2DGame {
const theme.DashTheme().leaderboardIcon.keyName,
Assets.images.backbox.marquee.keyName,
Assets.images.backbox.displayDivider.keyName,
Assets.images.displayArrows.arrowLeft.keyName,
Assets.images.displayArrows.arrowRight.keyName,
],
);
}

Loading…
Cancel
Save