Merge branch 'main' into feat/spaceship-animation-and-assets

pull/258/head
Allison Ryan 3 years ago
commit 0f20c516e8

@ -0,0 +1,25 @@
name: deploy
on:
push:
branches:
- main
jobs:
deploy-dev:
runs-on: ubuntu-latest
name: Deploy Development
steps:
- uses: actions/checkout@v2
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter packages get
- run: flutter build web --target lib/main_development.dart --web-renderer canvaskit --release
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: "${{ secrets.GITHUB_TOKEN }}"
firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_PINBALL_DEV }}"
channelId: live
projectId: pinball-dev
target: ashehwkdkdjruejdnensjsjdne

@ -0,0 +1,23 @@
name: platform_helper
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
paths:
- "packages/platform_helper/**"
- ".github/workflows/platform_helper.yaml"
pull_request:
paths:
- "packages/platform_helper/**"
- ".github/workflows/platform_helper.yaml"
jobs:
build:
uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1
with:
working_directory: packages/platform_helper
coverage_excludes: "lib/gen/*.dart"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

@ -16,14 +16,19 @@ class AndroidAcres extends Blueprint {
components: [
AndroidBumper.a(
children: [
ScoringBehavior(points: 20),
ScoringBehavior(points: 20000),
],
)..initialPosition = Vector2(-32.52, -9.1),
)..initialPosition = Vector2(-25, 1.3),
AndroidBumper.b(
children: [
ScoringBehavior(points: 20000),
],
)..initialPosition = Vector2(-32.6, -9.2),
AndroidBumper.cow(
children: [
ScoringBehavior(points: 20),
],
)..initialPosition = Vector2(-22.89, -17.35),
)..initialPosition = Vector2(-20.5, -13.8),
],
blueprints: [
SpaceshipRamp(),

@ -48,6 +48,9 @@ class _BottomGroupSide extends Component {
);
final kicker = Kicker(
side: _side,
children: [
ScoringBehavior(points: 5000),
],
)..initialPosition = Vector2(
(22.4 * direction) + centerXAdjustment,
25,

@ -10,5 +10,6 @@ export 'flutter_forest/flutter_forest.dart';
export 'game_flow_controller.dart';
export 'google_word/google_word.dart';
export 'launcher.dart';
export 'multipliers/multipliers.dart';
export 'scoring_behavior.dart';
export 'sparky_fire_zone.dart';

@ -23,17 +23,17 @@ class FlutterForest extends Component {
)..initialPosition = Vector2(8.35, -58.3),
DashNestBumper.main(
children: [
ScoringBehavior(points: 20),
ScoringBehavior(points: 200000),
],
)..initialPosition = Vector2(18.55, -59.35),
DashNestBumper.a(
children: [
ScoringBehavior(points: 20),
ScoringBehavior(points: 20000),
],
)..initialPosition = Vector2(8.95, -51.95),
DashNestBumper.b(
children: [
ScoringBehavior(points: 20),
ScoringBehavior(points: 20000),
],
)..initialPosition = Vector2(23.3, -46.75),
DashAnimatronic()..position = Vector2(20, -66),

@ -1,6 +1,7 @@
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import 'package:pinball/game/components/google_word/behaviors/behaviors.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template google_word}
@ -12,12 +13,30 @@ class GoogleWord extends Component {
required Vector2 position,
}) : super(
children: [
GoogleLetter(0)..initialPosition = position + Vector2(-12.92, 1.82),
GoogleLetter(1)..initialPosition = position + Vector2(-8.33, -0.65),
GoogleLetter(2)..initialPosition = position + Vector2(-2.88, -1.75),
GoogleLetter(3)..initialPosition = position + Vector2(2.88, -1.75),
GoogleLetter(4)..initialPosition = position + Vector2(8.33, -0.65),
GoogleLetter(5)..initialPosition = position + Vector2(12.92, 1.82),
GoogleLetter(
0,
children: [ScoringBehavior(points: 5000)],
)..initialPosition = position + Vector2(-12.92, 1.82),
GoogleLetter(
1,
children: [ScoringBehavior(points: 5000)],
)..initialPosition = position + Vector2(-8.33, -0.65),
GoogleLetter(
2,
children: [ScoringBehavior(points: 5000)],
)..initialPosition = position + Vector2(-2.88, -1.75),
GoogleLetter(
3,
children: [ScoringBehavior(points: 5000)],
)..initialPosition = position + Vector2(2.88, -1.75),
GoogleLetter(
4,
children: [ScoringBehavior(points: 5000)],
)..initialPosition = position + Vector2(8.33, -0.65),
GoogleLetter(
5,
children: [ScoringBehavior(points: 5000)],
)..initialPosition = position + Vector2(12.92, 1.82),
GoogleWordBonusBehavior(),
],
);

@ -0,0 +1 @@
export 'multipliers_behavior.dart';

@ -0,0 +1,25 @@
import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// Toggle each [Multiplier] when GameState.multiplier changes.
class MultipliersBehavior extends Component
with
HasGameRef<PinballGame>,
ParentIsA<Multipliers>,
BlocComponent<GameBloc, GameState> {
@override
bool listenWhen(GameState? previousState, GameState newState) {
return previousState?.multiplier != newState.multiplier;
}
@override
void onNewState(GameState state) {
final multipliers = parent.children.whereType<Multiplier>();
for (final multiplier in multipliers) {
multiplier.bloc.next(state.multiplier);
}
}
}

@ -0,0 +1,44 @@
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import 'package:pinball/game/components/multipliers/behaviors/behaviors.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template multipliers}
/// A group for the multipliers on the board.
/// {@endtemplate}
class Multipliers extends Component {
/// {@macro multipliers}
Multipliers()
: super(
children: [
Multiplier.x2(
position: Vector2(-19.5, -2),
angle: -15 * math.pi / 180,
),
Multiplier.x3(
position: Vector2(13, -9.4),
angle: 15 * math.pi / 180,
),
Multiplier.x4(
position: Vector2(0, -21.2),
angle: 0,
),
Multiplier.x5(
position: Vector2(-8.5, -28),
angle: -3 * math.pi / 180,
),
Multiplier.x6(
position: Vector2(10, -30.7),
angle: 8 * math.pi / 180,
),
MultipliersBehavior(),
],
);
/// Creates [Multipliers] without any children.
///
/// This can be used for testing [Multipliers]'s behaviors in isolation.
@visibleForTesting
Multipliers.test();
}

@ -18,17 +18,17 @@ class SparkyFireZone extends Blueprint {
components: [
SparkyBumper.a(
children: [
ScoringBehavior(points: 20),
ScoringBehavior(points: 20000),
],
)..initialPosition = Vector2(-22.9, -41.65),
SparkyBumper.b(
children: [
ScoringBehavior(points: 20),
ScoringBehavior(points: 20000),
],
)..initialPosition = Vector2(-21.25, -57.9),
SparkyBumper.c(
children: [
ScoringBehavior(points: 20),
ScoringBehavior(points: 20000),
],
)..initialPosition = Vector2(-3.3, -52.55),
SparkyComputerSensor()..initialPosition = Vector2(-13, -49.8),
@ -47,7 +47,13 @@ class SparkyFireZone extends Blueprint {
class SparkyComputerSensor extends BodyComponent
with InitialPosition, ContactCallbacks {
/// {@macro sparky_computer_sensor}
SparkyComputerSensor() : super(renderBody: false);
SparkyComputerSensor()
: super(
renderBody: false,
children: [
ScoringBehavior(points: 200000),
],
);
@override
Body createBody() {

@ -84,6 +84,8 @@ extension PinballGameAssetsX on PinballGame {
images.load(components.Assets.images.android.bumper.a.dimmed.keyName),
images.load(components.Assets.images.android.bumper.b.lit.keyName),
images.load(components.Assets.images.android.bumper.b.dimmed.keyName),
images.load(components.Assets.images.android.bumper.cow.lit.keyName),
images.load(components.Assets.images.android.bumper.cow.dimmed.keyName),
images.load(components.Assets.images.sparky.computer.top.keyName),
images.load(components.Assets.images.sparky.computer.base.keyName),
images.load(components.Assets.images.sparky.animatronic.keyName),
@ -102,6 +104,16 @@ extension PinballGameAssetsX on PinballGame {
images.load(components.Assets.images.googleWord.letter5.keyName),
images.load(components.Assets.images.googleWord.letter6.keyName),
images.load(components.Assets.images.backboard.display.keyName),
images.load(components.Assets.images.multiplier.x2.lit.keyName),
images.load(components.Assets.images.multiplier.x2.dimmed.keyName),
images.load(components.Assets.images.multiplier.x3.lit.keyName),
images.load(components.Assets.images.multiplier.x3.dimmed.keyName),
images.load(components.Assets.images.multiplier.x4.lit.keyName),
images.load(components.Assets.images.multiplier.x4.dimmed.keyName),
images.load(components.Assets.images.multiplier.x5.lit.keyName),
images.load(components.Assets.images.multiplier.x5.dimmed.keyName),
images.load(components.Assets.images.multiplier.x6.lit.keyName),
images.load(components.Assets.images.multiplier.x6.dimmed.keyName),
images.load(dashTheme.leaderboardIcon.keyName),
images.load(sparkyTheme.leaderboardIcon.keyName),
images.load(androidTheme.leaderboardIcon.keyName),

@ -52,6 +52,7 @@ class PinballGame extends Forge2DGame
final launcher = Launcher();
unawaited(addFromBlueprint(launcher));
await add(Multipliers());
await add(FlutterForest());
await addFromBlueprint(SparkyFireZone());
await addFromBlueprint(AndroidAcres());

@ -21,7 +21,7 @@ class RoundCountDisplay extends StatelessWidget {
Text(
l10n.rounds,
style: AppTextStyle.subtitle1.copyWith(
color: AppColors.orange,
color: AppColors.yellow,
),
),
const SizedBox(width: 8),
@ -53,7 +53,7 @@ class RoundIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
final color = isActive ? AppColors.orange : AppColors.orange.withAlpha(128);
final color = isActive ? AppColors.yellow : AppColors.yellow.withAlpha(128);
const size = 8.0;
return Padding(

@ -59,7 +59,7 @@ class _ScoreDisplay extends StatelessWidget {
Text(
l10n.score.toLowerCase(),
style: AppTextStyle.subtitle1.copyWith(
color: AppColors.orange,
color: AppColors.yellow,
),
),
const _ScoreText(),

@ -47,6 +47,14 @@ class $AssetsImagesComponentsGen {
/// File path: assets/images/components/background.png
AssetGenImage get background =>
const AssetGenImage('assets/images/components/background.png');
/// File path: assets/images/components/key.png
AssetGenImage get key =>
const AssetGenImage('assets/images/components/key.png');
/// File path: assets/images/components/space.png
AssetGenImage get space =>
const AssetGenImage('assets/images/components/space.png');
}
class $AssetsImagesScoreGen {

@ -8,6 +8,10 @@
"@howToPlay": {
"description": "Text displayed on the landing page how to play button"
},
"tipsForFlips": "Tips for flips",
"@tipsForFlips": {
"description": "Text displayed on the landing page how to play button"
},
"launchControls": "Launch Controls",
"@launchControls": {
"description": "Text displayed on the how to play dialog with the launch controls"
@ -16,6 +20,26 @@
"@flipperControls": {
"description": "Text displayed on the how to play dialog with the flipper controls"
},
"tapAndHoldRocket": "Tap & Hold Rocket",
"@tapAndHoldRocket": {
"description": "Text displayed on the how to launch on mobile"
},
"to": "to",
"@to": {
"description": "Text displayed for the word to"
},
"launch": "LAUNCH",
"@launch": {
"description": "Text displayed for the word launch"
},
"tapLeftRightScreen": "Tap left/right screen",
"@tapLeftRightScreen": {
"description": "Text displayed on the how to flip on mobile"
},
"flip": "FLIP",
"@flip": {
"description": "Text displayed for the word FLIP"
},
"start": "Start",
"@start": {
"description": "Text displayed on the character selection page start button"
@ -24,6 +48,10 @@
"@select": {
"description": "Text displayed on the character selection page select button"
},
"space": "Space",
"@space": {
"description": "Text displayed on space control button"
},
"characterSelectionTitle": "Choose your character!",
"@characterSelectionTitle": {
"description": "Title text displayed on the character selection page"

@ -1,71 +0,0 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/leaderboard/leaderboard.dart';
part 'leaderboard_event.dart';
part 'leaderboard_state.dart';
/// {@template leaderboard_bloc}
/// Manages leaderboard events.
///
/// Uses a [LeaderboardRepository] to request and update players participations.
/// {@endtemplate}
class LeaderboardBloc extends Bloc<LeaderboardEvent, LeaderboardState> {
/// {@macro leaderboard_bloc}
LeaderboardBloc(this._leaderboardRepository)
: super(const LeaderboardState.initial()) {
on<Top10Fetched>(_onTop10Fetched);
on<LeaderboardEntryAdded>(_onLeaderboardEntryAdded);
}
final LeaderboardRepository _leaderboardRepository;
Future<void> _onTop10Fetched(
Top10Fetched event,
Emitter<LeaderboardState> emit,
) async {
emit(state.copyWith(status: LeaderboardStatus.loading));
try {
final top10Leaderboard =
await _leaderboardRepository.fetchTop10Leaderboard();
final leaderboardEntries = <LeaderboardEntry>[];
top10Leaderboard.asMap().forEach(
(index, value) => leaderboardEntries.add(value.toEntry(index + 1)),
);
emit(
state.copyWith(
status: LeaderboardStatus.success,
leaderboard: leaderboardEntries,
),
);
} catch (error) {
emit(state.copyWith(status: LeaderboardStatus.error));
addError(error);
}
}
Future<void> _onLeaderboardEntryAdded(
LeaderboardEntryAdded event,
Emitter<LeaderboardState> emit,
) async {
emit(state.copyWith(status: LeaderboardStatus.loading));
try {
final ranking =
await _leaderboardRepository.addLeaderboardEntry(event.entry);
emit(
state.copyWith(
status: LeaderboardStatus.success,
ranking: ranking,
),
);
} catch (error) {
emit(state.copyWith(status: LeaderboardStatus.error));
addError(error);
}
}
}

@ -1,36 +0,0 @@
part of 'leaderboard_bloc.dart';
/// {@template leaderboard_event}
/// Represents the events available for [LeaderboardBloc].
/// {endtemplate}
abstract class LeaderboardEvent extends Equatable {
/// {@macro leaderboard_event}
const LeaderboardEvent();
}
/// {@template top_10_fetched}
/// Request the top 10 [LeaderboardEntryData]s.
/// {endtemplate}
class Top10Fetched extends LeaderboardEvent {
/// {@macro top_10_fetched}
const Top10Fetched();
@override
List<Object?> get props => [];
}
/// {@template leaderboard_entry_added}
/// Writes a new [LeaderboardEntryData].
///
/// Should be added when a player finishes a game.
/// {endtemplate}
class LeaderboardEntryAdded extends LeaderboardEvent {
/// {@macro leaderboard_entry_added}
const LeaderboardEntryAdded({required this.entry});
/// [LeaderboardEntryData] to be written to the remote storage.
final LeaderboardEntryData entry;
@override
List<Object?> get props => [entry];
}

@ -1,59 +0,0 @@
// ignore_for_file: public_member_api_docs
part of 'leaderboard_bloc.dart';
/// Defines the request status.
enum LeaderboardStatus {
/// Request is being loaded.
loading,
/// Request was processed successfully and received a valid response.
success,
/// Request was processed unsuccessfully and received an error.
error,
}
/// {@template leaderboard_state}
/// Represents the state of the leaderboard.
/// {@endtemplate}
class LeaderboardState extends Equatable {
/// {@macro leaderboard_state}
const LeaderboardState({
required this.status,
required this.ranking,
required this.leaderboard,
});
const LeaderboardState.initial()
: status = LeaderboardStatus.loading,
ranking = const LeaderboardRanking(
ranking: 0,
outOf: 0,
),
leaderboard = const [];
/// The current [LeaderboardStatus] of the state.
final LeaderboardStatus status;
/// Rank of the current player.
final LeaderboardRanking ranking;
/// List of top-ranked players.
final List<LeaderboardEntry> leaderboard;
@override
List<Object> get props => [status, ranking, leaderboard];
LeaderboardState copyWith({
LeaderboardStatus? status,
LeaderboardRanking? ranking,
List<LeaderboardEntry>? leaderboard,
}) {
return LeaderboardState(
status: status ?? this.status,
ranking: ranking ?? this.ranking,
leaderboard: leaderboard ?? this.leaderboard,
);
}
}

@ -1,3 +0,0 @@
export 'bloc/leaderboard_bloc.dart';
export 'models/leader_board_entry.dart';
export 'view/leaderboard_page.dart';

@ -1,306 +0,0 @@
// 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/select_character/select_character.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<void>(
builder: (_) => LeaderboardPage(theme: theme),
);
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => LeaderboardBloc(
context.read<LeaderboardRepository>(),
)..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<LeaderboardBloc, LeaderboardState>(
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<void>(
CharacterSelectionDialog.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<LeaderboardEntry> 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<LeaderboardEntry> 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(),
),
),
);
}
}

@ -49,14 +49,13 @@ class CharacterSelectionView extends StatelessWidget {
Navigator.of(context).pop();
// TODO(arturplaczek): remove after merge StarBlocListener
final height = MediaQuery.of(context).size.height * 0.5;
showDialog<void>(
context: context,
builder: (_) => Center(
child: SizedBox(
height: height,
width: height * 1.4,
child: const HowToPlayDialog(),
child: HowToPlayDialog(),
),
),
);

@ -1,33 +1,236 @@
// ignore_for_file: public_member_api_docs
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:pinball/gen/gen.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/theme/theme.dart';
import 'package:pinball_ui/pinball_ui.dart';
import 'package:platform_helper/platform_helper.dart';
@visibleForTesting
enum Control {
left,
right,
down,
a,
d,
s,
space,
}
extension on Control {
bool get isArrow => isDown || isRight || isLeft;
bool get isDown => this == Control.down;
bool get isRight => this == Control.right;
bool get isLeft => this == Control.left;
bool get isSpace => this == Control.space;
String getCharacter(BuildContext context) {
switch (this) {
case Control.a:
return 'A';
case Control.d:
return 'D';
case Control.down:
return '>'; // Will be rotated
case Control.left:
return '<';
case Control.right:
return '>';
case Control.s:
return 'S';
case Control.space:
return context.l10n.space;
}
}
}
class HowToPlayDialog extends StatefulWidget {
HowToPlayDialog({
Key? key,
@visibleForTesting PlatformHelper? platformHelper,
}) : platformHelper = platformHelper ?? PlatformHelper(),
super(key: key);
final PlatformHelper platformHelper;
@override
State<HowToPlayDialog> createState() => _HowToPlayDialogState();
}
class _HowToPlayDialogState extends State<HowToPlayDialog> {
late Timer closeTimer;
@override
void initState() {
super.initState();
closeTimer = Timer(const Duration(seconds: 3), () {
if (mounted) {
Navigator.of(context).maybePop();
}
});
}
@override
void dispose() {
closeTimer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isMobile = widget.platformHelper.isMobile;
return PixelatedDecoration(
header: const _HowToPlayHeader(),
body: isMobile ? const _MobileBody() : const _DesktopBody(),
);
}
}
class _MobileBody extends StatelessWidget {
const _MobileBody({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final paddingWidth = MediaQuery.of(context).size.width * 0.15;
final paddingHeight = MediaQuery.of(context).size.height * 0.075;
return FittedBox(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: paddingWidth,
),
child: Column(
children: [
const _MobileLaunchControls(),
SizedBox(height: paddingHeight),
const _MobileFlipperControls(),
],
),
),
);
}
}
class _MobileLaunchControls extends StatelessWidget {
const _MobileLaunchControls({Key? key}) : super(key: key);
class HowToPlayDialog extends StatelessWidget {
const HowToPlayDialog({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
const textStyle = AppTextStyle.subtitle3;
return Column(
children: [
Text(
l10n.tapAndHoldRocket,
style: textStyle,
),
Text.rich(
TextSpan(
children: [
TextSpan(
text: '${l10n.to} ',
style: textStyle,
),
TextSpan(
text: l10n.launch,
style: textStyle.copyWith(
color: AppColors.blue,
),
),
],
),
),
],
);
}
}
class _MobileFlipperControls extends StatelessWidget {
const _MobileFlipperControls({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
const textStyle = AppTextStyle.subtitle3;
return Column(
children: [
Text(
l10n.tapLeftRightScreen,
style: textStyle,
),
Text.rich(
TextSpan(
children: [
TextSpan(
text: '${l10n.to} ',
style: textStyle,
),
TextSpan(
text: l10n.flip,
style: textStyle.copyWith(
color: AppColors.orange,
),
),
],
),
),
],
);
}
}
class _DesktopBody extends StatelessWidget {
const _DesktopBody({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
const spacing = SizedBox(height: 16);
return ListView(
children: const [
spacing,
_DesktopLaunchControls(),
spacing,
_DesktopFlipperControls(),
],
);
}
}
return PixelatedDecoration(
header: Text(l10n.howToPlay),
body: ListView(
children: const [
spacing,
_LaunchControls(),
spacing,
_FlipperControls(),
class _HowToPlayHeader extends StatelessWidget {
const _HowToPlayHeader({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
const headerTextStyle = AppTextStyle.title;
return FittedBox(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
l10n.howToPlay,
style: headerTextStyle.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
l10n.tipsForFlips,
style: headerTextStyle,
),
],
),
);
}
}
class _LaunchControls extends StatelessWidget {
const _LaunchControls({Key? key}) : super(key: key);
class _DesktopLaunchControls extends StatelessWidget {
const _DesktopLaunchControls({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
@ -36,15 +239,18 @@ class _LaunchControls extends StatelessWidget {
return Column(
children: [
Text(l10n.launchControls),
Text(
l10n.launchControls,
style: AppTextStyle.headline4,
),
const SizedBox(height: 10),
Wrap(
children: const [
KeyIndicator.fromIcon(keyIcon: Icons.keyboard_arrow_down),
KeyButton(control: Control.down),
spacing,
KeyIndicator.fromKeyName(keyName: 'SPACE'),
KeyButton(control: Control.space),
spacing,
KeyIndicator.fromKeyName(keyName: 'S'),
KeyButton(control: Control.s),
],
)
],
@ -52,8 +258,8 @@ class _LaunchControls extends StatelessWidget {
}
}
class _FlipperControls extends StatelessWidget {
const _FlipperControls({Key? key}) : super(key: key);
class _DesktopFlipperControls extends StatelessWidget {
const _DesktopFlipperControls({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
@ -62,7 +268,10 @@ class _FlipperControls extends StatelessWidget {
return Column(
children: [
Text(l10n.flipperControls),
Text(
l10n.flipperControls,
style: AppTextStyle.subtitle2,
),
const SizedBox(height: 10),
Column(
children: [
@ -70,17 +279,17 @@ class _FlipperControls extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: const [
KeyIndicator.fromIcon(keyIcon: Icons.keyboard_arrow_left),
KeyButton(control: Control.left),
rowSpacing,
KeyIndicator.fromIcon(keyIcon: Icons.keyboard_arrow_right),
KeyButton(control: Control.right),
],
),
const SizedBox(height: 8),
Wrap(
children: const [
KeyIndicator.fromKeyName(keyName: 'A'),
KeyButton(control: Control.a),
rowSpacing,
KeyIndicator.fromKeyName(keyName: 'D'),
KeyButton(control: Control.d),
],
)
],
@ -90,65 +299,46 @@ class _FlipperControls extends StatelessWidget {
}
}
// TODO(allisonryan0002): remove visibility when adding final UI.
@visibleForTesting
class KeyIndicator extends StatelessWidget {
const KeyIndicator._({
class KeyButton extends StatelessWidget {
const KeyButton({
Key? key,
required String keyName,
required IconData keyIcon,
required bool fromIcon,
}) : _keyName = keyName,
_keyIcon = keyIcon,
_fromIcon = fromIcon,
required Control control,
}) : _control = control,
super(key: key);
const KeyIndicator.fromKeyName({Key? key, required String keyName})
: this._(
key: key,
keyName: keyName,
keyIcon: Icons.keyboard_arrow_down,
fromIcon: false,
);
const KeyIndicator.fromIcon({Key? key, required IconData keyIcon})
: this._(
key: key,
keyName: '',
keyIcon: keyIcon,
fromIcon: true,
);
final String _keyName;
final IconData _keyIcon;
final bool _fromIcon;
final Control _control;
@override
Widget build(BuildContext context) {
const iconPadding = EdgeInsets.all(15);
const textPadding = EdgeInsets.symmetric(vertical: 20, horizontal: 22);
final boarderColor = Colors.blue.withOpacity(0.5);
final color = Colors.blue.withOpacity(0.7);
final textStyle =
_control.isArrow ? AppTextStyle.headline1 : AppTextStyle.headline3;
const height = 60.0;
final width = _control.isSpace ? height * 2.83 : height;
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
border: Border.all(
color: boarderColor,
width: 3,
image: DecorationImage(
fit: BoxFit.fill,
image: AssetImage(
_control.isSpace
? Assets.images.components.space.keyName
: Assets.images.components.key.keyName,
),
),
),
child: _fromIcon
? Padding(
padding: iconPadding,
child: Icon(_keyIcon, color: color),
)
: Padding(
padding: textPadding,
child: Text(_keyName, style: TextStyle(color: color)),
child: SizedBox(
width: width,
height: height,
child: Center(
child: RotatedBox(
quarterTurns: _control.isDown ? 1 : 0,
child: Text(
_control.getCharacter(context),
style: textStyle.copyWith(color: AppColors.white),
),
),
),
),
);
}
}

@ -7,7 +7,9 @@ abstract class AppColors {
static const Color darkBlue = Color(0xFF0C32A4);
static const Color orange = Color(0xFFFFEE02);
static const Color yellow = Color(0xFFFFEE02);
static const Color orange = Color(0xFFE5AB05);
static const Color blue = Color(0xFF4B94F6);

@ -27,6 +27,35 @@ abstract class AppTextStyle {
fontFamily: _primaryFontFamily,
);
static const headline4 = TextStyle(
color: AppColors.white,
fontSize: 16,
package: _fontPackage,
fontFamily: _primaryFontFamily,
);
static const title = TextStyle(
color: AppColors.darkBlue,
fontSize: 20,
package: _fontPackage,
fontFamily: _primaryFontFamily,
);
static const subtitle3 = TextStyle(
color: AppColors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
package: _fontPackage,
fontFamily: _primaryFontFamily,
);
static const subtitle2 = TextStyle(
color: AppColors.white,
fontSize: 16,
package: _fontPackage,
fontFamily: _primaryFontFamily,
);
static const subtitle1 = TextStyle(
fontSize: 10,
fontFamily: _primaryFontFamily,

@ -1,91 +1,6 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
/// {@template leaderboard_exception}
/// Base exception for leaderboard repository failures.
/// {@endtemplate}
abstract class LeaderboardException implements Exception {
/// {@macro leaderboard_exception}
const LeaderboardException(this.error, this.stackTrace);
/// The error that was caught.
final Object error;
/// The Stacktrace associated with the [error].
final StackTrace stackTrace;
}
/// {@template leaderboard_deserialization_exception}
/// Exception thrown when leaderboard data cannot be deserialized in the
/// expected way.
/// {@endtemplate}
class LeaderboardDeserializationException extends LeaderboardException {
/// {@macro leaderboard_deserialization_exception}
const LeaderboardDeserializationException(
Object error,
StackTrace stackTrace,
) : super(
error,
stackTrace,
);
}
/// {@template fetch_top_10_leaderboard_exception}
/// Exception thrown when failure occurs while fetching top 10 leaderboard.
/// {@endtemplate}
class FetchTop10LeaderboardException extends LeaderboardException {
/// {@macro fetch_top_10_leaderboard_exception}
const FetchTop10LeaderboardException(
Object error,
StackTrace stackTrace,
) : super(
error,
stackTrace,
);
}
/// {@template add_leaderboard_entry_exception}
/// Exception thrown when failure occurs while adding entry to leaderboard.
/// {@endtemplate}
class AddLeaderboardEntryException extends LeaderboardException {
/// {@macro add_leaderboard_entry_exception}
const AddLeaderboardEntryException(
Object error,
StackTrace stackTrace,
) : super(
error,
stackTrace,
);
}
/// {@template fetch_player_ranking_exception}
/// Exception thrown when failure occurs while fetching player ranking.
/// {@endtemplate}
class FetchPlayerRankingException extends LeaderboardException {
/// {@macro fetch_player_ranking_exception}
const FetchPlayerRankingException(
Object error,
StackTrace stackTrace,
) : super(
error,
stackTrace,
);
}
/// {@template fetch_prohibited_initials_exception}
/// Exception thrown when failure occurs while fetching prohibited initials.
/// {@endtemplate}
class FetchProhibitedInitialsException extends LeaderboardException {
/// {@macro fetch_prohibited_initials_exception}
const FetchProhibitedInitialsException(
Object error,
StackTrace stackTrace,
) : super(
error,
stackTrace,
);
}
/// {@template leaderboard_repository}
/// Repository to access leaderboard data in Firebase Cloud Firestore.
/// {@endtemplate}
@ -97,73 +12,40 @@ class LeaderboardRepository {
final FirebaseFirestore _firebaseFirestore;
static const _leaderboardLimit = 10;
static const _leaderboardCollectionName = 'leaderboard';
static const _scoreFieldName = 'score';
/// Acquires top 10 [LeaderboardEntryData]s.
Future<List<LeaderboardEntryData>> fetchTop10Leaderboard() async {
final leaderboardEntries = <LeaderboardEntryData>[];
late List<QueryDocumentSnapshot> documents;
try {
final querySnapshot = await _firebaseFirestore
.collection('leaderboard')
.orderBy('score', descending: true)
.limit(10)
.collection(_leaderboardCollectionName)
.orderBy(_scoreFieldName, descending: true)
.limit(_leaderboardLimit)
.get();
documents = querySnapshot.docs;
final documents = querySnapshot.docs;
return documents.toLeaderboard();
} on LeaderboardDeserializationException {
rethrow;
} on Exception catch (error, stackTrace) {
throw FetchTop10LeaderboardException(error, stackTrace);
}
for (final document in documents) {
final data = document.data() as Map<String, dynamic>?;
if (data != null) {
try {
leaderboardEntries.add(LeaderboardEntryData.fromJson(data));
} catch (error, stackTrace) {
throw LeaderboardDeserializationException(error, stackTrace);
}
}
}
return leaderboardEntries;
}
/// Adds player's score entry to the leaderboard and gets their
/// [LeaderboardRanking].
Future<LeaderboardRanking> addLeaderboardEntry(
/// Adds player's score entry to the leaderboard if it is within the top-10
Future<void> addLeaderboardEntry(
LeaderboardEntryData entry,
) async {
late DocumentReference entryReference;
try {
entryReference = await _firebaseFirestore
.collection('leaderboard')
.add(entry.toJson());
} on Exception catch (error, stackTrace) {
throw AddLeaderboardEntryException(error, stackTrace);
}
try {
final querySnapshot = await _firebaseFirestore
.collection('leaderboard')
.orderBy('score', descending: true)
.get();
// TODO(allisonryan0002): see if we can find a more performant solution.
final documents = querySnapshot.docs;
final ranking = documents.indexWhere(
(document) => document.id == entryReference.id,
) +
1;
if (ranking > 0) {
return LeaderboardRanking(ranking: ranking, outOf: documents.length);
} else {
throw FetchPlayerRankingException(
'Player score could not be found and ranking cannot be provided.',
StackTrace.current,
);
final leaderboard = await _fetchLeaderboardSortedByScore();
if (leaderboard.length < 10) {
await _saveScore(entry);
} else {
final tenthPositionScore = leaderboard[9].score;
if (entry.score > tenthPositionScore) {
await _saveScore(entry);
await _deleteScoresUnder(tenthPositionScore);
}
} on Exception catch (error, stackTrace) {
throw FetchPlayerRankingException(error, stackTrace);
}
}
@ -174,7 +56,6 @@ class LeaderboardRepository {
if (!initialsRegex.hasMatch(initials)) {
return false;
}
try {
final document = await _firebaseFirestore
.collection('prohibitedInitials')
@ -187,4 +68,61 @@ class LeaderboardRepository {
throw FetchProhibitedInitialsException(error, stackTrace);
}
}
Future<List<LeaderboardEntryData>> _fetchLeaderboardSortedByScore() async {
try {
final querySnapshot = await _firebaseFirestore
.collection(_leaderboardCollectionName)
.orderBy(_scoreFieldName, descending: true)
.get();
final documents = querySnapshot.docs;
return documents.toLeaderboard();
} on Exception catch (error, stackTrace) {
throw FetchLeaderboardException(error, stackTrace);
}
}
Future<void> _saveScore(LeaderboardEntryData entry) {
try {
return _firebaseFirestore
.collection(_leaderboardCollectionName)
.add(entry.toJson());
} on Exception catch (error, stackTrace) {
throw AddLeaderboardEntryException(error, stackTrace);
}
}
Future<void> _deleteScoresUnder(int score) async {
try {
final querySnapshot = await _firebaseFirestore
.collection(_leaderboardCollectionName)
.where(_scoreFieldName, isLessThanOrEqualTo: score)
.get();
final documents = querySnapshot.docs;
for (final document in documents) {
await document.reference.delete();
}
} on LeaderboardDeserializationException {
rethrow;
} on Exception catch (error, stackTrace) {
throw DeleteLeaderboardException(error, stackTrace);
}
}
}
extension on List<QueryDocumentSnapshot> {
List<LeaderboardEntryData> toLeaderboard() {
final leaderboardEntries = <LeaderboardEntryData>[];
for (final document in this) {
final data = document.data() as Map<String, dynamic>?;
if (data != null) {
try {
leaderboardEntries.add(LeaderboardEntryData.fromJson(data));
} catch (error, stackTrace) {
throw LeaderboardDeserializationException(error, stackTrace);
}
}
}
return leaderboardEntries;
}
}

@ -0,0 +1,69 @@
/// {@template leaderboard_exception}
/// Base exception for leaderboard repository failures.
/// {@endtemplate}
abstract class LeaderboardException implements Exception {
/// {@macro leaderboard_exception}
const LeaderboardException(this.error, this.stackTrace);
/// The error that was caught.
final Object error;
/// The Stacktrace associated with the [error].
final StackTrace stackTrace;
}
/// {@template leaderboard_deserialization_exception}
/// Exception thrown when leaderboard data cannot be deserialized in the
/// expected way.
/// {@endtemplate}
class LeaderboardDeserializationException extends LeaderboardException {
/// {@macro leaderboard_deserialization_exception}
const LeaderboardDeserializationException(Object error, StackTrace stackTrace)
: super(error, stackTrace);
}
/// {@template fetch_top_10_leaderboard_exception}
/// Exception thrown when failure occurs while fetching top 10 leaderboard.
/// {@endtemplate}
class FetchTop10LeaderboardException extends LeaderboardException {
/// {@macro fetch_top_10_leaderboard_exception}
const FetchTop10LeaderboardException(Object error, StackTrace stackTrace)
: super(error, stackTrace);
}
/// {@template fetch_leaderboard_exception}
/// Exception thrown when failure occurs while fetching the leaderboard.
/// {@endtemplate}
class FetchLeaderboardException extends LeaderboardException {
/// {@macro fetch_top_10_leaderboard_exception}
const FetchLeaderboardException(Object error, StackTrace stackTrace)
: super(error, stackTrace);
}
/// {@template delete_leaderboard_exception}
/// Exception thrown when failure occurs while deleting the leaderboard under
/// the tenth position.
/// {@endtemplate}
class DeleteLeaderboardException extends LeaderboardException {
/// {@macro fetch_top_10_leaderboard_exception}
const DeleteLeaderboardException(Object error, StackTrace stackTrace)
: super(error, stackTrace);
}
/// {@template add_leaderboard_entry_exception}
/// Exception thrown when failure occurs while adding entry to leaderboard.
/// {@endtemplate}
class AddLeaderboardEntryException extends LeaderboardException {
/// {@macro add_leaderboard_entry_exception}
const AddLeaderboardEntryException(Object error, StackTrace stackTrace)
: super(error, stackTrace);
}
/// {@template fetch_prohibited_initials_exception}
/// Exception thrown when failure occurs while fetching prohibited initials.
/// {@endtemplate}
class FetchProhibitedInitialsException extends LeaderboardException {
/// {@macro fetch_prohibited_initials_exception}
const FetchProhibitedInitialsException(Object error, StackTrace stackTrace)
: super(error, stackTrace);
}

@ -1,20 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
/// {@template leaderboard_ranking}
/// Contains [ranking] for a single [LeaderboardEntryData] and the number of
/// players the [ranking] is [outOf].
/// {@endtemplate}
class LeaderboardRanking extends Equatable {
/// {@macro leaderboard_ranking}
const LeaderboardRanking({required this.ranking, required this.outOf});
/// Place ranking by score for a [LeaderboardEntryData].
final int ranking;
/// Number of [LeaderboardEntryData]s at the time of score entry.
final int outOf;
@override
List<Object> get props => [ranking, outOf];
}

@ -1,2 +1,2 @@
export 'exceptions.dart';
export 'leaderboard_entry_data.dart';
export 'leaderboard_ranking.dart';

@ -153,7 +153,6 @@ void main() {
character: CharacterType.dash,
);
const entryDocumentId = 'id$entryScore';
final ranking = LeaderboardRanking(ranking: 3, outOf: 4);
setUp(() {
leaderboardRepository = LeaderboardRepository(firestore);
@ -165,13 +164,12 @@ void main() {
final queryDocumentSnapshot = MockQueryDocumentSnapshot();
when(queryDocumentSnapshot.data).thenReturn(<String, dynamic>{
'character': 'dash',
'username': 'user$score',
'playerInitials': 'AAA',
'score': score
});
when(() => queryDocumentSnapshot.id).thenReturn('id$score');
return queryDocumentSnapshot;
}).toList();
when(() => firestore.collection('leaderboard'))
.thenAnswer((_) => collectionReference);
when(() => collectionReference.add(any()))
@ -184,19 +182,29 @@ void main() {
});
test(
'adds leaderboard entry and returns player ranking when '
'firestore operations succeed', () async {
final rankingResult =
await leaderboardRepository.addLeaderboardEntry(leaderboardEntry);
expect(rankingResult, equals(ranking));
'throws FetchLeaderboardException '
'when querying the leaderboard fails', () {
when(() => firestore.collection('leaderboard')).thenThrow(Exception());
expect(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
throwsA(isA<FetchLeaderboardException>()),
);
});
test(
'throws AddLeaderboardEntryException when Exception occurs '
'when trying to add entry to firestore', () async {
when(() => firestore.collection('leaderboard')).thenThrow(Exception());
'saves the new score if the existing leaderboard '
'has less than 10 scores', () async {
await leaderboardRepository.addLeaderboardEntry(leaderboardEntry);
verify(
() => collectionReference.add(leaderboardEntry.toJson()),
).called(1);
});
test(
'throws AddLeaderboardEntryException '
'when adding a new entry fails', () async {
when(() => collectionReference.add(leaderboardEntry.toJson()))
.thenThrow(Exception('oops'));
expect(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
throwsA(isA<AddLeaderboardEntryException>()),
@ -204,26 +212,160 @@ void main() {
});
test(
'throws FetchPlayerRankingException when Exception occurs '
'when trying to retrieve information from firestore', () async {
when(() => collectionReference.orderBy('score', descending: true))
.thenThrow(Exception());
'does nothing if there are more than 10 scores in the leaderboard '
'and the new score is smaller than the top 10', () async {
final leaderboardScores = [
10000,
9500,
9000,
8500,
8000,
7500,
7000,
6500,
6000,
5500,
5000
];
final queryDocumentSnapshots = leaderboardScores.map((score) {
final queryDocumentSnapshot = MockQueryDocumentSnapshot();
when(queryDocumentSnapshot.data).thenReturn(<String, dynamic>{
'character': 'dash',
'playerInitials': 'AAA',
'score': score
});
when(() => queryDocumentSnapshot.id).thenReturn('id$score');
return queryDocumentSnapshot;
}).toList();
when(() => querySnapshot.docs).thenReturn(queryDocumentSnapshots);
expect(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
throwsA(isA<FetchPlayerRankingException>()),
await leaderboardRepository.addLeaderboardEntry(leaderboardEntry);
verifyNever(
() => collectionReference.add(leaderboardEntry.toJson()),
);
});
test(
'throws FetchPlayerRankingException when score cannot be found '
'in firestore leaderboard data', () async {
when(() => documentReference.id).thenReturn('nonexistentDocumentId');
'throws DeleteLeaderboardException '
'when deleting scores outside the top 10 fails', () async {
final deleteQuery = MockQuery();
final deleteQuerySnapshot = MockQuerySnapshot();
final newScore = LeaderboardEntryData(
playerInitials: 'ABC',
score: 15000,
character: CharacterType.android,
);
final leaderboardScores = [
10000,
9500,
9000,
8500,
8000,
7500,
7000,
6500,
6000,
5500,
5000,
];
final deleteDocumentSnapshots = [5500, 5000].map((score) {
final queryDocumentSnapshot = MockQueryDocumentSnapshot();
when(queryDocumentSnapshot.data).thenReturn(<String, dynamic>{
'character': 'dash',
'playerInitials': 'AAA',
'score': score
});
when(() => queryDocumentSnapshot.id).thenReturn('id$score');
when(() => queryDocumentSnapshot.reference)
.thenReturn(documentReference);
return queryDocumentSnapshot;
}).toList();
when(deleteQuery.get).thenAnswer((_) async => deleteQuerySnapshot);
when(() => deleteQuerySnapshot.docs)
.thenReturn(deleteDocumentSnapshots);
final queryDocumentSnapshots = leaderboardScores.map((score) {
final queryDocumentSnapshot = MockQueryDocumentSnapshot();
when(queryDocumentSnapshot.data).thenReturn(<String, dynamic>{
'character': 'dash',
'playerInitials': 'AAA',
'score': score
});
when(() => queryDocumentSnapshot.id).thenReturn('id$score');
when(() => queryDocumentSnapshot.reference)
.thenReturn(documentReference);
return queryDocumentSnapshot;
}).toList();
when(
() => collectionReference.where('score', isLessThanOrEqualTo: 5500),
).thenAnswer((_) => deleteQuery);
when(() => documentReference.delete()).thenThrow(Exception('oops'));
when(() => querySnapshot.docs).thenReturn(queryDocumentSnapshots);
expect(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
throwsA(isA<FetchPlayerRankingException>()),
() => leaderboardRepository.addLeaderboardEntry(newScore),
throwsA(isA<DeleteLeaderboardException>()),
);
});
test(
'saves the new score when there are more than 10 scores in the '
'leaderboard and the new score is higher than the lowest top 10, and '
'deletes the scores that are not in the top 10 anymore', () async {
final deleteQuery = MockQuery();
final deleteQuerySnapshot = MockQuerySnapshot();
final newScore = LeaderboardEntryData(
playerInitials: 'ABC',
score: 15000,
character: CharacterType.android,
);
final leaderboardScores = [
10000,
9500,
9000,
8500,
8000,
7500,
7000,
6500,
6000,
5500,
5000,
];
final deleteDocumentSnapshots = [5500, 5000].map((score) {
final queryDocumentSnapshot = MockQueryDocumentSnapshot();
when(queryDocumentSnapshot.data).thenReturn(<String, dynamic>{
'character': 'dash',
'playerInitials': 'AAA',
'score': score
});
when(() => queryDocumentSnapshot.id).thenReturn('id$score');
when(() => queryDocumentSnapshot.reference)
.thenReturn(documentReference);
return queryDocumentSnapshot;
}).toList();
when(deleteQuery.get).thenAnswer((_) async => deleteQuerySnapshot);
when(() => deleteQuerySnapshot.docs)
.thenReturn(deleteDocumentSnapshots);
final queryDocumentSnapshots = leaderboardScores.map((score) {
final queryDocumentSnapshot = MockQueryDocumentSnapshot();
when(queryDocumentSnapshot.data).thenReturn(<String, dynamic>{
'character': 'dash',
'playerInitials': 'AAA',
'score': score
});
when(() => queryDocumentSnapshot.id).thenReturn('id$score');
when(() => queryDocumentSnapshot.reference)
.thenReturn(documentReference);
return queryDocumentSnapshot;
}).toList();
when(
() => collectionReference.where('score', isLessThanOrEqualTo: 5500),
).thenAnswer((_) => deleteQuery);
when(() => documentReference.delete())
.thenAnswer((_) async => Future.value());
when(() => querySnapshot.docs).thenReturn(queryDocumentSnapshots);
await leaderboardRepository.addLeaderboardEntry(newScore);
verify(() => collectionReference.add(newScore.toJson())).called(1);
verify(() => documentReference.delete()).called(2);
});
});

@ -1,19 +0,0 @@
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:test/test.dart';
void main() {
group('LeaderboardRanking', () {
test('can be instantiated', () {
const leaderboardRanking = LeaderboardRanking(ranking: 1, outOf: 1);
expect(leaderboardRanking, isNotNull);
});
test('supports value equality.', () {
const leaderboardRanking = LeaderboardRanking(ranking: 1, outOf: 1);
const leaderboardRanking2 = LeaderboardRanking(ranking: 1, outOf: 1);
expect(leaderboardRanking, equals(leaderboardRanking2));
});
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@ -23,6 +23,8 @@ class $AssetsImagesGen {
$AssetsImagesKickerGen get kicker => const $AssetsImagesKickerGen();
$AssetsImagesLaunchRampGen get launchRamp =>
const $AssetsImagesLaunchRampGen();
$AssetsImagesMultiplierGen get multiplier =>
const $AssetsImagesMultiplierGen();
$AssetsImagesPlungerGen get plunger => const $AssetsImagesPlungerGen();
$AssetsImagesSignpostGen get signpost => const $AssetsImagesSignpostGen();
$AssetsImagesSlingshotGen get slingshot => const $AssetsImagesSlingshotGen();
@ -188,6 +190,16 @@ class $AssetsImagesLaunchRampGen {
const AssetGenImage('assets/images/launch_ramp/ramp.png');
}
class $AssetsImagesMultiplierGen {
const $AssetsImagesMultiplierGen();
$AssetsImagesMultiplierX2Gen get x2 => const $AssetsImagesMultiplierX2Gen();
$AssetsImagesMultiplierX3Gen get x3 => const $AssetsImagesMultiplierX3Gen();
$AssetsImagesMultiplierX4Gen get x4 => const $AssetsImagesMultiplierX4Gen();
$AssetsImagesMultiplierX5Gen get x5 => const $AssetsImagesMultiplierX5Gen();
$AssetsImagesMultiplierX6Gen get x6 => const $AssetsImagesMultiplierX6Gen();
}
class $AssetsImagesPlungerGen {
const $AssetsImagesPlungerGen();
@ -252,6 +264,8 @@ class $AssetsImagesAndroidBumperGen {
const $AssetsImagesAndroidBumperAGen();
$AssetsImagesAndroidBumperBGen get b =>
const $AssetsImagesAndroidBumperBGen();
$AssetsImagesAndroidBumperCowGen get cow =>
const $AssetsImagesAndroidBumperCowGen();
}
class $AssetsImagesAndroidRailGen {
@ -326,6 +340,66 @@ class $AssetsImagesDinoAnimatronicGen {
const AssetGenImage('assets/images/dino/animatronic/mouth.png');
}
class $AssetsImagesMultiplierX2Gen {
const $AssetsImagesMultiplierX2Gen();
/// File path: assets/images/multiplier/x2/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/multiplier/x2/dimmed.png');
/// File path: assets/images/multiplier/x2/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/multiplier/x2/lit.png');
}
class $AssetsImagesMultiplierX3Gen {
const $AssetsImagesMultiplierX3Gen();
/// File path: assets/images/multiplier/x3/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/multiplier/x3/dimmed.png');
/// File path: assets/images/multiplier/x3/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/multiplier/x3/lit.png');
}
class $AssetsImagesMultiplierX4Gen {
const $AssetsImagesMultiplierX4Gen();
/// File path: assets/images/multiplier/x4/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/multiplier/x4/dimmed.png');
/// File path: assets/images/multiplier/x4/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/multiplier/x4/lit.png');
}
class $AssetsImagesMultiplierX5Gen {
const $AssetsImagesMultiplierX5Gen();
/// File path: assets/images/multiplier/x5/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/multiplier/x5/dimmed.png');
/// File path: assets/images/multiplier/x5/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/multiplier/x5/lit.png');
}
class $AssetsImagesMultiplierX6Gen {
const $AssetsImagesMultiplierX6Gen();
/// File path: assets/images/multiplier/x6/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/multiplier/x6/dimmed.png');
/// File path: assets/images/multiplier/x6/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/multiplier/x6/lit.png');;
}
class $AssetsImagesSparkyBumperGen {
const $AssetsImagesSparkyBumperGen();
@ -370,6 +444,18 @@ class $AssetsImagesAndroidBumperBGen {
const AssetGenImage('assets/images/android/bumper/b/lit.png');
}
class $AssetsImagesAndroidBumperCowGen {
const $AssetsImagesAndroidBumperCowGen();
/// File path: assets/images/android/bumper/cow/dimmed.png
AssetGenImage get dimmed =>
const AssetGenImage('assets/images/android/bumper/cow/dimmed.png');
/// File path: assets/images/android/bumper/cow/lit.png
AssetGenImage get lit =>
const AssetGenImage('assets/images/android/bumper/cow/lit.png');
}
class $AssetsImagesAndroidRampArrowGen {
const $AssetsImagesAndroidRampArrowGen();

@ -19,6 +19,7 @@ class AndroidBumper extends BodyComponent with InitialPosition {
required double minorRadius,
required String litAssetPath,
required String dimmedAssetPath,
required Vector2 spritePosition,
Iterable<Component>? children,
required this.bloc,
}) : _majorRadius = majorRadius,
@ -32,6 +33,7 @@ class AndroidBumper extends BodyComponent with InitialPosition {
_AndroidBumperSpriteGroupComponent(
dimmedAssetPath: dimmedAssetPath,
litAssetPath: litAssetPath,
position: spritePosition,
state: bloc.state,
),
...?children,
@ -46,6 +48,7 @@ class AndroidBumper extends BodyComponent with InitialPosition {
minorRadius: 2.97,
litAssetPath: Assets.images.android.bumper.a.lit.keyName,
dimmedAssetPath: Assets.images.android.bumper.a.dimmed.keyName,
spritePosition: Vector2(0, -0.1),
bloc: AndroidBumperCubit(),
children: children,
);
@ -58,6 +61,20 @@ class AndroidBumper extends BodyComponent with InitialPosition {
minorRadius: 2.79,
litAssetPath: Assets.images.android.bumper.b.lit.keyName,
dimmedAssetPath: Assets.images.android.bumper.b.dimmed.keyName,
spritePosition: Vector2(0, -0.1),
bloc: AndroidBumperCubit(),
children: children,
);
/// {@macro android_bumper}
AndroidBumper.cow({
Iterable<Component>? children,
}) : this._(
majorRadius: 3.4,
minorRadius: 2.9,
litAssetPath: Assets.images.android.bumper.cow.lit.keyName,
dimmedAssetPath: Assets.images.android.bumper.cow.dimmed.keyName,
spritePosition: Vector2(0, -0.68),
bloc: AndroidBumperCubit(),
children: children,
);
@ -113,12 +130,13 @@ class _AndroidBumperSpriteGroupComponent
_AndroidBumperSpriteGroupComponent({
required String litAssetPath,
required String dimmedAssetPath,
required Vector2 position,
required AndroidBumperState state,
}) : _litAssetPath = litAssetPath,
_dimmedAssetPath = dimmedAssetPath,
super(
anchor: Anchor.center,
position: Vector2(0, -0.1),
position: position,
current: state,
);

@ -5,7 +5,7 @@ import 'package:bloc/bloc.dart';
part 'android_bumper_state.dart';
class AndroidBumperCubit extends Cubit<AndroidBumperState> {
AndroidBumperCubit() : super(AndroidBumperState.dimmed);
AndroidBumperCubit() : super(AndroidBumperState.lit);
void onBallContacted() {
emit(AndroidBumperState.dimmed);

@ -1,4 +1,5 @@
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template bumping_behavior}
@ -11,15 +12,22 @@ class BumpingBehavior extends ContactBehavior {
/// Determines how strong the bump is.
final double _strength;
/// This is used to recoginze the current state of a contact manifold in world
/// coordinates.
@visibleForTesting
final WorldManifold worldManifold = WorldManifold();
@override
void postSolve(Object other, Contact contact, ContactImpulse impulse) {
super.postSolve(other, contact, impulse);
if (other is! BodyComponent) return;
contact.getWorldManifold(worldManifold);
other.body.applyLinearImpulse(
contact.manifold.localPoint
..normalize()
..multiply(Vector2.all(other.body.mass * _strength)),
worldManifold.normal
..multiply(
Vector2.all(other.body.mass * _strength),
),
);
}
}

@ -20,6 +20,7 @@ export 'kicker.dart';
export 'launch_ramp.dart';
export 'layer.dart';
export 'layer_sensor.dart';
export 'multiplier/multiplier.dart';
export 'plunger.dart';
export 'render_priority.dart';
export 'rocket.dart';

@ -13,12 +13,14 @@ export 'cubit/google_letter_cubit.dart';
class GoogleLetter extends BodyComponent with InitialPosition {
/// {@macro google_letter}
GoogleLetter(
int index,
) : bloc = GoogleLetterCubit(),
int index, {
Iterable<Component>? children,
}) : bloc = GoogleLetterCubit(),
super(
children: [
GoogleLetterBallContactBehavior(),
_GoogleLetterSprite(_GoogleLetterSprite.spritePaths[index])
_GoogleLetterSprite(_GoogleLetterSprite.spritePaths[index]),
...?children,
],
);

@ -16,9 +16,13 @@ class Kicker extends BodyComponent with InitialPosition {
/// {@macro kicker}
Kicker({
required BoardSide side,
Iterable<Component>? children,
}) : _side = side,
super(
children: [_KickerSpriteComponent(side: side)],
children: [
_KickerSpriteComponent(side: side),
...?children,
],
renderBody: false,
);

@ -0,0 +1,25 @@
// ignore_for_file: public_member_api_docs
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:pinball_components/pinball_components.dart';
part 'multiplier_state.dart';
class MultiplierCubit extends Cubit<MultiplierState> {
MultiplierCubit(MultiplierValue multiplierValue)
: super(MultiplierState.initial(multiplierValue));
/// Event added when the game's current multiplier changes.
void next(int multiplier) {
if (state.value.equals(multiplier)) {
if (state.spriteState == MultiplierSpriteState.dimmed) {
emit(state.copyWith(spriteState: MultiplierSpriteState.lit));
}
} else {
if (state.spriteState == MultiplierSpriteState.lit) {
emit(state.copyWith(spriteState: MultiplierSpriteState.dimmed));
}
}
}
}

@ -0,0 +1,56 @@
// ignore_for_file: public_member_api_docs
part of 'multiplier_cubit.dart';
enum MultiplierSpriteState {
lit,
dimmed,
}
class MultiplierState extends Equatable {
const MultiplierState({
required this.value,
required this.spriteState,
});
const MultiplierState.initial(MultiplierValue multiplierValue)
: this(
value: multiplierValue,
spriteState: MultiplierSpriteState.dimmed,
);
/// Current value for the [Multiplier]
final MultiplierValue value;
/// The [MultiplierSpriteGroupComponent] current sprite state
final MultiplierSpriteState spriteState;
MultiplierState copyWith({
MultiplierSpriteState? spriteState,
}) {
return MultiplierState(
value: value,
spriteState: spriteState ?? this.spriteState,
);
}
@override
List<Object> get props => [value, spriteState];
}
extension MultiplierValueX on MultiplierValue {
bool equals(int value) {
switch (this) {
case MultiplierValue.x2:
return value == 2;
case MultiplierValue.x3:
return value == 3;
case MultiplierValue.x4:
return value == 4;
case MultiplierValue.x5:
return value == 5;
case MultiplierValue.x6:
return value == 6;
}
}
}

@ -0,0 +1,204 @@
// ignore_for_file: public_member_api_docs
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import 'package:pinball_components/gen/assets.gen.dart';
import 'package:pinball_components/src/components/multiplier/cubit/multiplier_cubit.dart';
import 'package:pinball_flame/pinball_flame.dart';
export 'cubit/multiplier_cubit.dart';
/// {@template multiplier}
/// Backlit multiplier decal displayed on the board.
/// {@endtemplate}
class Multiplier extends Component {
/// {@macro multiplier}
Multiplier._({
required MultiplierValue value,
required Vector2 position,
required double angle,
required this.bloc,
}) : _value = value,
_position = position,
_angle = angle,
super();
/// {@macro multiplier}
Multiplier.x2({
required Vector2 position,
required double angle,
}) : this._(
value: MultiplierValue.x2,
position: position,
angle: angle,
bloc: MultiplierCubit(MultiplierValue.x2),
);
/// {@macro multiplier}
Multiplier.x3({
required Vector2 position,
required double angle,
}) : this._(
value: MultiplierValue.x3,
position: position,
angle: angle,
bloc: MultiplierCubit(MultiplierValue.x3),
);
/// {@macro multiplier}
Multiplier.x4({
required Vector2 position,
required double angle,
}) : this._(
value: MultiplierValue.x4,
position: position,
angle: angle,
bloc: MultiplierCubit(MultiplierValue.x4),
);
/// {@macro multiplier}
Multiplier.x5({
required Vector2 position,
required double angle,
}) : this._(
value: MultiplierValue.x5,
position: position,
angle: angle,
bloc: MultiplierCubit(MultiplierValue.x5),
);
/// {@macro multiplier}
Multiplier.x6({
required Vector2 position,
required double angle,
}) : this._(
value: MultiplierValue.x6,
position: position,
angle: angle,
bloc: MultiplierCubit(MultiplierValue.x6),
);
/// Creates a [Multiplier] without any children.
///
/// This can be used for testing [Multiplier]'s behaviors in isolation.
// TODO(alestiago): Refactor injecting bloc once the following is merged:
// https://github.com/flame-engine/flame/pull/1538
@visibleForTesting
Multiplier.test({
required MultiplierValue value,
required this.bloc,
}) : _value = value,
_position = Vector2.zero(),
_angle = 0;
// TODO(ruimiguel): Consider refactoring once the following is merged:
// https://github.com/flame-engine/flame/pull/1538
final MultiplierCubit bloc;
final MultiplierValue _value;
final Vector2 _position;
final double _angle;
late final MultiplierSpriteGroupComponent _sprite;
@override
void onRemove() {
bloc.close();
super.onRemove();
}
@override
Future<void> onLoad() async {
await super.onLoad();
_sprite = MultiplierSpriteGroupComponent(
position: _position,
litAssetPath: _value.litAssetPath,
dimmedAssetPath: _value.dimmedAssetPath,
angle: _angle,
current: bloc.state,
);
await add(_sprite);
}
}
/// Available multiplier values.
enum MultiplierValue {
x2,
x3,
x4,
x5,
x6,
}
extension on MultiplierValue {
String get litAssetPath {
switch (this) {
case MultiplierValue.x2:
return Assets.images.multiplier.x2.lit.keyName;
case MultiplierValue.x3:
return Assets.images.multiplier.x3.lit.keyName;
case MultiplierValue.x4:
return Assets.images.multiplier.x4.lit.keyName;
case MultiplierValue.x5:
return Assets.images.multiplier.x5.lit.keyName;
case MultiplierValue.x6:
return Assets.images.multiplier.x6.lit.keyName;
}
}
String get dimmedAssetPath {
switch (this) {
case MultiplierValue.x2:
return Assets.images.multiplier.x2.dimmed.keyName;
case MultiplierValue.x3:
return Assets.images.multiplier.x3.dimmed.keyName;
case MultiplierValue.x4:
return Assets.images.multiplier.x4.dimmed.keyName;
case MultiplierValue.x5:
return Assets.images.multiplier.x5.dimmed.keyName;
case MultiplierValue.x6:
return Assets.images.multiplier.x6.dimmed.keyName;
}
}
}
/// {@template multiplier_sprite_group_component}
/// A [SpriteGroupComponent] for a [Multiplier] with lit and dimmed states.
/// {@endtemplate}
@visibleForTesting
class MultiplierSpriteGroupComponent
extends SpriteGroupComponent<MultiplierSpriteState>
with HasGameRef, ParentIsA<Multiplier> {
/// {@macro multiplier_sprite_group_component}
MultiplierSpriteGroupComponent({
required Vector2 position,
required String litAssetPath,
required String dimmedAssetPath,
required double angle,
required MultiplierState current,
}) : _litAssetPath = litAssetPath,
_dimmedAssetPath = dimmedAssetPath,
super(
anchor: Anchor.center,
position: position,
angle: angle,
current: current.spriteState,
);
final String _litAssetPath;
final String _dimmedAssetPath;
@override
Future<void> onLoad() async {
await super.onLoad();
parent.bloc.stream.listen((state) => current = state.spriteState);
final sprites = {
MultiplierSpriteState.lit:
Sprite(gameRef.images.fromCache(_litAssetPath)),
MultiplierSpriteState.dimmed:
Sprite(gameRef.images.fromCache(_dimmedAssetPath)),
};
this.sprites = sprites;
size = sprites[current]!.originalSize / 10;
}
}

@ -62,6 +62,7 @@ flutter:
- assets/images/android/ramp/arrow/
- assets/images/android/bumper/a/
- assets/images/android/bumper/b/
- assets/images/android/bumper/cow/
- assets/images/kicker/
- assets/images/plunger/
- assets/images/slingshot/
@ -73,6 +74,11 @@ flutter:
- assets/images/backboard/
- assets/images/google_word/
- assets/images/signpost/
- assets/images/multiplier/x2/
- assets/images/multiplier/x3/
- assets/images/multiplier/x4/
- assets/images/multiplier/x5/
- assets/images/multiplier/x6/
flutter_gen:
line_length: 80

@ -27,6 +27,7 @@ void main() {
addScoreTextStories(dashbook);
addBackboardStories(dashbook);
addDinoWallStories(dashbook);
addMultipliersStories(dashbook);
runApp(dashbook);
}

@ -0,0 +1,33 @@
import 'dart:async';
import 'package:flame/extensions.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:sandbox/stories/ball/basic_ball_game.dart';
class AndroidBumperCowGame extends BallGame {
AndroidBumperCowGame()
: super(
imagesFileNames: [
Assets.images.androidBumper.cow.lit.keyName,
Assets.images.androidBumper.cow.dimmed.keyName,
],
);
static const description = '''
Shows how a AndroidBumper.cow is rendered.
- Activate the "trace" parameter to overlay the body.
''';
@override
Future<void> onLoad() async {
await super.onLoad();
camera.followVector2(Vector2.zero());
await add(
AndroidBumper.cow()..priority = 1,
);
await traceAllBodies();
}
}

@ -2,6 +2,7 @@ import 'package:dashbook/dashbook.dart';
import 'package:sandbox/common/common.dart';
import 'package:sandbox/stories/android_acres/android_bumper_a_game.dart';
import 'package:sandbox/stories/android_acres/android_bumper_b_game.dart';
import 'package:sandbox/stories/android_acres/android_bumper_cow_game.dart';
import 'package:sandbox/stories/android_acres/android_spaceship_game.dart';
import 'package:sandbox/stories/android_acres/spaceship_rail_game.dart';
import 'package:sandbox/stories/android_acres/spaceship_ramp_game.dart';
@ -18,6 +19,11 @@ void addAndroidAcresStories(Dashbook dashbook) {
description: AndroidBumperBGame.description,
gameBuilder: (_) => AndroidBumperBGame(),
)
..addGame(
title: 'Android Bumper Cow',
description: AndroidBumperCowGame.description,
gameBuilder: (_) => AndroidBumperCowGame(),
)
..addGame(
title: 'Android Spaceship',
description: AndroidSpaceshipGame.description,

@ -0,0 +1,97 @@
import 'dart:math' as math;
import 'package:flame/input.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:sandbox/stories/ball/basic_ball_game.dart';
class MultipliersGame extends BallGame with KeyboardEvents {
MultipliersGame()
: super(
imagesFileNames: [
Assets.images.multiplier.x2.lit.keyName,
Assets.images.multiplier.x2.dimmed.keyName,
Assets.images.multiplier.x3.lit.keyName,
Assets.images.multiplier.x3.dimmed.keyName,
Assets.images.multiplier.x4.lit.keyName,
Assets.images.multiplier.x4.dimmed.keyName,
Assets.images.multiplier.x5.lit.keyName,
Assets.images.multiplier.x5.dimmed.keyName,
Assets.images.multiplier.x6.lit.keyName,
Assets.images.multiplier.x6.dimmed.keyName,
],
);
static const description = '''
Shows how the Multipliers are rendered.
- Tap anywhere on the screen to spawn a ball into the game.
- Press digits 2 to 6 for toggle state multipliers 2 to 6.
''';
final List<Multiplier> multipliers = [
Multiplier.x2(
position: Vector2(-20, 0),
angle: -15 * math.pi / 180,
),
Multiplier.x3(
position: Vector2(20, -5),
angle: 15 * math.pi / 180,
),
Multiplier.x4(
position: Vector2(0, -15),
angle: 0,
),
Multiplier.x5(
position: Vector2(-10, -25),
angle: -3 * math.pi / 180,
),
Multiplier.x6(
position: Vector2(10, -35),
angle: 8 * math.pi / 180,
),
];
@override
Future<void> onLoad() async {
await super.onLoad();
camera.followVector2(Vector2.zero());
await addAll(multipliers);
await traceAllBodies();
}
@override
KeyEventResult onKeyEvent(
RawKeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
if (event is RawKeyDownEvent) {
var currentMultiplier = 1;
if (event.logicalKey == LogicalKeyboardKey.digit2) {
currentMultiplier = 2;
}
if (event.logicalKey == LogicalKeyboardKey.digit3) {
currentMultiplier = 3;
}
if (event.logicalKey == LogicalKeyboardKey.digit4) {
currentMultiplier = 4;
}
if (event.logicalKey == LogicalKeyboardKey.digit5) {
currentMultiplier = 5;
}
if (event.logicalKey == LogicalKeyboardKey.digit6) {
currentMultiplier = 6;
}
for (final multiplier in multipliers) {
multiplier.bloc.next(currentMultiplier);
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
}

@ -0,0 +1,11 @@
import 'package:dashbook/dashbook.dart';
import 'package:sandbox/common/common.dart';
import 'package:sandbox/stories/multipliers/multipliers_game.dart';
void addMultipliersStories(Dashbook dashbook) {
dashbook.storiesOf('Multipliers').addGame(
title: 'Multipliers',
description: MultipliersGame.description,
gameBuilder: (_) => MultipliersGame(),
);
}

@ -10,6 +10,7 @@ export 'flutter_forest/stories.dart';
export 'google_word/stories.dart';
export 'launch_ramp/stories.dart';
export 'layer/stories.dart';
export 'multipliers/stories.dart';
export 'plunger/stories.dart';
export 'score_text/stories.dart';
export 'slingshot/stories.dart';

@ -15,6 +15,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.8.2"
bloc:
dependency: transitive
description:
name: bloc
url: "https://pub.dartlang.org"
source: hosted
version: "8.0.3"
boolean_selector:
dependency: transitive
description:
@ -171,7 +178,7 @@ packages:
name: js
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.4"
version: "0.6.3"
json_annotation:
dependency: transitive
description:
@ -199,7 +206,7 @@ packages:
name: material_color_utilities
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.4"
version: "0.1.3"
meta:
dependency: transitive
description:
@ -220,7 +227,7 @@ packages:
name: path
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.1"
version: "1.8.0"
path_provider_linux:
dependency: transitive
description:
@ -351,7 +358,7 @@ packages:
name: source_span
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.2"
version: "1.8.1"
stack_trace:
dependency: transitive
description:
@ -386,7 +393,7 @@ packages:
name: test_api
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.9"
version: "0.4.8"
typed_data:
dependency: transitive
description:
@ -456,7 +463,7 @@ packages:
name: vector_math
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
version: "2.1.1"
very_good_analysis:
dependency: "direct dev"
description:

@ -24,3 +24,5 @@ class MockGoogleLetterCubit extends Mock implements GoogleLetterCubit {}
class MockSparkyBumperCubit extends Mock implements SparkyBumperCubit {}
class MockDashNestBumperCubit extends Mock implements DashNestBumperCubit {}
class MockMultiplierCubit extends Mock implements MultiplierCubit {}

@ -17,6 +17,8 @@ void main() {
Assets.images.android.bumper.a.dimmed.keyName,
Assets.images.android.bumper.b.lit.keyName,
Assets.images.android.bumper.b.dimmed.keyName,
Assets.images.android.bumper.cow.lit.keyName,
Assets.images.android.bumper.cow.dimmed.keyName,
];
final flameTester = FlameTester(() => TestGame(assets));
@ -33,6 +35,12 @@ void main() {
expect(game.contains(androidBumper), isTrue);
});
flameTester.test('"cow" loads correctly', (game) async {
final androidBumper = AndroidBumper.cow();
await game.ensureAdd(androidBumper);
expect(game.contains(androidBumper), isTrue);
});
// TODO(alestiago): Consider refactoring once the following is merged:
// https://github.com/flame-engine/flame/pull/1538
// ignore: public_member_api_docs

@ -7,22 +7,16 @@ import 'package:mocktail/mocktail.dart';
import 'package:pinball_components/src/components/bumping_behavior.dart';
import '../../helpers/helpers.dart';
import 'layer_test.dart';
class MockContactImpulse extends Mock implements ContactImpulse {}
class _MockContact extends Mock implements Contact {}
class MockManifold extends Mock implements Manifold {}
class _MockContactImpulse extends Mock implements ContactImpulse {}
class TestHeavyBodyComponent extends BodyComponent {
class _TestBodyComponent extends BodyComponent {
@override
Body createBody() {
final shape = CircleShape();
return world.createBody(
BodyDef(
type: BodyType.dynamic,
),
)..createFixtureFromShape(shape, 20);
}
Body createBody() => world.createBody(
BodyDef(type: BodyType.dynamic),
)..createFixtureFromShape(CircleShape(), 1);
}
void main() {
@ -32,7 +26,7 @@ void main() {
group('BumpingBehavior', () {
flameTester.test('can be added', (game) async {
final behavior = BumpingBehavior(strength: 0);
final component = TestBodyComponent();
final component = _TestBodyComponent();
await component.add(behavior);
await game.ensureAdd(component);
});
@ -40,16 +34,18 @@ void main() {
flameTester.testGameWidget(
'the bump is greater when the strengh is greater',
setUp: (game, tester) async {
final component1 = TestBodyComponent();
final behavior1 = BumpingBehavior(strength: 1);
final component1 = _TestBodyComponent();
final behavior1 = BumpingBehavior(strength: 1)
..worldManifold.normal.setFrom(Vector2.all(1));
await component1.add(behavior1);
final component2 = TestBodyComponent();
final behavior2 = BumpingBehavior(strength: 2);
final component2 = _TestBodyComponent();
final behavior2 = BumpingBehavior(strength: 2)
..worldManifold.normal.setFrom(Vector2.all(1));
await component2.add(behavior2);
final dummy1 = TestHeavyBodyComponent();
final dummy2 = TestHeavyBodyComponent();
final dummy1 = _TestBodyComponent();
final dummy2 = _TestBodyComponent();
await game.ensureAddAll([
component1,
@ -58,14 +54,8 @@ void main() {
dummy2,
]);
expect(dummy1.body.inverseMass, greaterThan(0));
expect(dummy2.body.inverseMass, greaterThan(0));
final contact = MockContact();
final manifold = MockManifold();
final contactImpulse = MockContactImpulse();
when(() => manifold.localPoint).thenReturn(Vector2.all(1));
when(() => contact.manifold).thenReturn(manifold);
final contact = _MockContact();
final contactImpulse = _MockContactImpulse();
behavior1.postSolve(dummy1, contact, contactImpulse);
behavior2.postSolve(dummy2, contact, contactImpulse);

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@ -1,6 +1,7 @@
// ignore_for_file: cascade_invocations
import 'package:bloc_test/bloc_test.dart';
import 'package:flame/components.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
@ -80,6 +81,16 @@ void main() {
},
);
flameTester.test('adds new children', (game) async {
final component = Component();
final googleLetter = GoogleLetter(
1,
children: [component],
);
await game.ensureAdd(googleLetter);
expect(googleLetter.children, contains(component));
});
test('throws error when index out of range', () {
expect(() => GoogleLetter(-1), throwsA(isA<RangeError>()));
expect(() => GoogleLetter(6), throwsA(isA<RangeError>()));

@ -1,5 +1,6 @@
// ignore_for_file: cascade_invocations
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
@ -44,6 +45,16 @@ void main() {
},
);
flameTester.test('adds new children', (game) async {
final component = Component();
final kicker = Kicker(
side: BoardSide.left,
children: [component],
);
await game.ensureAdd(kicker);
expect(kicker.children, contains(component));
});
flameTester.test(
'body is static',
(game) async {

@ -0,0 +1,118 @@
// ignore_for_file: prefer_const_constructors
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball_components/pinball_components.dart';
void main() {
group(
'MultiplierCubit',
() {
blocTest<MultiplierCubit, MultiplierState>(
"emits [lit] when 'next' on x2 dimmed with x2 multiplier value",
build: () => MultiplierCubit(MultiplierValue.x2),
act: (bloc) => bloc.next(2),
expect: () => [
isA<MultiplierState>()
..having(
(state) => state.spriteState,
'spriteState',
MultiplierSpriteState.lit,
),
],
);
blocTest<MultiplierCubit, MultiplierState>(
"emits [lit] when 'next' on x3 dimmed with x3 multiplier value",
build: () => MultiplierCubit(MultiplierValue.x3),
act: (bloc) => bloc.next(3),
expect: () => [
isA<MultiplierState>()
..having(
(state) => state.spriteState,
'spriteState',
MultiplierSpriteState.lit,
),
],
);
blocTest<MultiplierCubit, MultiplierState>(
"emits [lit] when 'next' on x4 dimmed with x4 multiplier value",
build: () => MultiplierCubit(MultiplierValue.x4),
act: (bloc) => bloc.next(4),
expect: () => [
isA<MultiplierState>()
..having(
(state) => state.spriteState,
'spriteState',
MultiplierSpriteState.lit,
),
],
);
blocTest<MultiplierCubit, MultiplierState>(
"emits [lit] when 'next' on x5 dimmed with x5 multiplier value",
build: () => MultiplierCubit(MultiplierValue.x5),
act: (bloc) => bloc.next(5),
expect: () => [
isA<MultiplierState>()
..having(
(state) => state.spriteState,
'spriteState',
MultiplierSpriteState.lit,
),
],
);
blocTest<MultiplierCubit, MultiplierState>(
"emits [lit] when 'next' on x6 dimmed with x6 multiplier value",
build: () => MultiplierCubit(MultiplierValue.x6),
act: (bloc) => bloc.next(6),
expect: () => [
isA<MultiplierState>()
..having(
(state) => state.spriteState,
'spriteState',
MultiplierSpriteState.lit,
),
],
);
blocTest<MultiplierCubit, MultiplierState>(
"emits [dimmed] when 'next' on lit with different multiplier value",
build: () => MultiplierCubit(MultiplierValue.x2),
seed: () => MultiplierState(
value: MultiplierValue.x2,
spriteState: MultiplierSpriteState.lit,
),
act: (bloc) => bloc.next(3),
expect: () => [
isA<MultiplierState>()
..having(
(state) => state.spriteState,
'spriteState',
MultiplierSpriteState.dimmed,
),
],
);
blocTest<MultiplierCubit, MultiplierState>(
"emits nothing when 'next' on lit with same multiplier value",
build: () => MultiplierCubit(MultiplierValue.x2),
seed: () => MultiplierState(
value: MultiplierValue.x2,
spriteState: MultiplierSpriteState.lit,
),
act: (bloc) => bloc.next(2),
expect: () => <MultiplierState>[],
);
blocTest<MultiplierCubit, MultiplierState>(
"emits nothing when 'next' on dimmed with different multiplier value",
build: () => MultiplierCubit(MultiplierValue.x2),
act: (bloc) => bloc.next(3),
expect: () => <MultiplierState>[],
);
},
);
}

@ -0,0 +1,75 @@
// ignore_for_file: prefer_const_constructors
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball_components/src/pinball_components.dart';
void main() {
group('MultiplierState', () {
test('supports value equality', () {
expect(
MultiplierState(
value: MultiplierValue.x2,
spriteState: MultiplierSpriteState.lit,
),
equals(
MultiplierState(
value: MultiplierValue.x2,
spriteState: MultiplierSpriteState.lit,
),
),
);
});
group('constructor', () {
test('can be instantiated', () {
expect(
MultiplierState(
value: MultiplierValue.x2,
spriteState: MultiplierSpriteState.lit,
),
isNotNull,
);
});
});
group('copyWith', () {
test(
'copies correctly '
'when no argument specified',
() {
const multiplierState = MultiplierState(
value: MultiplierValue.x2,
spriteState: MultiplierSpriteState.lit,
);
expect(
multiplierState.copyWith(),
equals(multiplierState),
);
},
);
test(
'copies correctly '
'when all arguments specified',
() {
const multiplierState = MultiplierState(
value: MultiplierValue.x2,
spriteState: MultiplierSpriteState.lit,
);
final otherMultiplierState = MultiplierState(
value: MultiplierValue.x2,
spriteState: MultiplierSpriteState.dimmed,
);
expect(multiplierState, isNot(equals(otherMultiplierState)));
expect(
multiplierState.copyWith(
spriteState: MultiplierSpriteState.dimmed,
),
equals(otherMultiplierState),
);
},
);
});
});
}

@ -0,0 +1,517 @@
// ignore_for_file: cascade_invocations, prefer_const_constructors
import 'package:bloc_test/bloc_test.dart';
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';
void main() {
final bloc = MockMultiplierCubit();
group('Multiplier', () {
TestWidgetsFlutterBinding.ensureInitialized();
final assets = [
Assets.images.multiplier.x2.lit.keyName,
Assets.images.multiplier.x2.dimmed.keyName,
Assets.images.multiplier.x3.lit.keyName,
Assets.images.multiplier.x3.dimmed.keyName,
Assets.images.multiplier.x4.lit.keyName,
Assets.images.multiplier.x4.dimmed.keyName,
Assets.images.multiplier.x5.lit.keyName,
Assets.images.multiplier.x5.dimmed.keyName,
Assets.images.multiplier.x6.lit.keyName,
Assets.images.multiplier.x6.dimmed.keyName,
];
final flameTester = FlameTester(() => TestGame(assets));
flameTester.test('"x2" loads correctly', (game) async {
final multiplier = Multiplier.x2(
position: Vector2.zero(),
angle: 0,
);
await game.ensureAdd(multiplier);
expect(game.contains(multiplier), isTrue);
});
flameTester.test('"x3" loads correctly', (game) async {
final multiplier = Multiplier.x3(
position: Vector2.zero(),
angle: 0,
);
await game.ensureAdd(multiplier);
expect(game.contains(multiplier), isTrue);
});
flameTester.test('"x4" loads correctly', (game) async {
final multiplier = Multiplier.x4(
position: Vector2.zero(),
angle: 0,
);
await game.ensureAdd(multiplier);
expect(game.contains(multiplier), isTrue);
});
flameTester.test('"x5" loads correctly', (game) async {
final multiplier = Multiplier.x5(
position: Vector2.zero(),
angle: 0,
);
await game.ensureAdd(multiplier);
expect(game.contains(multiplier), isTrue);
});
flameTester.test('"x6" loads correctly', (game) async {
final multiplier = Multiplier.x6(
position: Vector2.zero(),
angle: 0,
);
await game.ensureAdd(multiplier);
expect(game.contains(multiplier), isTrue);
});
group('renders correctly', () {
group('x2', () {
const multiplierValue = MultiplierValue.x2;
flameTester.testGameWidget(
'lit when bloc state is lit',
setUp: (game, tester) async {
await game.images.loadAll(assets);
whenListen(
bloc,
const Stream<MultiplierState>.empty(),
initialState: MultiplierState(
value: multiplierValue,
spriteState: MultiplierSpriteState.lit,
),
);
final multiplier = Multiplier.test(
value: multiplierValue,
bloc: bloc,
);
await game.ensureAdd(multiplier);
await tester.pump();
game.camera.followVector2(Vector2.zero());
},
verify: (game, tester) async {
expect(
game
.descendants()
.whereType<MultiplierSpriteGroupComponent>()
.first
.current,
MultiplierSpriteState.lit,
);
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('../golden/multipliers/x2-lit.png'),
);
},
);
flameTester.testGameWidget(
'dimmed when bloc state is dimmed',
setUp: (game, tester) async {
await game.images.loadAll(assets);
whenListen(
bloc,
const Stream<MultiplierState>.empty(),
initialState: MultiplierState(
value: multiplierValue,
spriteState: MultiplierSpriteState.dimmed,
),
);
final multiplier = Multiplier.test(
value: multiplierValue,
bloc: bloc,
);
await game.ensureAdd(multiplier);
await tester.pump();
game.camera.followVector2(Vector2.zero());
},
verify: (game, tester) async {
expect(
game
.descendants()
.whereType<MultiplierSpriteGroupComponent>()
.first
.current,
MultiplierSpriteState.dimmed,
);
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('../golden/multipliers/x2-dimmed.png'),
);
},
);
});
group('x3', () {
const multiplierValue = MultiplierValue.x3;
flameTester.testGameWidget(
'lit when bloc state is lit',
setUp: (game, tester) async {
await game.images.loadAll(assets);
whenListen(
bloc,
const Stream<MultiplierState>.empty(),
initialState: MultiplierState(
value: multiplierValue,
spriteState: MultiplierSpriteState.lit,
),
);
final multiplier = Multiplier.test(
value: multiplierValue,
bloc: bloc,
);
await game.ensureAdd(multiplier);
await tester.pump();
game.camera.followVector2(Vector2.zero());
},
verify: (game, tester) async {
expect(
game
.descendants()
.whereType<MultiplierSpriteGroupComponent>()
.first
.current,
MultiplierSpriteState.lit,
);
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('../golden/multipliers/x3-lit.png'),
);
},
);
flameTester.testGameWidget(
'dimmed when bloc state is dimmed',
setUp: (game, tester) async {
await game.images.loadAll(assets);
whenListen(
bloc,
const Stream<MultiplierState>.empty(),
initialState: MultiplierState(
value: multiplierValue,
spriteState: MultiplierSpriteState.dimmed,
),
);
final multiplier = Multiplier.test(
value: multiplierValue,
bloc: bloc,
);
await game.ensureAdd(multiplier);
await tester.pump();
game.camera.followVector2(Vector2.zero());
},
verify: (game, tester) async {
expect(
game
.descendants()
.whereType<MultiplierSpriteGroupComponent>()
.first
.current,
MultiplierSpriteState.dimmed,
);
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('../golden/multipliers/x3-dimmed.png'),
);
},
);
});
group('x4', () {
const multiplierValue = MultiplierValue.x4;
flameTester.testGameWidget(
'lit when bloc state is lit',
setUp: (game, tester) async {
await game.images.loadAll(assets);
whenListen(
bloc,
const Stream<MultiplierState>.empty(),
initialState: MultiplierState(
value: multiplierValue,
spriteState: MultiplierSpriteState.lit,
),
);
final multiplier = Multiplier.test(
value: multiplierValue,
bloc: bloc,
);
await game.ensureAdd(multiplier);
await tester.pump();
game.camera.followVector2(Vector2.zero());
},
verify: (game, tester) async {
expect(
game
.descendants()
.whereType<MultiplierSpriteGroupComponent>()
.first
.current,
MultiplierSpriteState.lit,
);
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('../golden/multipliers/x4-lit.png'),
);
},
);
flameTester.testGameWidget(
'dimmed when bloc state is dimmed',
setUp: (game, tester) async {
await game.images.loadAll(assets);
whenListen(
bloc,
const Stream<MultiplierState>.empty(),
initialState: MultiplierState(
value: multiplierValue,
spriteState: MultiplierSpriteState.dimmed,
),
);
final multiplier = Multiplier.test(
value: multiplierValue,
bloc: bloc,
);
await game.ensureAdd(multiplier);
await tester.pump();
game.camera.followVector2(Vector2.zero());
},
verify: (game, tester) async {
expect(
game
.descendants()
.whereType<MultiplierSpriteGroupComponent>()
.first
.current,
MultiplierSpriteState.dimmed,
);
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('../golden/multipliers/x4-dimmed.png'),
);
},
);
});
group('x5', () {
const multiplierValue = MultiplierValue.x5;
flameTester.testGameWidget(
'lit when bloc state is lit',
setUp: (game, tester) async {
await game.images.loadAll(assets);
whenListen(
bloc,
const Stream<MultiplierState>.empty(),
initialState: MultiplierState(
value: multiplierValue,
spriteState: MultiplierSpriteState.lit,
),
);
final multiplier = Multiplier.test(
value: multiplierValue,
bloc: bloc,
);
await game.ensureAdd(multiplier);
await tester.pump();
game.camera.followVector2(Vector2.zero());
},
verify: (game, tester) async {
expect(
game
.descendants()
.whereType<MultiplierSpriteGroupComponent>()
.first
.current,
MultiplierSpriteState.lit,
);
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('../golden/multipliers/x5-lit.png'),
);
},
);
flameTester.testGameWidget(
'dimmed when bloc state is dimmed',
setUp: (game, tester) async {
await game.images.loadAll(assets);
whenListen(
bloc,
const Stream<MultiplierState>.empty(),
initialState: MultiplierState(
value: multiplierValue,
spriteState: MultiplierSpriteState.dimmed,
),
);
final multiplier = Multiplier.test(
value: multiplierValue,
bloc: bloc,
);
await game.ensureAdd(multiplier);
await tester.pump();
game.camera.followVector2(Vector2.zero());
},
verify: (game, tester) async {
expect(
game
.descendants()
.whereType<MultiplierSpriteGroupComponent>()
.first
.current,
MultiplierSpriteState.dimmed,
);
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('../golden/multipliers/x5-dimmed.png'),
);
},
);
});
group('x6', () {
const multiplierValue = MultiplierValue.x6;
flameTester.testGameWidget(
'lit when bloc state is lit',
setUp: (game, tester) async {
await game.images.loadAll(assets);
whenListen(
bloc,
const Stream<MultiplierState>.empty(),
initialState: MultiplierState(
value: multiplierValue,
spriteState: MultiplierSpriteState.lit,
),
);
final multiplier = Multiplier.test(
value: multiplierValue,
bloc: bloc,
);
await game.ensureAdd(multiplier);
await tester.pump();
game.camera.followVector2(Vector2.zero());
},
verify: (game, tester) async {
expect(
game
.descendants()
.whereType<MultiplierSpriteGroupComponent>()
.first
.current,
MultiplierSpriteState.lit,
);
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('../golden/multipliers/x6-lit.png'),
);
},
);
flameTester.testGameWidget(
'dimmed when bloc state is dimmed',
setUp: (game, tester) async {
await game.images.loadAll(assets);
whenListen(
bloc,
const Stream<MultiplierState>.empty(),
initialState: MultiplierState(
value: multiplierValue,
spriteState: MultiplierSpriteState.dimmed,
),
);
final multiplier = Multiplier.test(
value: multiplierValue,
bloc: bloc,
);
await game.ensureAdd(multiplier);
await tester.pump();
game.camera.followVector2(Vector2.zero());
},
verify: (game, tester) async {
expect(
game
.descendants()
.whereType<MultiplierSpriteGroupComponent>()
.first
.current,
MultiplierSpriteState.dimmed,
);
await expectLater(
find.byGame<TestGame>(),
matchesGoldenFile('../golden/multipliers/x6-dimmed.png'),
);
},
);
});
});
flameTester.test('closes bloc when removed', (game) async {
whenListen(
bloc,
const Stream<MultiplierState>.empty(),
initialState: MultiplierState(
value: MultiplierValue.x2,
spriteState: MultiplierSpriteState.dimmed,
),
);
when(bloc.close).thenAnswer((_) async {});
final multiplier = Multiplier.test(value: MultiplierValue.x2, bloc: bloc);
await game.ensureAdd(multiplier);
game.remove(multiplier);
await game.ready();
verify(bloc.close).called(1);
});
});
}

@ -0,0 +1,39 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# VSCode related
.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
# Web related
lib/generated_plugin_registrant.dart
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json

@ -0,0 +1,11 @@
# platform_helper
[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link]
[![License: MIT][license_badge]][license_link]
Platform helper for Pinball application.
[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg
[license_link]: https://opensource.org/licenses/MIT
[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg
[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis

@ -0,0 +1 @@
include: package:very_good_analysis/analysis_options.2.4.0.yaml

@ -0,0 +1,3 @@
library platform_helper;
export 'src/platform_helper.dart';

@ -0,0 +1,12 @@
import 'package:flutter/foundation.dart';
/// {@template platform_helper}
/// Returns whether the current platform is running on a mobile device.
/// {@endtemplate}
class PlatformHelper {
/// {@macro platform_helper}
bool get isMobile {
return defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.android;
}
}

@ -0,0 +1,16 @@
name: platform_helper
description: Platform helper for Pinball application.
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=2.16.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
very_good_analysis: ^2.4.0

@ -0,0 +1,39 @@
// ignore_for_file: prefer_const_constructors
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:platform_helper/platform_helper.dart';
void main() {
group('PlatformHelper', () {
test('can be instantiated', () {
expect(PlatformHelper(), isNotNull);
});
group('isMobile', () {
tearDown(() async {
debugDefaultTargetPlatformOverride = null;
});
test('returns true when defaultTargetPlatform is iOS', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
expect(PlatformHelper().isMobile, isTrue);
debugDefaultTargetPlatformOverride = null;
});
test('returns true when defaultTargetPlatform is android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
expect(PlatformHelper().isMobile, isTrue);
debugDefaultTargetPlatformOverride = null;
});
test(
'returns false when defaultTargetPlatform is niether iOS nor android',
() async {
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
expect(PlatformHelper().isMobile, isFalse);
debugDefaultTargetPlatformOverride = null;
},
);
});
});
}

@ -513,6 +513,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
platform_helper:
dependency: "direct main"
description:
path: "packages/platform_helper"
relative: true
source: path
version: "1.0.0+1"
plugin_platform_interface:
dependency: transitive
description:

@ -37,6 +37,8 @@ dependencies:
path: packages/pinball_theme
pinball_ui:
path: packages/pinball_ui
platform_helper:
path: packages/platform_helper
dev_dependencies:
bloc_test: ^9.0.2

@ -30,6 +30,8 @@ void main() {
Assets.images.android.bumper.a.dimmed.keyName,
Assets.images.android.bumper.b.lit.keyName,
Assets.images.android.bumper.b.dimmed.keyName,
Assets.images.android.bumper.cow.lit.keyName,
Assets.images.android.bumper.cow.dimmed.keyName,
];
final flameTester = FlameTester(
() => EmptyPinballTestGame(assets: assets),
@ -76,7 +78,7 @@ void main() {
);
flameTester.test(
'two AndroidBumper',
'three AndroidBumper',
(game) async {
final androidZone = AndroidAcres();
await game.addFromBlueprint(androidZone);
@ -84,7 +86,7 @@ void main() {
expect(
game.descendants().whereType<AndroidBumper>().length,
equals(2),
equals(3),
);
},
);

@ -0,0 +1,133 @@
// ignore_for_file: cascade_invocations, prefer_const_constructors
import 'dart:async';
import 'package:bloc_test/bloc_test.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockingjay/mockingjay.dart';
import 'package:pinball/game/components/multipliers/behaviors/behaviors.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import '../../../../helpers/helpers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final assets = [
Assets.images.multiplier.x2.lit.keyName,
Assets.images.multiplier.x2.dimmed.keyName,
Assets.images.multiplier.x3.lit.keyName,
Assets.images.multiplier.x3.dimmed.keyName,
Assets.images.multiplier.x4.lit.keyName,
Assets.images.multiplier.x4.dimmed.keyName,
Assets.images.multiplier.x5.lit.keyName,
Assets.images.multiplier.x5.dimmed.keyName,
Assets.images.multiplier.x6.lit.keyName,
Assets.images.multiplier.x6.dimmed.keyName,
];
group('MultipliersBehavior', () {
late GameBloc gameBloc;
setUp(() {
registerFallbackValue(MockComponent());
gameBloc = MockGameBloc();
whenListen(
gameBloc,
const Stream<GameState>.empty(),
initialState: const GameState.initial(),
);
});
final flameBlocTester = FlameBlocTester<PinballGame, GameBloc>(
gameBuilder: EmptyPinballTestGame.new,
blocBuilder: () => gameBloc,
assets: assets,
);
group('listenWhen', () {
test('is true when the multiplier has changed', () {
final state = GameState(
score: 10,
multiplier: 2,
rounds: 0,
bonusHistory: const [],
);
final previous = GameState.initial();
expect(
MultipliersBehavior().listenWhen(previous, state),
isTrue,
);
});
test('is false when the multiplier state is the same', () {
final state = GameState(
score: 10,
multiplier: 1,
rounds: 0,
bonusHistory: const [],
);
final previous = GameState.initial();
expect(
MultipliersBehavior().listenWhen(previous, state),
isFalse,
);
});
});
group('onNewState', () {
flameBlocTester.testGameWidget(
"calls 'next' once per each multiplier when GameBloc emit state",
setUp: (game, tester) async {
final behavior = MultipliersBehavior();
final parent = Multipliers.test();
final multiplierX2Cubit = MockMultiplierCubit();
final multiplierX3Cubit = MockMultiplierCubit();
final multipliers = [
Multiplier.test(
value: MultiplierValue.x2,
bloc: multiplierX2Cubit,
),
Multiplier.test(
value: MultiplierValue.x3,
bloc: multiplierX3Cubit,
),
];
whenListen(
multiplierX2Cubit,
const Stream<MultiplierState>.empty(),
initialState: MultiplierState.initial(MultiplierValue.x2),
);
when(() => multiplierX2Cubit.next(any())).thenAnswer((_) async {});
whenListen(
multiplierX3Cubit,
const Stream<MultiplierState>.empty(),
initialState: MultiplierState.initial(MultiplierValue.x2),
);
when(() => multiplierX3Cubit.next(any())).thenAnswer((_) async {});
await parent.addAll(multipliers);
await game.ensureAdd(parent);
await parent.ensureAdd(behavior);
await tester.pump();
behavior.onNewState(
GameState.initial().copyWith(multiplier: 2),
);
for (final multiplier in multipliers) {
verify(
() => multiplier.bloc.next(any()),
).called(1);
}
},
);
});
});
}

@ -0,0 +1,63 @@
// ignore_for_file: cascade_invocations
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import '../../../helpers/helpers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final assets = [
Assets.images.multiplier.x2.lit.keyName,
Assets.images.multiplier.x2.dimmed.keyName,
Assets.images.multiplier.x3.lit.keyName,
Assets.images.multiplier.x3.dimmed.keyName,
Assets.images.multiplier.x4.lit.keyName,
Assets.images.multiplier.x4.dimmed.keyName,
Assets.images.multiplier.x5.lit.keyName,
Assets.images.multiplier.x5.dimmed.keyName,
Assets.images.multiplier.x6.lit.keyName,
Assets.images.multiplier.x6.dimmed.keyName,
];
late GameBloc gameBloc;
setUp(() {
gameBloc = GameBloc();
});
final flameBlocTester = FlameBlocTester<PinballGame, GameBloc>(
gameBuilder: EmptyPinballTestGame.new,
blocBuilder: () => gameBloc,
assets: assets,
);
group('Multipliers', () {
flameBlocTester.testGameWidget(
'loads correctly',
setUp: (game, tester) async {
final multipliersGroup = Multipliers();
await game.ensureAdd(multipliersGroup);
expect(game.contains(multipliersGroup), isTrue);
},
);
group('loads', () {
flameBlocTester.testGameWidget(
'five Multiplier',
setUp: (game, tester) async {
final multipliersGroup = Multipliers();
await game.ensureAdd(multipliersGroup);
expect(
multipliersGroup.descendants().whereType<Multiplier>().length,
equals(5),
);
},
);
});
});
}

@ -1,5 +1,6 @@
// ignore_for_file: cascade_invocations
import 'package:bloc_test/bloc_test.dart';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame_test/flame_test.dart';
@ -18,6 +19,8 @@ void main() {
Assets.images.android.bumper.a.dimmed.keyName,
Assets.images.android.bumper.b.lit.keyName,
Assets.images.android.bumper.b.dimmed.keyName,
Assets.images.android.bumper.cow.lit.keyName,
Assets.images.android.bumper.cow.dimmed.keyName,
Assets.images.backboard.backboardScores.keyName,
Assets.images.backboard.backboardGameOver.keyName,
Assets.images.backboard.display.keyName,
@ -52,6 +55,16 @@ void main() {
Assets.images.launchRamp.ramp.keyName,
Assets.images.launchRamp.foregroundRailing.keyName,
Assets.images.launchRamp.backgroundRailing.keyName,
Assets.images.multiplier.x2.lit.keyName,
Assets.images.multiplier.x2.dimmed.keyName,
Assets.images.multiplier.x3.lit.keyName,
Assets.images.multiplier.x3.dimmed.keyName,
Assets.images.multiplier.x4.lit.keyName,
Assets.images.multiplier.x4.dimmed.keyName,
Assets.images.multiplier.x5.lit.keyName,
Assets.images.multiplier.x5.dimmed.keyName,
Assets.images.multiplier.x6.lit.keyName,
Assets.images.multiplier.x6.dimmed.keyName,
Assets.images.plunger.plunger.keyName,
Assets.images.plunger.rocket.keyName,
Assets.images.signpost.inactive.keyName,
@ -93,6 +106,17 @@ void main() {
Assets.images.sparky.bumper.c.inactive.keyName,
];
late GameBloc gameBloc;
setUp(() {
gameBloc = MockGameBloc();
whenListen(
gameBloc,
const Stream<GameState>.empty(),
initialState: const GameState.initial(),
);
});
final flameTester = FlameTester(
() => PinballTestGame(assets: assets),
);
@ -100,11 +124,16 @@ void main() {
() => DebugPinballTestGame(assets: assets),
);
final flameBlocTester = FlameBlocTester<PinballGame, GameBloc>(
gameBuilder: () => PinballTestGame(assets: assets),
blocBuilder: () => gameBloc,
);
group('PinballGame', () {
group('components', () {
// TODO(alestiago): tests that Blueprints get added once the Blueprint
// class is removed.
flameTester.test(
flameBlocTester.test(
'has only one Drain',
(game) async {
await game.ready();
@ -115,11 +144,10 @@ void main() {
},
);
flameTester.test(
flameBlocTester.test(
'has only one BottomGroup',
(game) async {
await game.ready();
expect(
game.children.whereType<BottomGroup>().length,
equals(1),
@ -127,7 +155,7 @@ void main() {
},
);
flameTester.test(
flameBlocTester.test(
'has only one Plunger',
(game) async {
await game.ready();
@ -138,7 +166,7 @@ void main() {
},
);
flameTester.test('has one FlutterForest', (game) async {
flameBlocTester.test('has one FlutterForest', (game) async {
await game.ready();
expect(
game.children.whereType<FlutterForest>().length,
@ -146,7 +174,7 @@ void main() {
);
});
flameTester.test(
flameBlocTester.test(
'one GoogleWord',
(game) async {
await game.ready();

@ -108,7 +108,7 @@ void main() {
expect(
find.byWidgetPredicate(
(widget) => widget is Container && widget.color == AppColors.orange,
(widget) => widget is Container && widget.color == AppColors.yellow,
),
findsOneWidget,
);
@ -125,7 +125,7 @@ void main() {
find.byWidgetPredicate(
(widget) =>
widget is Container &&
widget.color == AppColors.orange.withAlpha(128),
widget.color == AppColors.yellow.withAlpha(128),
),
findsOneWidget,
);

@ -7,7 +7,6 @@ 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/select_character/select_character.dart';
import 'package:pinball/start_game/start_game.dart';
import 'package:pinball_audio/pinball_audio.dart';
@ -35,8 +34,6 @@ class MockGameState extends Mock implements GameState {}
class MockCharacterThemeCubit extends Mock implements CharacterThemeCubit {}
class MockLeaderboardBloc extends Mock implements LeaderboardBloc {}
class MockLeaderboardRepository extends Mock implements LeaderboardRepository {}
class MockRawKeyDownEvent extends Mock implements RawKeyDownEvent {
@ -67,6 +64,8 @@ class MockFilter extends Mock implements Filter {}
class MockFixture extends Mock implements Fixture {}
class MockComponent extends Mock implements Component {}
class MockComponentSet extends Mock implements ComponentSet {}
class MockDashNestBumper extends Mock implements DashNestBumper {}
@ -89,3 +88,9 @@ class MockGameFlowController extends Mock implements GameFlowController {}
class MockAndroidBumper extends Mock implements AndroidBumper {}
class MockSparkyBumper extends Mock implements SparkyBumper {}
class MockMultiplier extends Mock implements Multiplier {}
class MockMultipliersGroup extends Mock implements Multipliers {}
class MockMultiplierCubit extends Mock implements MultiplierCubit {}

@ -1,203 +0,0 @@
// ignore_for_file: prefer_const_constructors
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/leaderboard/leaderboard.dart';
import 'package:pinball_theme/pinball_theme.dart';
import '../../helpers/helpers.dart';
void main() {
group('LeaderboardBloc', () {
late LeaderboardRepository leaderboardRepository;
setUp(() {
leaderboardRepository = MockLeaderboardRepository();
});
test('initial state has state loading no ranking and empty leaderboard',
() {
final bloc = LeaderboardBloc(leaderboardRepository);
expect(bloc.state.status, equals(LeaderboardStatus.loading));
expect(bloc.state.ranking.ranking, equals(0));
expect(bloc.state.ranking.outOf, equals(0));
expect(bloc.state.leaderboard.isEmpty, isTrue);
});
group('Top10Fetched', () {
const top10Scores = [
2500,
2200,
2200,
2000,
1800,
1400,
1300,
1000,
600,
300,
100,
];
final top10Leaderboard = top10Scores
.map(
(score) => LeaderboardEntryData(
playerInitials: 'user$score',
score: score,
character: CharacterType.dash,
),
)
.toList();
blocTest<LeaderboardBloc, LeaderboardState>(
'emits [loading, success] statuses '
'when fetchTop10Leaderboard succeeds',
setUp: () {
when(() => leaderboardRepository.fetchTop10Leaderboard()).thenAnswer(
(_) async => top10Leaderboard,
);
},
build: () => LeaderboardBloc(leaderboardRepository),
act: (bloc) => bloc.add(Top10Fetched()),
expect: () => [
LeaderboardState.initial(),
isA<LeaderboardState>()
..having(
(element) => element.status,
'status',
equals(LeaderboardStatus.success),
)
..having(
(element) => element.leaderboard.length,
'leaderboard',
equals(top10Leaderboard.length),
)
],
verify: (_) =>
verify(() => leaderboardRepository.fetchTop10Leaderboard())
.called(1),
);
blocTest<LeaderboardBloc, LeaderboardState>(
'emits [loading, error] statuses '
'when fetchTop10Leaderboard fails',
setUp: () {
when(() => leaderboardRepository.fetchTop10Leaderboard()).thenThrow(
Exception(),
);
},
build: () => LeaderboardBloc(leaderboardRepository),
act: (bloc) => bloc.add(Top10Fetched()),
expect: () => <LeaderboardState>[
LeaderboardState.initial(),
LeaderboardState.initial().copyWith(status: LeaderboardStatus.error),
],
verify: (_) =>
verify(() => leaderboardRepository.fetchTop10Leaderboard())
.called(1),
errors: () => [isA<Exception>()],
);
});
group('LeaderboardEntryAdded', () {
final leaderboardEntry = LeaderboardEntryData(
playerInitials: 'ABC',
score: 1500,
character: CharacterType.dash,
);
final ranking = LeaderboardRanking(ranking: 3, outOf: 4);
blocTest<LeaderboardBloc, LeaderboardState>(
'emits [loading, success] statuses '
'when addLeaderboardEntry succeeds',
setUp: () {
when(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
).thenAnswer(
(_) async => ranking,
);
},
build: () => LeaderboardBloc(leaderboardRepository),
act: (bloc) => bloc.add(LeaderboardEntryAdded(entry: leaderboardEntry)),
expect: () => [
LeaderboardState.initial(),
isA<LeaderboardState>()
..having(
(element) => element.status,
'status',
equals(LeaderboardStatus.success),
)
..having(
(element) => element.ranking,
'ranking',
equals(ranking),
)
],
verify: (_) => verify(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
).called(1),
);
blocTest<LeaderboardBloc, LeaderboardState>(
'emits [loading, error] statuses '
'when addLeaderboardEntry fails',
setUp: () {
when(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
).thenThrow(
Exception(),
);
},
build: () => LeaderboardBloc(leaderboardRepository),
act: (bloc) => bloc.add(LeaderboardEntryAdded(entry: leaderboardEntry)),
expect: () => <LeaderboardState>[
LeaderboardState.initial(),
LeaderboardState.initial().copyWith(status: LeaderboardStatus.error),
],
verify: (_) => verify(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
).called(1),
errors: () => [isA<Exception>()],
);
});
});
group('CharacterTypeX', () {
test('converts CharacterType.android to AndroidTheme', () {
expect(CharacterType.android.toTheme, equals(AndroidTheme()));
});
test('converts CharacterType.dash to DashTheme', () {
expect(CharacterType.dash.toTheme, equals(DashTheme()));
});
test('converts CharacterType.dino to DinoTheme', () {
expect(CharacterType.dino.toTheme, equals(DinoTheme()));
});
test('converts CharacterType.sparky to SparkyTheme', () {
expect(CharacterType.sparky.toTheme, equals(SparkyTheme()));
});
});
group('CharacterThemeX', () {
test('converts AndroidTheme to CharacterType.android', () {
expect(AndroidTheme().toType, equals(CharacterType.android));
});
test('converts DashTheme to CharacterType.dash', () {
expect(DashTheme().toType, equals(CharacterType.dash));
});
test('converts DinoTheme to CharacterType.dino', () {
expect(DinoTheme().toType, equals(CharacterType.dino));
});
test('converts SparkyTheme to CharacterType.sparky', () {
expect(SparkyTheme().toType, equals(CharacterType.sparky));
});
});
}

@ -1,41 +0,0 @@
// ignore_for_file: prefer_const_constructors
import 'package:flutter_test/flutter_test.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/leaderboard/leaderboard.dart';
void main() {
group('GameEvent', () {
group('Top10Fetched', () {
test('can be instantiated', () {
expect(const Top10Fetched(), isNotNull);
});
test('supports value equality', () {
expect(
Top10Fetched(),
equals(const Top10Fetched()),
);
});
});
group('LeaderboardEntryAdded', () {
const leaderboardEntry = LeaderboardEntryData(
playerInitials: 'ABC',
score: 1500,
character: CharacterType.dash,
);
test('can be instantiated', () {
expect(const LeaderboardEntryAdded(entry: leaderboardEntry), isNotNull);
});
test('supports value equality', () {
expect(
LeaderboardEntryAdded(entry: leaderboardEntry),
equals(const LeaderboardEntryAdded(entry: leaderboardEntry)),
);
});
});
});
}

@ -1,72 +0,0 @@
// ignore_for_file: prefer_const_constructors
import 'package:flutter_test/flutter_test.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/leaderboard/leaderboard.dart';
import 'package:pinball_theme/pinball_theme.dart';
void main() {
group('LeaderboardState', () {
test('supports value equality', () {
expect(
LeaderboardState.initial(),
equals(
LeaderboardState.initial(),
),
);
});
group('constructor', () {
test('can be instantiated', () {
expect(
LeaderboardState.initial(),
isNotNull,
);
});
});
group('copyWith', () {
final leaderboardEntry = LeaderboardEntry(
rank: '1',
playerInitials: 'ABC',
score: 1500,
character: DashTheme().leaderboardIcon,
);
test(
'copies correctly '
'when no argument specified',
() {
const leaderboardState = LeaderboardState.initial();
expect(
leaderboardState.copyWith(),
equals(leaderboardState),
);
},
);
test(
'copies correctly '
'when all arguments specified',
() {
const leaderboardState = LeaderboardState.initial();
final otherLeaderboardState = LeaderboardState(
status: LeaderboardStatus.success,
ranking: LeaderboardRanking(ranking: 0, outOf: 0),
leaderboard: [leaderboardEntry],
);
expect(leaderboardState, isNot(equals(otherLeaderboardState)));
expect(
leaderboardState.copyWith(
status: otherLeaderboardState.status,
ranking: otherLeaderboardState.ranking,
leaderboard: otherLeaderboardState.leaderboard,
),
equals(otherLeaderboardState),
);
},
);
});
});
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save