Merge branch 'main' into feat/adjusting-game-physics

pull/285/head
Allison Ryan 3 years ago
commit 8991d8bb9d

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

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

@ -7,6 +7,7 @@
// ignore_for_file: public_member_api_docs // ignore_for_file: public_member_api_docs
import 'package:authentication_repository/authentication_repository.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
@ -15,16 +16,20 @@ import 'package:pinball/game/game.dart';
import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/select_character/select_character.dart'; import 'package:pinball/select_character/select_character.dart';
import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
import 'package:pinball_ui/pinball_ui.dart';
class App extends StatelessWidget { class App extends StatelessWidget {
const App({ const App({
Key? key, Key? key,
required AuthenticationRepository authenticationRepository,
required LeaderboardRepository leaderboardRepository, required LeaderboardRepository leaderboardRepository,
required PinballAudio pinballAudio, required PinballAudio pinballAudio,
}) : _leaderboardRepository = leaderboardRepository, }) : _authenticationRepository = authenticationRepository,
_leaderboardRepository = leaderboardRepository,
_pinballAudio = pinballAudio, _pinballAudio = pinballAudio,
super(key: key); super(key: key);
final AuthenticationRepository _authenticationRepository;
final LeaderboardRepository _leaderboardRepository; final LeaderboardRepository _leaderboardRepository;
final PinballAudio _pinballAudio; final PinballAudio _pinballAudio;
@ -32,19 +37,21 @@ class App extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiRepositoryProvider( return MultiRepositoryProvider(
providers: [ providers: [
RepositoryProvider.value(value: _authenticationRepository),
RepositoryProvider.value(value: _leaderboardRepository), RepositoryProvider.value(value: _leaderboardRepository),
RepositoryProvider.value(value: _pinballAudio), RepositoryProvider.value(value: _pinballAudio),
], ],
child: BlocProvider( child: BlocProvider(
create: (context) => CharacterThemeCubit(), create: (context) => CharacterThemeCubit(),
child: const MaterialApp( child: MaterialApp(
title: 'I/O Pinball', title: 'I/O Pinball',
localizationsDelegates: [ theme: PinballTheme.standard,
localizationsDelegates: const [
AppLocalizations.delegate, AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
], ],
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
home: PinballGamePage(), home: const PinballGamePage(),
), ),
), ),
); );

@ -12,6 +12,7 @@ import 'dart:developer';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class AppBlocObserver extends BlocObserver { class AppBlocObserver extends BlocObserver {
@ -28,9 +29,12 @@ class AppBlocObserver extends BlocObserver {
} }
} }
Future<void> bootstrap( typedef BootstrapBuilder = Future<Widget> Function(
Future<Widget> Function(FirebaseFirestore firestore) builder, FirebaseFirestore firestore,
) async { FirebaseAuth firebaseAuth,
);
Future<void> bootstrap(BootstrapBuilder builder) async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = (details) { FlutterError.onError = (details) {
log(details.exceptionAsString(), stackTrace: details.stack); log(details.exceptionAsString(), stackTrace: details.stack);
@ -39,7 +43,12 @@ Future<void> bootstrap(
await runZonedGuarded( await runZonedGuarded(
() async { () async {
await BlocOverrides.runZoned( await BlocOverrides.runZoned(
() async => runApp(await builder(FirebaseFirestore.instance)), () async => runApp(
await builder(
FirebaseFirestore.instance,
FirebaseAuth.instance,
),
),
blocObserver: AppBlocObserver(), blocObserver: AppBlocObserver(),
); );
}, },

@ -0,0 +1,76 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball_ui/pinball_ui.dart';
/// {@template footer}
/// Footer widget with links to the main tech stack.
/// {@endtemplate}
class Footer extends StatelessWidget {
/// {@macro footer}
const Footer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(50, 0, 50, 32),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
_MadeWithFlutterAndFirebase(),
_GoogleIO(),
],
),
);
}
}
class _GoogleIO extends StatelessWidget {
const _GoogleIO({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
return Text(
l10n.footerGoogleIOText,
style: theme.textTheme.bodyText1!.copyWith(color: PinballColors.white),
);
}
}
class _MadeWithFlutterAndFirebase extends StatelessWidget {
const _MadeWithFlutterAndFirebase({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: l10n.footerMadeWithText,
style: theme.textTheme.bodyText1!.copyWith(color: PinballColors.white),
children: <TextSpan>[
TextSpan(
text: l10n.footerFlutterLinkText,
recognizer: TapGestureRecognizer()
..onTap = () => openLink('https://flutter.dev'),
style: const TextStyle(
decoration: TextDecoration.underline,
),
),
const TextSpan(text: ' & '),
TextSpan(
text: l10n.footerFirebaseLinkText,
recognizer: TapGestureRecognizer()
..onTap = () => openLink('https://firebase.google.com'),
style: const TextStyle(
decoration: TextDecoration.underline,
),
),
],
),
);
}
}

@ -1,34 +1,36 @@
// ignore_for_file: avoid_renaming_method_parameters // ignore_for_file: avoid_renaming_method_parameters
import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame/components.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template android_acres} /// {@template android_acres}
/// Area positioned on the left side of the board containing the [Spaceship], /// Area positioned on the left side of the board containing the
/// [SpaceshipRamp], [SpaceshipRail], and [AndroidBumper]s. /// [AndroidSpaceship], [SpaceshipRamp], [SpaceshipRail], and [AndroidBumper]s.
/// {@endtemplate} /// {@endtemplate}
class AndroidAcres extends Blueprint { class AndroidAcres extends Component {
/// {@macro android_acres} /// {@macro android_acres}
AndroidAcres() AndroidAcres()
: super( : super(
components: [ children: [
SpaceshipRamp(),
SpaceshipRail(),
AndroidSpaceship(position: Vector2(-26.5, -28.5)),
AndroidBumper.a( AndroidBumper.a(
children: [ children: [
ScoringBehavior(points: 20), ScoringBehavior(points: 20000),
], ],
)..initialPosition = Vector2(-32.52, -9.1), )..initialPosition = Vector2(-25, 1.3),
AndroidBumper.b( AndroidBumper.b(
children: [
ScoringBehavior(points: 20000),
],
)..initialPosition = Vector2(-32.8, -9.2),
AndroidBumper.cow(
children: [ children: [
ScoringBehavior(points: 20), ScoringBehavior(points: 20),
], ],
)..initialPosition = Vector2(-22.89, -17.35), )..initialPosition = Vector2(-20.5, -13.8),
],
blueprints: [
SpaceshipRamp(),
Spaceship(position: Vector2(-26.5, -28.5)),
SpaceshipRail(),
], ],
); );
} }

@ -1,6 +1,7 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template bottom_group} /// {@template bottom_group}
/// Grouping of the board's symmetrical bottom [Component]s. /// Grouping of the board's symmetrical bottom [Component]s.
@ -8,7 +9,7 @@ import 'package:pinball_components/pinball_components.dart';
/// The [BottomGroup] consists of [Flipper]s, [Baseboard]s and [Kicker]s. /// The [BottomGroup] consists of [Flipper]s, [Baseboard]s and [Kicker]s.
/// {@endtemplate} /// {@endtemplate}
// TODO(allisonryan0002): Consider renaming. // TODO(allisonryan0002): Consider renaming.
class BottomGroup extends Component { class BottomGroup extends Component with ZIndex {
/// {@macro bottom_group} /// {@macro bottom_group}
BottomGroup() BottomGroup()
: super( : super(
@ -16,7 +17,9 @@ class BottomGroup extends Component {
_BottomGroupSide(side: BoardSide.right), _BottomGroupSide(side: BoardSide.right),
_BottomGroupSide(side: BoardSide.left), _BottomGroupSide(side: BoardSide.left),
], ],
); ) {
zIndex = ZIndexes.bottomGroup;
}
} }
/// {@template bottom_group_side} /// {@template bottom_group_side}
@ -28,15 +31,14 @@ class _BottomGroupSide extends Component {
/// {@macro bottom_group_side} /// {@macro bottom_group_side}
_BottomGroupSide({ _BottomGroupSide({
required BoardSide side, required BoardSide side,
}) : _side = side, }) : _side = side;
super(priority: RenderPriority.bottomGroup);
final BoardSide _side; final BoardSide _side;
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
final direction = _side.direction; final direction = _side.direction;
final centerXAdjustment = _side.isLeft ? 0 : -6.5; final centerXAdjustment = _side.isLeft ? 0 : -6.66;
final flipper = ControlledFlipper( final flipper = ControlledFlipper(
side: _side, side: _side,
@ -44,13 +46,16 @@ class _BottomGroupSide extends Component {
final baseboard = Baseboard(side: _side) final baseboard = Baseboard(side: _side)
..initialPosition = Vector2( ..initialPosition = Vector2(
(25.58 * direction) + centerXAdjustment, (25.58 * direction) + centerXAdjustment,
28.69, 28.71,
); );
final kicker = Kicker( final kicker = Kicker(
side: _side, side: _side,
children: [
ScoringBehavior(points: 5000)..applyTo(['bouncy_edge']),
],
)..initialPosition = Vector2( )..initialPosition = Vector2(
(22.4 * direction) + centerXAdjustment, (22.64 * direction) + centerXAdjustment,
25, 25.1,
); );
await addAll([flipper, baseboard, kicker]); await addAll([flipper, baseboard, kicker]);

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

@ -19,8 +19,8 @@ class ControlledBall extends Ball with Controls<BallController> {
required CharacterTheme characterTheme, required CharacterTheme characterTheme,
}) : super(baseColor: characterTheme.ballColor) { }) : super(baseColor: characterTheme.ballColor) {
controller = BallController(this); controller = BallController(this);
priority = RenderPriority.ballOnLaunchRamp;
layer = Layer.launcher; layer = Layer.launcher;
zIndex = ZIndexes.ballOnLaunchRamp;
} }
/// {@template bonus_ball} /// {@template bonus_ball}
@ -30,13 +30,13 @@ class ControlledBall extends Ball with Controls<BallController> {
required CharacterTheme characterTheme, required CharacterTheme characterTheme,
}) : super(baseColor: characterTheme.ballColor) { }) : super(baseColor: characterTheme.ballColor) {
controller = BallController(this); controller = BallController(this);
priority = RenderPriority.ballOnBoard; zIndex = ZIndexes.ballOnBoard;
} }
/// [Ball] used in [DebugPinballGame]. /// [Ball] used in [DebugPinballGame].
ControlledBall.debug() : super(baseColor: const Color(0xFFFF0000)) { ControlledBall.debug() : super(baseColor: const Color(0xFFFF0000)) {
controller = BallController(this); controller = BallController(this);
priority = RenderPriority.ballOnBoard; zIndex = ZIndexes.ballOnBoard;
} }
} }

@ -1,7 +1,6 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template dino_desert} /// {@template dino_desert}
/// Area located next to the [Launcher] containing the [ChromeDino] and /// Area located next to the [Launcher] containing the [ChromeDino] and
@ -9,15 +8,14 @@ import 'package:pinball_flame/pinball_flame.dart';
/// {@endtemplate} /// {@endtemplate}
// TODO(allisonryan0002): use a controller to initiate dino bonus when dino is // TODO(allisonryan0002): use a controller to initiate dino bonus when dino is
// fully implemented. // fully implemented.
class DinoDesert extends Blueprint { class DinoDesert extends Component {
/// {@macro dino_desert} /// {@macro dino_desert}
DinoDesert() DinoDesert()
: super( : super(
components: [ children: [
ChromeDino()..initialPosition = Vector2(12.3, -6.9), ChromeDino()..initialPosition = Vector2(12.3, -6.9),
],
blueprints: [
DinoWalls(), DinoWalls(),
Slingshots(),
], ],
); );
} }

@ -5,16 +5,16 @@ import 'package:flutter/material.dart';
import 'package:pinball/game/components/flutter_forest/behaviors/behaviors.dart'; import 'package:pinball/game/components/flutter_forest/behaviors/behaviors.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template flutter_forest} /// {@template flutter_forest}
/// Area positioned at the top right of the board where the [Ball] can bounce /// Area positioned at the top right of the board where the [Ball] can bounce
/// off [DashNestBumper]s. /// off [DashNestBumper]s.
/// {@endtemplate} /// {@endtemplate}
class FlutterForest extends Component { class FlutterForest extends Component with ZIndex {
/// {@macro flutter_forest} /// {@macro flutter_forest}
FlutterForest() FlutterForest()
: super( : super(
priority: RenderPriority.flutterForest,
children: [ children: [
Signpost( Signpost(
children: [ children: [
@ -23,23 +23,25 @@ class FlutterForest extends Component {
)..initialPosition = Vector2(8.35, -58.3), )..initialPosition = Vector2(8.35, -58.3),
DashNestBumper.main( DashNestBumper.main(
children: [ children: [
ScoringBehavior(points: 20), ScoringBehavior(points: 200000),
], ],
)..initialPosition = Vector2(18.55, -59.35), )..initialPosition = Vector2(18.55, -59.35),
DashNestBumper.a( DashNestBumper.a(
children: [ children: [
ScoringBehavior(points: 20), ScoringBehavior(points: 20000),
], ],
)..initialPosition = Vector2(8.95, -51.95), )..initialPosition = Vector2(8.95, -51.95),
DashNestBumper.b( DashNestBumper.b(
children: [ children: [
ScoringBehavior(points: 20), ScoringBehavior(points: 20000),
], ],
)..initialPosition = Vector2(22.3, -46.75), )..initialPosition = Vector2(22.3, -46.75),
DashAnimatronic()..position = Vector2(20, -66), DashAnimatronic()..position = Vector2(20, -66),
FlutterForestBonusBehavior(), FlutterForestBonusBehavior(),
], ],
); ) {
zIndex = ZIndexes.flutterForest;
}
/// Creates a [FlutterForest] without any children. /// Creates a [FlutterForest] without any children.
/// ///

@ -1,26 +1,48 @@
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pinball/game/components/google_word/behaviors/behaviors.dart'; import 'package:pinball/game/components/google_word/behaviors/behaviors.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template google_word} /// {@template google_word}
/// Loads all [GoogleLetter]s to compose a [GoogleWord]. /// Loads all [GoogleLetter]s to compose a [GoogleWord].
/// {@endtemplate} /// {@endtemplate}
class GoogleWord extends Component { class GoogleWord extends Component with ZIndex {
/// {@macro google_word} /// {@macro google_word}
GoogleWord({ GoogleWord({
required Vector2 position, required Vector2 position,
}) : super( }) : super(
children: [ children: [
GoogleLetter(0)..initialPosition = position + Vector2(-12.92, 1.82), GoogleLetter(
GoogleLetter(1)..initialPosition = position + Vector2(-8.33, -0.65), 0,
GoogleLetter(2)..initialPosition = position + Vector2(-2.88, -1.75), children: [ScoringBehavior(points: 5000)],
GoogleLetter(3)..initialPosition = position + Vector2(2.88, -1.75), )..initialPosition = position + Vector2(-12.92, 1.82),
GoogleLetter(4)..initialPosition = position + Vector2(8.33, -0.65), GoogleLetter(
GoogleLetter(5)..initialPosition = position + Vector2(12.92, 1.82), 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(), GoogleWordBonusBehavior(),
], ],
); ) {
zIndex = ZIndexes.decal;
}
/// Creates a [GoogleWord] without any children. /// Creates a [GoogleWord] without any children.
/// ///

@ -1,21 +1,20 @@
import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame/components.dart';
import 'package:pinball/game/components/components.dart'; import 'package:pinball/game/components/components.dart';
import 'package:pinball_components/pinball_components.dart' hide Assets; import 'package:pinball_components/pinball_components.dart' hide Assets;
import 'package:pinball_flame/pinball_flame.dart';
/// {@template launcher} /// {@template launcher}
/// A [Blueprint] which creates the [Plunger], [RocketSpriteComponent] and /// Channel on the right side of the board containing the [LaunchRamp],
/// [LaunchRamp]. /// [Plunger], and [RocketSpriteComponent].
/// {@endtemplate} /// {@endtemplate}
class Launcher extends Blueprint { class Launcher extends Component {
/// {@macro launcher} /// {@macro launcher}
Launcher() Launcher()
: super( : super(
components: [ children: [
ControlledPlunger(compressionDistance: 14) LaunchRamp(),
..initialPosition = Vector2(40.7, 38), ControlledPlunger(compressionDistance: 10.5)
RocketSpriteComponent()..position = Vector2(43, 62), ..initialPosition = Vector2(41.1, 43),
RocketSpriteComponent()..position = Vector2(43, 62.3),
], ],
blueprints: [LaunchRamp()],
); );
} }

@ -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,47 @@
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';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template multipliers}
/// A group for the multipliers on the board.
/// {@endtemplate}
class Multipliers extends Component with ZIndex {
/// {@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(),
],
) {
zIndex = ZIndexes.decal;
}
/// Creates [Multipliers] without any children.
///
/// This can be used for testing [Multipliers]'s behaviors in isolation.
@visibleForTesting
Multipliers.test();
}

@ -24,11 +24,11 @@ class ScoringBehavior extends ContactBehavior with HasGameRef<PinballGame> {
gameRef.read<GameBloc>().add(Scored(points: _points)); gameRef.read<GameBloc>().add(Scored(points: _points));
gameRef.audio.score(); gameRef.audio.score();
gameRef.add( gameRef.firstChild<ZCanvasComponent>()!.add(
ScoreText( ScoreText(
text: _points.toString(), text: _points.toString(),
position: other.body.position, position: other.body.position,
), ),
); );
} }
} }

@ -1,40 +1,36 @@
// ignore_for_file: avoid_renaming_method_parameters // ignore_for_file: avoid_renaming_method_parameters
import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
/// {@template sparky_fire_zone} /// {@template sparky_scorch}
/// Area positioned at the top left of the board where the [Ball] /// Area positioned at the top left of the board containing the
/// can bounce off [SparkyBumper]s. /// [SparkyComputer], [SparkyAnimatronic], and [SparkyBumper]s.
///
/// When a [Ball] hits [SparkyBumper]s, the bumper animates.
/// {@endtemplate} /// {@endtemplate}
class SparkyFireZone extends Blueprint { class SparkyScorch extends Component {
/// {@macro sparky_fire_zone} /// {@macro sparky_scorch}
SparkyFireZone() SparkyScorch()
: super( : super(
components: [ children: [
SparkyBumper.a( SparkyBumper.a(
children: [ children: [
ScoringBehavior(points: 20), ScoringBehavior(points: 20000),
], ],
)..initialPosition = Vector2(-22.9, -41.65), )..initialPosition = Vector2(-22.9, -41.65),
SparkyBumper.b( SparkyBumper.b(
children: [ children: [
ScoringBehavior(points: 20), ScoringBehavior(points: 20000),
], ],
)..initialPosition = Vector2(-21.25, -57.9), )..initialPosition = Vector2(-21.25, -57.9),
SparkyBumper.c( SparkyBumper.c(
children: [ children: [
ScoringBehavior(points: 20), ScoringBehavior(points: 20000),
], ],
)..initialPosition = Vector2(-3.3, -52.55), )..initialPosition = Vector2(-3.3, -52.55),
SparkyComputerSensor()..initialPosition = Vector2(-13, -49.8), SparkyComputerSensor()..initialPosition = Vector2(-13, -49.8),
SparkyAnimatronic()..position = Vector2(-13.8, -58.2), SparkyAnimatronic()..position = Vector2(-13.8, -58.2),
],
blueprints: [
SparkyComputer(), SparkyComputer(),
], ],
); );
@ -47,7 +43,13 @@ class SparkyFireZone extends Blueprint {
class SparkyComputerSensor extends BodyComponent class SparkyComputerSensor extends BodyComponent
with InitialPosition, ContactCallbacks { with InitialPosition, ContactCallbacks {
/// {@macro sparky_computer_sensor} /// {@macro sparky_computer_sensor}
SparkyComputerSensor() : super(renderBody: false); SparkyComputerSensor()
: super(
renderBody: false,
children: [
ScoringBehavior(points: 200000),
],
);
@override @override
Body createBody() { Body createBody() {

@ -13,6 +13,7 @@ extension PinballGameAssetsX on PinballGame {
const dinoTheme = DinoTheme(); const dinoTheme = DinoTheme();
return [ return [
images.load(components.Assets.images.boardBackground.keyName),
images.load(components.Assets.images.ball.ball.keyName), images.load(components.Assets.images.ball.ball.keyName),
images.load(components.Assets.images.ball.flameEffect.keyName), images.load(components.Assets.images.ball.flameEffect.keyName),
images.load(components.Assets.images.signpost.inactive.keyName), images.load(components.Assets.images.signpost.inactive.keyName),
@ -23,8 +24,10 @@ extension PinballGameAssetsX on PinballGame {
images.load(components.Assets.images.flipper.right.keyName), images.load(components.Assets.images.flipper.right.keyName),
images.load(components.Assets.images.baseboard.left.keyName), images.load(components.Assets.images.baseboard.left.keyName),
images.load(components.Assets.images.baseboard.right.keyName), images.load(components.Assets.images.baseboard.right.keyName),
images.load(components.Assets.images.kicker.left.keyName), images.load(components.Assets.images.kicker.left.lit.keyName),
images.load(components.Assets.images.kicker.right.keyName), images.load(components.Assets.images.kicker.left.dimmed.keyName),
images.load(components.Assets.images.kicker.right.lit.keyName),
images.load(components.Assets.images.kicker.right.dimmed.keyName),
images.load(components.Assets.images.slingshot.upper.keyName), images.load(components.Assets.images.slingshot.upper.keyName),
images.load(components.Assets.images.slingshot.lower.keyName), images.load(components.Assets.images.slingshot.lower.keyName),
images.load(components.Assets.images.launchRamp.ramp.keyName), images.load(components.Assets.images.launchRamp.ramp.keyName),
@ -50,48 +53,52 @@ extension PinballGameAssetsX on PinballGame {
images.load(components.Assets.images.boundary.bottom.keyName), images.load(components.Assets.images.boundary.bottom.keyName),
images.load(components.Assets.images.boundary.outer.keyName), images.load(components.Assets.images.boundary.outer.keyName),
images.load(components.Assets.images.boundary.outerBottom.keyName), images.load(components.Assets.images.boundary.outerBottom.keyName),
images.load(components.Assets.images.spaceship.saucer.keyName), images.load(components.Assets.images.android.spaceship.saucer.keyName),
images.load(components.Assets.images.spaceship.bridge.keyName), images
images.load(components.Assets.images.spaceship.ramp.boardOpening.keyName), .load(components.Assets.images.android.spaceship.animatronic.keyName),
images.load(components.Assets.images.android.spaceship.lightBeam.keyName),
images.load(components.Assets.images.android.ramp.boardOpening.keyName),
images.load( images.load(
components.Assets.images.spaceship.ramp.railingForeground.keyName, components.Assets.images.android.ramp.railingForeground.keyName,
), ),
images.load( images.load(
components.Assets.images.spaceship.ramp.railingBackground.keyName, components.Assets.images.android.ramp.railingBackground.keyName,
), ),
images.load(components.Assets.images.spaceship.ramp.main.keyName), images.load(components.Assets.images.android.ramp.main.keyName),
images images.load(components.Assets.images.android.ramp.arrow.inactive.keyName),
.load(components.Assets.images.spaceship.ramp.arrow.inactive.keyName),
images.load( images.load(
components.Assets.images.spaceship.ramp.arrow.active1.keyName, components.Assets.images.android.ramp.arrow.active1.keyName,
), ),
images.load( images.load(
components.Assets.images.spaceship.ramp.arrow.active2.keyName, components.Assets.images.android.ramp.arrow.active2.keyName,
), ),
images.load( images.load(
components.Assets.images.spaceship.ramp.arrow.active3.keyName, components.Assets.images.android.ramp.arrow.active3.keyName,
), ),
images.load( images.load(
components.Assets.images.spaceship.ramp.arrow.active4.keyName, components.Assets.images.android.ramp.arrow.active4.keyName,
), ),
images.load( images.load(
components.Assets.images.spaceship.ramp.arrow.active5.keyName, components.Assets.images.android.ramp.arrow.active5.keyName,
), ),
images.load(components.Assets.images.spaceship.rail.main.keyName), images.load(components.Assets.images.android.rail.main.keyName),
images.load(components.Assets.images.spaceship.rail.exit.keyName), images.load(components.Assets.images.android.rail.exit.keyName),
images.load(components.Assets.images.androidBumper.a.lit.keyName), images.load(components.Assets.images.android.bumper.a.lit.keyName),
images.load(components.Assets.images.androidBumper.a.dimmed.keyName), images.load(components.Assets.images.android.bumper.a.dimmed.keyName),
images.load(components.Assets.images.androidBumper.b.lit.keyName), images.load(components.Assets.images.android.bumper.b.lit.keyName),
images.load(components.Assets.images.androidBumper.b.dimmed.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.top.keyName),
images.load(components.Assets.images.sparky.computer.base.keyName), images.load(components.Assets.images.sparky.computer.base.keyName),
images.load(components.Assets.images.sparky.computer.glow.keyName),
images.load(components.Assets.images.sparky.animatronic.keyName), images.load(components.Assets.images.sparky.animatronic.keyName),
images.load(components.Assets.images.sparky.bumper.a.inactive.keyName), images.load(components.Assets.images.sparky.bumper.a.lit.keyName),
images.load(components.Assets.images.sparky.bumper.a.active.keyName), images.load(components.Assets.images.sparky.bumper.a.dimmed.keyName),
images.load(components.Assets.images.sparky.bumper.b.active.keyName), images.load(components.Assets.images.sparky.bumper.b.lit.keyName),
images.load(components.Assets.images.sparky.bumper.b.inactive.keyName), images.load(components.Assets.images.sparky.bumper.b.dimmed.keyName),
images.load(components.Assets.images.sparky.bumper.c.active.keyName), images.load(components.Assets.images.sparky.bumper.c.lit.keyName),
images.load(components.Assets.images.sparky.bumper.c.inactive.keyName), images.load(components.Assets.images.sparky.bumper.c.dimmed.keyName),
images.load(components.Assets.images.backboard.backboardScores.keyName), images.load(components.Assets.images.backboard.backboardScores.keyName),
images.load(components.Assets.images.backboard.backboardGameOver.keyName), images.load(components.Assets.images.backboard.backboardGameOver.keyName),
images.load(components.Assets.images.googleWord.letter1.keyName), images.load(components.Assets.images.googleWord.letter1.keyName),
@ -101,6 +108,16 @@ extension PinballGameAssetsX on PinballGame {
images.load(components.Assets.images.googleWord.letter5.keyName), images.load(components.Assets.images.googleWord.letter5.keyName),
images.load(components.Assets.images.googleWord.letter6.keyName), images.load(components.Assets.images.googleWord.letter6.keyName),
images.load(components.Assets.images.backboard.display.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(dashTheme.leaderboardIcon.keyName),
images.load(sparkyTheme.leaderboardIcon.keyName), images.load(sparkyTheme.leaderboardIcon.keyName),
images.load(androidTheme.leaderboardIcon.keyName), images.load(androidTheme.leaderboardIcon.keyName),

@ -9,11 +9,10 @@ import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/gen/assets.gen.dart';
import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
import 'package:pinball_components/pinball_components.dart' hide Assets; import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart'; import 'package:pinball_flame/pinball_flame.dart';
import 'package:pinball_theme/pinball_theme.dart' hide Assets; import 'package:pinball_theme/pinball_theme.dart';
class PinballGame extends Forge2DGame class PinballGame extends Forge2DGame
with with
@ -43,31 +42,40 @@ class PinballGame extends Forge2DGame
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
unawaited(add(gameFlowController = GameFlowController(this))); await add(gameFlowController = GameFlowController(this));
unawaited(add(CameraController(this))); await add(CameraController(this));
unawaited(add(Backboard.waiting(position: Vector2(0, -88))));
await add(Drain()); final machine = [
await add(BottomGroup()); BoardBackgroundSpriteComponent(),
unawaited(addFromBlueprint(Boundaries())); Boundaries(),
unawaited(addFromBlueprint(LaunchRamp())); Backboard.waiting(position: Vector2(0, -88)),
];
final launcher = Launcher(); final decals = [
unawaited(addFromBlueprint(launcher));
await add(FlutterForest());
await addFromBlueprint(SparkyFireZone());
await addFromBlueprint(AndroidAcres());
await addFromBlueprint(DinoDesert());
unawaited(addFromBlueprint(Slingshots()));
await add(
GoogleWord( GoogleWord(
position: Vector2( position: Vector2(-4.1, 1.8),
BoardDimensions.bounds.center.dx - 4.1, ),
BoardDimensions.bounds.center.dy + 1.8, Multipliers(),
), ];
final characterAreas = [
AndroidAcres(),
DinoDesert(),
FlutterForest(),
SparkyScorch(),
];
await add(
ZCanvasComponent(
children: [
...machine,
...decals,
...characterAreas,
Drain(),
BottomGroup(),
Launcher(),
],
), ),
); );
controller.attachTo(launcher.components.whereType<Plunger>().first);
await super.onLoad(); await super.onLoad();
} }
@ -76,12 +84,12 @@ class PinballGame extends Forge2DGame
@override @override
void onTapDown(TapDownInfo info) { void onTapDown(TapDownInfo info) {
if (info.raw.kind == PointerDeviceKind.touch) { if (info.raw.kind == PointerDeviceKind.touch) {
final rocket = children.whereType<RocketSpriteComponent>().first; final rocket = descendants().whereType<RocketSpriteComponent>().first;
final bounds = rocket.topLeftPosition & rocket.size; final bounds = rocket.topLeftPosition & rocket.size;
// NOTE(wolfen): As long as Flame does not have https://github.com/flame-engine/flame/issues/1586 we need to check it at the highest level manually. // NOTE(wolfen): As long as Flame does not have https://github.com/flame-engine/flame/issues/1586 we need to check it at the highest level manually.
if (bounds.contains(info.eventPosition.game.toOffset())) { if (bounds.contains(info.eventPosition.game.toOffset())) {
children.whereType<Plunger>().first.pull(); descendants().whereType<Plunger>().single.pull();
} else { } else {
final leftSide = info.eventPosition.widget.x < canvasSize.x / 2; final leftSide = info.eventPosition.widget.x < canvasSize.x / 2;
focusedBoardSide = leftSide ? BoardSide.left : BoardSide.right; focusedBoardSide = leftSide ? BoardSide.left : BoardSide.right;
@ -101,7 +109,7 @@ class PinballGame extends Forge2DGame
final bounds = rocket.topLeftPosition & rocket.size; final bounds = rocket.topLeftPosition & rocket.size;
if (bounds.contains(info.eventPosition.game.toOffset())) { if (bounds.contains(info.eventPosition.game.toOffset())) {
children.whereType<Plunger>().first.release(); descendants().whereType<Plunger>().single.release();
} else { } else {
_moveFlippersDown(); _moveFlippersDown();
} }
@ -110,7 +118,7 @@ class PinballGame extends Forge2DGame
@override @override
void onTapCancel() { void onTapCancel() {
children.whereType<Plunger>().first.release(); descendants().whereType<Plunger>().single.release();
_moveFlippersDown(); _moveFlippersDown();
super.onTapCancel(); super.onTapCancel();
@ -131,8 +139,6 @@ class _GameBallsController extends ComponentController<PinballGame>
with BlocComponent<GameBloc, GameState> { with BlocComponent<GameBloc, GameState> {
_GameBallsController(PinballGame game) : super(game); _GameBallsController(PinballGame game) : super(game);
late final Plunger _plunger;
@override @override
bool listenWhen(GameState? previousState, GameState newState) { bool listenWhen(GameState? previousState, GameState newState) {
final noBallsLeft = component.descendants().whereType<Ball>().isEmpty; final noBallsLeft = component.descendants().whereType<Ball>().isEmpty;
@ -144,30 +150,27 @@ class _GameBallsController extends ComponentController<PinballGame>
@override @override
void onNewState(GameState state) { void onNewState(GameState state) {
super.onNewState(state); super.onNewState(state);
_spawnBall(); spawnBall();
} }
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
await super.onLoad(); await super.onLoad();
_spawnBall(); spawnBall();
} }
void _spawnBall() { void spawnBall() {
final ball = ControlledBall.launch( // TODO(alestiago): Refactor with behavioural pattern.
characterTheme: component.characterTheme, component.ready().whenComplete(() {
)..initialPosition = Vector2( final plunger = parent!.descendants().whereType<Plunger>().single;
_plunger.body.position.x, final ball = ControlledBall.launch(
_plunger.body.position.y - Ball.size.y, characterTheme: component.characterTheme,
); )..initialPosition = Vector2(
component.add(ball); plunger.body.position.x,
} plunger.body.position.y - Ball.size.y,
);
/// Attaches the controller to the plunger. component.firstChild<ZCanvasComponent>()?.add(ball);
// TODO(alestiago): Remove this method and use onLoad instead. });
// ignore: use_setters_to_change_properties
void attachTo(Plunger plunger) {
_plunger = plunger;
} }
} }
@ -179,52 +182,30 @@ class DebugPinballGame extends PinballGame with FPSCounter {
characterTheme: characterTheme, characterTheme: characterTheme,
audio: audio, audio: audio,
) { ) {
controller = _DebugGameBallsController(this); controller = _GameBallsController(this);
} }
@override @override
Future<void> onLoad() async { Future<void> onLoad() async {
await super.onLoad(); await super.onLoad();
await _loadBackground();
await add(_DebugInformation()); await add(_DebugInformation());
} }
// TODO(alestiago): Move to PinballGame once we have the real background
// component.
Future<void> _loadBackground() async {
final sprite = await loadSprite(
Assets.images.components.background.path,
);
final spriteComponent = SpriteComponent(
sprite: sprite,
size: Vector2(120, 160),
anchor: Anchor.center,
)
..position = Vector2(0, -7.8)
..priority = RenderPriority.background;
await add(spriteComponent);
}
@override @override
void onTapUp(TapUpInfo info) { void onTapUp(TapUpInfo info) {
super.onTapUp(info); super.onTapUp(info);
if (info.raw.kind == PointerDeviceKind.mouse) { if (info.raw.kind == PointerDeviceKind.mouse) {
add(ControlledBall.debug()..initialPosition = info.eventPosition.game); final ball = ControlledBall.debug()
..initialPosition = info.eventPosition.game;
firstChild<ZCanvasComponent>()?.add(ball);
} }
} }
} }
class _DebugGameBallsController extends _GameBallsController {
_DebugGameBallsController(PinballGame game) : super(game);
}
// TODO(wolfenrain): investigate this CI failure. // TODO(wolfenrain): investigate this CI failure.
// coverage:ignore-start // coverage:ignore-start
class _DebugInformation extends Component with HasGameRef<DebugPinballGame> { class _DebugInformation extends Component with HasGameRef<DebugPinballGame> {
_DebugInformation() : super(priority: RenderPriority.debugInfo);
@override @override
PositionType get positionType => PositionType.widget; PositionType get positionType => PositionType.widget;

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/gen/gen.dart'; import 'package:pinball/gen/gen.dart';
import 'package:pinball/theme/app_colors.dart'; import 'package:pinball_ui/pinball_ui.dart';
/// {@template game_hud} /// {@template game_hud}
/// Overlay on the [PinballGame]. /// Overlay on the [PinballGame].
@ -72,7 +72,7 @@ class _ScoreViewDecoration extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: radius, borderRadius: radius,
border: Border.all( border: Border.all(
color: AppColors.white, color: PinballColors.white,
width: borderWidth, width: borderWidth,
), ),
image: DecorationImage( image: DecorationImage(

@ -22,24 +22,9 @@ class PlayButtonOverlay extends StatelessWidget {
return Center( return Center(
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () async {
_game.gameFlowController.start(); _game.gameFlowController.start();
showDialog<void>( await showCharacterSelectionDialog(context);
context: context,
barrierDismissible: false,
builder: (_) {
// TODO(arturplaczek): remove after merge StarBlocListener
final height = MediaQuery.of(context).size.height * 0.5;
return Center(
child: SizedBox(
height: height,
width: height * 1.4,
child: const CharacterSelectionDialog(),
),
);
},
);
}, },
child: Text(l10n.play), child: Text(l10n.play),
), ),

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/theme/theme.dart'; import 'package:pinball_ui/pinball_ui.dart';
/// {@template round_count_display} /// {@template round_count_display}
/// Colored square indicating if a round is available. /// Colored square indicating if a round is available.
@ -20,9 +20,7 @@ class RoundCountDisplay extends StatelessWidget {
children: [ children: [
Text( Text(
l10n.rounds, l10n.rounds,
style: AppTextStyle.subtitle1.copyWith( style: Theme.of(context).textTheme.subtitle1,
color: AppColors.orange,
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Row( Row(
@ -53,9 +51,9 @@ class RoundIndicator extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = isActive ? AppColors.orange : AppColors.orange.withAlpha(128); final color =
isActive ? PinballColors.yellow : PinballColors.yellow.withAlpha(128);
const size = 8.0; const size = 8.0;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Container( child: Container(

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pinball/game/game.dart'; import 'package:pinball/game/game.dart';
import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/theme/theme.dart';
import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_components/pinball_components.dart';
/// {@template score_view} /// {@template score_view}
@ -38,9 +37,7 @@ class _GameOver extends StatelessWidget {
return Text( return Text(
l10n.gameOver, l10n.gameOver,
style: AppTextStyle.headline1.copyWith( style: Theme.of(context).textTheme.headline1,
color: AppColors.white,
),
); );
} }
} }
@ -58,9 +55,7 @@ class _ScoreDisplay extends StatelessWidget {
children: [ children: [
Text( Text(
l10n.score.toLowerCase(), l10n.score.toLowerCase(),
style: AppTextStyle.subtitle1.copyWith( style: Theme.of(context).textTheme.subtitle1,
color: AppColors.orange,
),
), ),
const _ScoreText(), const _ScoreText(),
const RoundCountDisplay(), const RoundCountDisplay(),
@ -78,9 +73,7 @@ class _ScoreText extends StatelessWidget {
return Text( return Text(
score.formatScore(), score.formatScore(),
style: AppTextStyle.headline1.copyWith( style: Theme.of(context).textTheme.headline1,
color: AppColors.white,
),
); );
} }
} }

@ -47,6 +47,14 @@ class $AssetsImagesComponentsGen {
/// File path: assets/images/components/background.png /// File path: assets/images/components/background.png
AssetGenImage get background => AssetGenImage get background =>
const AssetGenImage('assets/images/components/background.png'); 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 { class $AssetsImagesScoreGen {

@ -0,0 +1 @@
export 'widgets/widgets.dart';

@ -0,0 +1,305 @@
// 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_ui/pinball_ui.dart';
import 'package:platform_helper/platform_helper.dart';
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;
}
}
}
Future<void> showHowToPlayDialog(BuildContext context) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => HowToPlayDialog(),
);
}
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).pop();
}
});
}
@override
void dispose() {
closeTimer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isMobile = widget.platformHelper.isMobile;
final l10n = context.l10n;
return PinballDialog(
title: l10n.howToPlay,
subtitle: l10n.tipsForFlips,
child: 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);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final headline3 = Theme.of(context)
.textTheme
.headline3!
.copyWith(color: PinballColors.white);
return Column(
children: [
Text(l10n.tapAndHoldRocket, style: headline3),
Text.rich(
TextSpan(
children: [
TextSpan(text: '${l10n.to} ', style: headline3),
TextSpan(
text: l10n.launch,
style: headline3.copyWith(color: PinballColors.blue),
),
],
),
),
],
);
}
}
class _MobileFlipperControls extends StatelessWidget {
const _MobileFlipperControls({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final headline3 = Theme.of(context)
.textTheme
.headline3!
.copyWith(color: PinballColors.white);
return Column(
children: [
Text(l10n.tapLeftRightScreen, style: headline3),
Text.rich(
TextSpan(
children: [
TextSpan(text: '${l10n.to} ', style: headline3),
TextSpan(
text: l10n.flip,
style: headline3.copyWith(color: PinballColors.orange),
),
],
),
),
],
);
}
}
class _DesktopBody extends StatelessWidget {
const _DesktopBody({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListView(
children: const [
SizedBox(height: 16),
_DesktopLaunchControls(),
SizedBox(height: 16),
_DesktopFlipperControls(),
],
);
}
}
class _DesktopLaunchControls extends StatelessWidget {
const _DesktopLaunchControls({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Column(
children: [
Text(
l10n.launchControls,
style: Theme.of(context).textTheme.headline4,
),
const SizedBox(height: 10),
Wrap(
children: const [
_KeyButton(control: Control.down),
SizedBox(width: 10),
_KeyButton(control: Control.space),
SizedBox(width: 10),
_KeyButton(control: Control.s),
],
)
],
);
}
}
class _DesktopFlipperControls extends StatelessWidget {
const _DesktopFlipperControls({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Column(
children: [
Text(
l10n.flipperControls,
style: Theme.of(context).textTheme.subtitle2,
),
const SizedBox(height: 10),
Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: const [
_KeyButton(control: Control.left),
SizedBox(width: 20),
_KeyButton(control: Control.right),
],
),
const SizedBox(height: 8),
Wrap(
children: const [
_KeyButton(control: Control.a),
SizedBox(width: 20),
_KeyButton(control: Control.d),
],
)
],
)
],
);
}
}
class _KeyButton extends StatelessWidget {
const _KeyButton({Key? key, required this.control}) : super(key: key);
final Control control;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final textStyle =
control.isArrow ? textTheme.headline1 : textTheme.headline3;
const height = 60.0;
final width = control.isSpace ? height * 2.83 : height;
return DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
fit: BoxFit.fill,
image: AssetImage(
control.isSpace
? Assets.images.components.space.keyName
: Assets.images.components.key.keyName,
),
),
),
child: SizedBox(
width: width,
height: height,
child: Center(
child: RotatedBox(
quarterTurns: control.isDown ? 1 : 0,
child: Text(
control.getCharacter(context),
style: textStyle?.copyWith(color: PinballColors.white),
),
),
),
),
);
}
}

@ -8,6 +8,10 @@
"@howToPlay": { "@howToPlay": {
"description": "Text displayed on the landing page how to play button" "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": "Launch Controls",
"@launchControls": { "@launchControls": {
"description": "Text displayed on the how to play dialog with the launch controls" "description": "Text displayed on the how to play dialog with the launch controls"
@ -16,21 +20,45 @@
"@flipperControls": { "@flipperControls": {
"description": "Text displayed on the how to play dialog with the flipper controls" "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": "Start",
"@start": { "@start": {
"description": "Text displayed on the character selection page start button" "description": "Text displayed on the character selection page start button"
}, },
"select": "Select", "select": "Select",
"@select": { "@select": {
"description": "Text displayed on the character selection page select button" "description": "Text displayed on the character selection dialog - select button"
},
"space": "Space",
"@space": {
"description": "Text displayed on space control button"
}, },
"characterSelectionTitle": "Choose your character!", "characterSelectionTitle": "Select a Character",
"@characterSelectionTitle": { "@characterSelectionTitle": {
"description": "Title text displayed on the character selection page" "description": "Title text displayed on the character selection dialog"
}, },
"characterSelectionSubtitle": "Theres no wrong answer", "characterSelectionSubtitle": "Theres no wrong choice!",
"@characterSelectionSubtitle": { "@characterSelectionSubtitle": {
"description": "Text displayed on the selecting character dialog at game beginning" "description": "Subtitle text displayed on the character selection dialog"
}, },
"gameOver": "Game Over", "gameOver": "Game Over",
"@gameOver": { "@gameOver": {
@ -79,5 +107,21 @@
"rounds": "Ball Ct:", "rounds": "Ball Ct:",
"@rounds": { "@rounds": {
"description": "Text displayed on the scoreboard widget to indicate rounds left" "description": "Text displayed on the scoreboard widget to indicate rounds left"
},
"footerMadeWithText": "Made with ",
"@footerMadeWithText": {
"description": "Text shown on the footer which mentions technologies used to build the app."
},
"footerFlutterLinkText": "Flutter",
"@footerFlutterLinkText": {
"description": "Text on the link shown on the footer which navigates to the Flutter page"
},
"footerFirebaseLinkText": "Firebase",
"@footerFirebaseLinkText": {
"description": "Text on the link shown on the footer which navigates to the Firebase page"
},
"footerGoogleIOText": "Google I/O",
"@footerGoogleIOText": {
"description": "Text shown on the footer which mentions Google I/O"
} }
} }

@ -1,15 +0,0 @@
{
"@@locale": "es",
"play": "Jugar",
"@play": {
"description": "Text displayed on the landing page play button"
},
"start": "Comienzo",
"@start": {
"description": "Text displayed on the character selection page start button"
},
"characterSelectionTitle": "¡Elige a tu personaje!",
"@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(),
),
),
);
}
}

@ -5,16 +5,27 @@
// license that can be found in the LICENSE file or at // license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT. // https://opensource.org/licenses/MIT.
import 'dart:async';
import 'package:authentication_repository/authentication_repository.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/app/app.dart'; import 'package:pinball/app/app.dart';
import 'package:pinball/bootstrap.dart'; import 'package:pinball/bootstrap.dart';
import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
void main() { void main() {
bootstrap((firestore) async { bootstrap((firestore, firebaseAuth) async {
final leaderboardRepository = LeaderboardRepository(firestore); final leaderboardRepository = LeaderboardRepository(firestore);
final authenticationRepository = AuthenticationRepository(firebaseAuth);
final pinballAudio = PinballAudio(); final pinballAudio = PinballAudio();
unawaited(
Firebase.initializeApp().then(
(_) => authenticationRepository.authenticateAnonymously(),
),
);
return App( return App(
authenticationRepository: authenticationRepository,
leaderboardRepository: leaderboardRepository, leaderboardRepository: leaderboardRepository,
pinballAudio: pinballAudio, pinballAudio: pinballAudio,
); );

@ -5,16 +5,27 @@
// license that can be found in the LICENSE file or at // license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT. // https://opensource.org/licenses/MIT.
import 'dart:async';
import 'package:authentication_repository/authentication_repository.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/app/app.dart'; import 'package:pinball/app/app.dart';
import 'package:pinball/bootstrap.dart'; import 'package:pinball/bootstrap.dart';
import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
void main() { void main() {
bootstrap((firestore) async { bootstrap((firestore, firebaseAuth) async {
final leaderboardRepository = LeaderboardRepository(firestore); final leaderboardRepository = LeaderboardRepository(firestore);
final authenticationRepository = AuthenticationRepository(firebaseAuth);
final pinballAudio = PinballAudio(); final pinballAudio = PinballAudio();
unawaited(
Firebase.initializeApp().then(
(_) => authenticationRepository.authenticateAnonymously(),
),
);
return App( return App(
authenticationRepository: authenticationRepository,
leaderboardRepository: leaderboardRepository, leaderboardRepository: leaderboardRepository,
pinballAudio: pinballAudio, pinballAudio: pinballAudio,
); );

@ -5,16 +5,27 @@
// license that can be found in the LICENSE file or at // license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT. // https://opensource.org/licenses/MIT.
import 'dart:async';
import 'package:authentication_repository/authentication_repository.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/app/app.dart'; import 'package:pinball/app/app.dart';
import 'package:pinball/bootstrap.dart'; import 'package:pinball/bootstrap.dart';
import 'package:pinball_audio/pinball_audio.dart'; import 'package:pinball_audio/pinball_audio.dart';
void main() { void main() {
bootstrap((firestore) async { bootstrap((firestore, firebaseAuth) async {
final leaderboardRepository = LeaderboardRepository(firestore); final leaderboardRepository = LeaderboardRepository(firestore);
final authenticationRepository = AuthenticationRepository(firebaseAuth);
final pinballAudio = PinballAudio(); final pinballAudio = PinballAudio();
unawaited(
Firebase.initializeApp().then(
(_) => authenticationRepository.authenticateAnonymously(),
),
);
return App( return App(
authenticationRepository: authenticationRepository,
leaderboardRepository: leaderboardRepository, leaderboardRepository: leaderboardRepository,
pinballAudio: pinballAudio, pinballAudio: pinballAudio,
); );

@ -10,6 +10,14 @@ class CharacterThemeState extends Equatable {
final CharacterTheme characterTheme; final CharacterTheme characterTheme;
bool get isSparkySelected => characterTheme == const SparkyTheme();
bool get isDashSelected => characterTheme == const DashTheme();
bool get isAndroidSelected => characterTheme == const AndroidTheme();
bool get isDinoSelected => characterTheme == const DinoTheme();
@override @override
List<Object> get props => [characterTheme]; List<Object> get props => [characterTheme];
} }

@ -1,140 +1,160 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pinball/how_to_play/how_to_play.dart';
import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/select_character/cubit/character_theme_cubit.dart';
import 'package:pinball/select_character/select_character.dart'; import 'package:pinball/select_character/select_character.dart';
import 'package:pinball/start_game/start_game.dart';
import 'package:pinball_theme/pinball_theme.dart'; import 'package:pinball_theme/pinball_theme.dart';
import 'package:pinball_ui/pinball_ui.dart'; import 'package:pinball_ui/pinball_ui.dart';
/// Inflates [CharacterSelectionDialog] using [showDialog].
Future<void> showCharacterSelectionDialog(BuildContext context) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const CharacterSelectionDialog(),
);
}
/// {@template character_selection_dialog}
/// Dialog used to select the playing character of the game.
/// {@endtemplate character_selection_dialog}
class CharacterSelectionDialog extends StatelessWidget { class CharacterSelectionDialog extends StatelessWidget {
/// {@macro character_selection_dialog}
const CharacterSelectionDialog({Key? key}) : super(key: key); const CharacterSelectionDialog({Key? key}) : super(key: key);
static Route route() {
return MaterialPageRoute<void>(
builder: (_) => const CharacterSelectionDialog(),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( final l10n = context.l10n;
create: (_) => CharacterThemeCubit(), return PinballDialog(
child: const CharacterSelectionView(), title: l10n.characterSelectionTitle,
subtitle: l10n.characterSelectionSubtitle,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
children: [
Expanded(child: _CharacterPreview()),
Expanded(child: _CharacterGrid()),
],
),
),
const SizedBox(height: 8),
const _SelectCharacterButton(),
],
),
),
); );
} }
} }
class CharacterSelectionView extends StatelessWidget { class _SelectCharacterButton extends StatelessWidget {
const CharacterSelectionView({Key? key}) : super(key: key); const _SelectCharacterButton({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
return PinballButton(
onTap: () async {
Navigator.of(context).pop();
await showHowToPlayDialog(context);
},
text: l10n.select,
);
}
}
return PixelatedDecoration( class _CharacterGrid extends StatelessWidget {
header: Text( @override
l10n.characterSelectionTitle, Widget build(BuildContext context) {
style: Theme.of(context).textTheme.headline3, return BlocBuilder<CharacterThemeCubit, CharacterThemeState>(
), builder: (context, state) {
body: SingleChildScrollView( return Row(
child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const _CharacterSelectionGridView(), Column(
const SizedBox(height: 20), children: [
TextButton( _Character(
onPressed: () { key: const Key('sparky_character_selection'),
Navigator.of(context).pop(); character: const SparkyTheme(),
// TODO(arturplaczek): remove after merge StarBlocListener isSelected: state.isSparkySelected,
final height = MediaQuery.of(context).size.height * 0.5; ),
const SizedBox(height: 6),
showDialog<void>( _Character(
context: context, key: const Key('android_character_selection'),
builder: (_) => Center( character: const AndroidTheme(),
child: SizedBox( isSelected: state.isAndroidSelected,
height: height, ),
width: height * 1.4, ],
child: const HowToPlayDialog(), ),
), const SizedBox(width: 6),
), Column(
); children: [
}, _Character(
child: Text(l10n.start), key: const Key('dash_character_selection'),
character: const DashTheme(),
isSelected: state.isDashSelected,
),
const SizedBox(height: 6),
_Character(
key: const Key('dino_character_selection'),
character: const DinoTheme(),
isSelected: state.isDinoSelected,
),
],
), ),
], ],
), );
), },
); );
} }
} }
class _CharacterSelectionGridView extends StatelessWidget { class _CharacterPreview extends StatelessWidget {
const _CharacterSelectionGridView({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return BlocBuilder<CharacterThemeCubit, CharacterThemeState>(
padding: const EdgeInsets.all(20), builder: (context, state) {
child: GridView.count( return Column(
shrinkWrap: true, mainAxisAlignment: MainAxisAlignment.center,
crossAxisCount: 2, children: [
mainAxisSpacing: 20, Text(
crossAxisSpacing: 20, state.characterTheme.name,
children: const [ style: Theme.of(context).textTheme.headline2,
CharacterImageButton( overflow: TextOverflow.ellipsis,
DashTheme(), textAlign: TextAlign.center,
key: Key('characterSelectionPage_dashButton'), ),
), const SizedBox(height: 10),
CharacterImageButton( Expanded(child: state.characterTheme.icon.image()),
SparkyTheme(), ],
key: Key('characterSelectionPage_sparkyButton'), );
), },
CharacterImageButton(
AndroidTheme(),
key: Key('characterSelectionPage_androidButton'),
),
CharacterImageButton(
DinoTheme(),
key: Key('characterSelectionPage_dinoButton'),
),
],
),
); );
} }
} }
// TODO(allisonryan0002): remove visibility when adding final UI. class _Character extends StatelessWidget {
@visibleForTesting const _Character({
class CharacterImageButton extends StatelessWidget {
const CharacterImageButton(
this.characterTheme, {
Key? key, Key? key,
required this.character,
required this.isSelected,
}) : super(key: key); }) : super(key: key);
final CharacterTheme characterTheme; final CharacterTheme character;
final bool isSelected;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentCharacterTheme = return Expanded(
context.select<CharacterThemeCubit, CharacterTheme>( child: Opacity(
(cubit) => cubit.state.characterTheme, opacity: isSelected ? 1 : 0.3,
); child: InkWell(
onTap: () =>
return GestureDetector( context.read<CharacterThemeCubit>().characterSelected(character),
onTap: () => child: character.icon.image(fit: BoxFit.contain),
context.read<CharacterThemeCubit>().characterSelected(characterTheme),
child: DecoratedBox(
decoration: BoxDecoration(
color: (currentCharacterTheme == characterTheme)
? Colors.blue.withOpacity(0.5)
: null,
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: characterTheme.icon.image(),
), ),
), ),
); );

@ -1,2 +1 @@
export 'bloc/start_game_bloc.dart'; export 'bloc/start_game_bloc.dart';
export 'widgets/widgets.dart';

@ -1,154 +0,0 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/material.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball_ui/pinball_ui.dart';
class HowToPlayDialog extends StatelessWidget {
const HowToPlayDialog({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
const spacing = SizedBox(height: 16);
return PixelatedDecoration(
header: Text(l10n.howToPlay),
body: ListView(
children: const [
spacing,
_LaunchControls(),
spacing,
_FlipperControls(),
],
),
);
}
}
class _LaunchControls extends StatelessWidget {
const _LaunchControls({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
const spacing = SizedBox(width: 10);
return Column(
children: [
Text(l10n.launchControls),
const SizedBox(height: 10),
Wrap(
children: const [
KeyIndicator.fromIcon(keyIcon: Icons.keyboard_arrow_down),
spacing,
KeyIndicator.fromKeyName(keyName: 'SPACE'),
spacing,
KeyIndicator.fromKeyName(keyName: 'S'),
],
)
],
);
}
}
class _FlipperControls extends StatelessWidget {
const _FlipperControls({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
const rowSpacing = SizedBox(width: 20);
return Column(
children: [
Text(l10n.flipperControls),
const SizedBox(height: 10),
Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: const [
KeyIndicator.fromIcon(keyIcon: Icons.keyboard_arrow_left),
rowSpacing,
KeyIndicator.fromIcon(keyIcon: Icons.keyboard_arrow_right),
],
),
const SizedBox(height: 8),
Wrap(
children: const [
KeyIndicator.fromKeyName(keyName: 'A'),
rowSpacing,
KeyIndicator.fromKeyName(keyName: 'D'),
],
)
],
)
],
);
}
}
// TODO(allisonryan0002): remove visibility when adding final UI.
@visibleForTesting
class KeyIndicator extends StatelessWidget {
const KeyIndicator._({
Key? key,
required String keyName,
required IconData keyIcon,
required bool fromIcon,
}) : _keyName = keyName,
_keyIcon = keyIcon,
_fromIcon = fromIcon,
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;
@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);
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
border: Border.all(
color: boarderColor,
width: 3,
),
),
child: _fromIcon
? Padding(
padding: iconPadding,
child: Icon(_keyIcon, color: color),
)
: Padding(
padding: textPadding,
child: Text(_keyName, style: TextStyle(color: color)),
),
);
}
}

@ -1,2 +0,0 @@
export 'app_colors.dart';
export 'app_text_style.dart';

@ -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 @@
# authentication_repository
[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link]
[![License: MIT][license_badge]][license_link]
Repository to manage user authentication.
[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 authentication_repository;
export 'src/authentication_repository.dart';

@ -0,0 +1,36 @@
import 'package:firebase_auth/firebase_auth.dart';
/// {@template authentication_exception}
/// Exception for authentication repository failures.
/// {@endtemplate}
class AuthenticationException implements Exception {
/// {@macro authentication_exception}
const AuthenticationException(this.error, this.stackTrace);
/// The error that was caught.
final Object error;
/// The Stacktrace associated with the [error].
final StackTrace stackTrace;
}
/// {@template authentication_repository}
/// Repository to manage user authentication.
/// {@endtemplate}
class AuthenticationRepository {
/// {@macro authentication_repository}
AuthenticationRepository(this._firebaseAuth);
final FirebaseAuth _firebaseAuth;
/// Sign in the existing user anonymously using [FirebaseAuth]. If the
/// authentication process can't be completed, it will throw an
/// [AuthenticationException].
Future<void> authenticateAnonymously() async {
try {
await _firebaseAuth.signInAnonymously();
} on Exception catch (error, stackTrace) {
throw AuthenticationException(error, stackTrace);
}
}
}

@ -0,0 +1,18 @@
name: authentication_repository
description: Repository to manage user authentication.
version: 1.0.0+1
publish_to: none
environment:
sdk: ">=2.16.0 <3.0.0"
dependencies:
firebase_auth: ^3.3.16
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^0.2.0
very_good_analysis: ^2.4.0

@ -0,0 +1,40 @@
import 'package:authentication_repository/authentication_repository.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockFirebaseAuth extends Mock implements FirebaseAuth {}
class MockUserCredential extends Mock implements UserCredential {}
void main() {
late FirebaseAuth firebaseAuth;
late UserCredential userCredential;
late AuthenticationRepository authenticationRepository;
group('AuthenticationRepository', () {
setUp(() {
firebaseAuth = MockFirebaseAuth();
userCredential = MockUserCredential();
authenticationRepository = AuthenticationRepository(firebaseAuth);
});
group('authenticateAnonymously', () {
test('completes if no exception is thrown', () async {
when(() => firebaseAuth.signInAnonymously())
.thenAnswer((_) async => userCredential);
await authenticationRepository.authenticateAnonymously();
verify(() => firebaseAuth.signInAnonymously()).called(1);
});
test('throws AuthenticationException when firebase auth fails', () async {
when(() => firebaseAuth.signInAnonymously())
.thenThrow(Exception('oops'));
expect(
() => authenticationRepository.authenticateAnonymously(),
throwsA(isA<AuthenticationException>()),
);
});
});
});
}

@ -1,91 +1,6 @@
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:leaderboard_repository/leaderboard_repository.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} /// {@template leaderboard_repository}
/// Repository to access leaderboard data in Firebase Cloud Firestore. /// Repository to access leaderboard data in Firebase Cloud Firestore.
/// {@endtemplate} /// {@endtemplate}
@ -97,73 +12,40 @@ class LeaderboardRepository {
final FirebaseFirestore _firebaseFirestore; final FirebaseFirestore _firebaseFirestore;
static const _leaderboardLimit = 10;
static const _leaderboardCollectionName = 'leaderboard';
static const _scoreFieldName = 'score';
/// Acquires top 10 [LeaderboardEntryData]s. /// Acquires top 10 [LeaderboardEntryData]s.
Future<List<LeaderboardEntryData>> fetchTop10Leaderboard() async { Future<List<LeaderboardEntryData>> fetchTop10Leaderboard() async {
final leaderboardEntries = <LeaderboardEntryData>[];
late List<QueryDocumentSnapshot> documents;
try { try {
final querySnapshot = await _firebaseFirestore final querySnapshot = await _firebaseFirestore
.collection('leaderboard') .collection(_leaderboardCollectionName)
.orderBy('score', descending: true) .orderBy(_scoreFieldName, descending: true)
.limit(10) .limit(_leaderboardLimit)
.get(); .get();
documents = querySnapshot.docs; final documents = querySnapshot.docs;
return documents.toLeaderboard();
} on LeaderboardDeserializationException {
rethrow;
} on Exception catch (error, stackTrace) { } on Exception catch (error, stackTrace) {
throw FetchTop10LeaderboardException(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 /// Adds player's score entry to the leaderboard if it is within the top-10
/// [LeaderboardRanking]. Future<void> addLeaderboardEntry(
Future<LeaderboardRanking> addLeaderboardEntry(
LeaderboardEntryData entry, LeaderboardEntryData entry,
) async { ) async {
late DocumentReference entryReference; final leaderboard = await _fetchLeaderboardSortedByScore();
try { if (leaderboard.length < 10) {
entryReference = await _firebaseFirestore await _saveScore(entry);
.collection('leaderboard') } else {
.add(entry.toJson()); final tenthPositionScore = leaderboard[9].score;
} on Exception catch (error, stackTrace) { if (entry.score > tenthPositionScore) {
throw AddLeaderboardEntryException(error, stackTrace); await _saveScore(entry);
} await _deleteScoresUnder(tenthPositionScore);
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,
);
} }
} on Exception catch (error, stackTrace) {
throw FetchPlayerRankingException(error, stackTrace);
} }
} }
@ -174,7 +56,6 @@ class LeaderboardRepository {
if (!initialsRegex.hasMatch(initials)) { if (!initialsRegex.hasMatch(initials)) {
return false; return false;
} }
try { try {
final document = await _firebaseFirestore final document = await _firebaseFirestore
.collection('prohibitedInitials') .collection('prohibitedInitials')
@ -187,4 +68,61 @@ class LeaderboardRepository {
throw FetchProhibitedInitialsException(error, stackTrace); 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_entry_data.dart';
export 'leaderboard_ranking.dart';

@ -153,7 +153,6 @@ void main() {
character: CharacterType.dash, character: CharacterType.dash,
); );
const entryDocumentId = 'id$entryScore'; const entryDocumentId = 'id$entryScore';
final ranking = LeaderboardRanking(ranking: 3, outOf: 4);
setUp(() { setUp(() {
leaderboardRepository = LeaderboardRepository(firestore); leaderboardRepository = LeaderboardRepository(firestore);
@ -165,13 +164,12 @@ void main() {
final queryDocumentSnapshot = MockQueryDocumentSnapshot(); final queryDocumentSnapshot = MockQueryDocumentSnapshot();
when(queryDocumentSnapshot.data).thenReturn(<String, dynamic>{ when(queryDocumentSnapshot.data).thenReturn(<String, dynamic>{
'character': 'dash', 'character': 'dash',
'username': 'user$score', 'playerInitials': 'AAA',
'score': score 'score': score
}); });
when(() => queryDocumentSnapshot.id).thenReturn('id$score'); when(() => queryDocumentSnapshot.id).thenReturn('id$score');
return queryDocumentSnapshot; return queryDocumentSnapshot;
}).toList(); }).toList();
when(() => firestore.collection('leaderboard')) when(() => firestore.collection('leaderboard'))
.thenAnswer((_) => collectionReference); .thenAnswer((_) => collectionReference);
when(() => collectionReference.add(any())) when(() => collectionReference.add(any()))
@ -184,19 +182,29 @@ void main() {
}); });
test( test(
'adds leaderboard entry and returns player ranking when ' 'throws FetchLeaderboardException '
'firestore operations succeed', () async { 'when querying the leaderboard fails', () {
final rankingResult = when(() => firestore.collection('leaderboard')).thenThrow(Exception());
await leaderboardRepository.addLeaderboardEntry(leaderboardEntry); expect(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
expect(rankingResult, equals(ranking)); throwsA(isA<FetchLeaderboardException>()),
);
}); });
test( test(
'throws AddLeaderboardEntryException when Exception occurs ' 'saves the new score if the existing leaderboard '
'when trying to add entry to firestore', () async { 'has less than 10 scores', () async {
when(() => firestore.collection('leaderboard')).thenThrow(Exception()); 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( expect(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry), () => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
throwsA(isA<AddLeaderboardEntryException>()), throwsA(isA<AddLeaderboardEntryException>()),
@ -204,26 +212,160 @@ void main() {
}); });
test( test(
'throws FetchPlayerRankingException when Exception occurs ' 'does nothing if there are more than 10 scores in the leaderboard '
'when trying to retrieve information from firestore', () async { 'and the new score is smaller than the top 10', () async {
when(() => collectionReference.orderBy('score', descending: true)) final leaderboardScores = [
.thenThrow(Exception()); 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( await leaderboardRepository.addLeaderboardEntry(leaderboardEntry);
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry), verifyNever(
throwsA(isA<FetchPlayerRankingException>()), () => collectionReference.add(leaderboardEntry.toJson()),
); );
}); });
test( test(
'throws FetchPlayerRankingException when score cannot be found ' 'throws DeleteLeaderboardException '
'in firestore leaderboard data', () async { 'when deleting scores outside the top 10 fails', () async {
when(() => documentReference.id).thenReturn('nonexistentDocumentId'); 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( expect(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry), () => leaderboardRepository.addLeaderboardEntry(newScore),
throwsA(isA<FetchPlayerRankingException>()), 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: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

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

Loading…
Cancel
Save