diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index 21bd4074..579d830b 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -70,7 +70,7 @@ class _PinballGameViewState extends State { showDialog( context: context, builder: (_) { - return const GameOverDialog(); + return GameOverDialog(theme: widget.theme.characterTheme); }, ); } diff --git a/lib/game/view/widgets/game_over_dialog.dart b/lib/game/view/widgets/game_over_dialog.dart index 9d1c61b0..29164a62 100644 --- a/lib/game/view/widgets/game_over_dialog.dart +++ b/lib/game/view/widgets/game_over_dialog.dart @@ -1,21 +1,40 @@ import 'package:flutter/material.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball/leaderboard/leaderboard.dart'; +import 'package:pinball_theme/pinball_theme.dart'; /// {@template game_over_dialog} /// [Dialog] displayed when the [PinballGame] is over. /// {@endtemplate} class GameOverDialog extends StatelessWidget { /// {@macro game_over_dialog} - const GameOverDialog({Key? key}) : super(key: key); + const GameOverDialog({Key? key, required this.theme}) : super(key: key); + + /// Current [CharacterTheme] to customize dialog + final CharacterTheme theme; @override Widget build(BuildContext context) { - return const Dialog( + final l10n = context.l10n; + + return Dialog( child: SizedBox( width: 200, height: 200, child: Center( - child: Text('Game Over'), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(l10n.gameOver), + TextButton( + onPressed: () => Navigator.of(context).push( + LeaderboardPage.route(theme: theme), + ), + child: Text(l10n.leaderboard), + ), + ], + ), ), ), ); diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index a118501e..235c8f2e 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -23,5 +23,33 @@ "characterSelectionTitle": "Choose your character!", "@characterSelectionTitle": { "description": "Title text displayed on the character selection page" + }, + "gameOver": "Game Over", + "@gameOver": { + "description": "Text displayed on the ending dialog when game finishes" + }, + "leaderboard": "Leaderboard", + "@leaderboard": { + "description": "Text displayed on the ending dialog leaderboard button" + }, + "rank": "Rank", + "@rank": { + "description": "Text displayed on the leaderboard page header rank column" + }, + "character": "Character", + "@character": { + "description": "Text displayed on the leaderboard page header character column" + }, + "username": "Username", + "@username": { + "description": "Text displayed on the leaderboard page header userName column" + }, + "score": "Score", + "@score": { + "description": "Text displayed on the leaderboard page header score column" + }, + "retry": "Retry", + "@retry": { + "description": "Text displayed on the retry button leaders board page" } } \ No newline at end of file diff --git a/lib/leaderboard/leaderboard.dart b/lib/leaderboard/leaderboard.dart index 156b7f78..08765743 100644 --- a/lib/leaderboard/leaderboard.dart +++ b/lib/leaderboard/leaderboard.dart @@ -1,2 +1,3 @@ export 'bloc/leaderboard_bloc.dart'; export 'models/leader_board_entry.dart'; +export 'view/leaderboard_page.dart'; diff --git a/lib/leaderboard/view/leaderboard_page.dart b/lib/leaderboard/view/leaderboard_page.dart new file mode 100644 index 00000000..54b364e9 --- /dev/null +++ b/lib/leaderboard/view/leaderboard_page.dart @@ -0,0 +1,306 @@ +// 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/theme/theme.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( + builder: (_) => LeaderboardPage(theme: theme), + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => LeaderboardBloc( + context.read(), + )..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( + 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( + CharacterSelectionPage.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 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 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(), + ), + ), + ); + } +} diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart index 5a6a249f..f8415a58 100644 --- a/test/app/view/app_test.dart +++ b/test/app/view/app_test.dart @@ -7,11 +7,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart'; -import 'package:mocktail/mocktail.dart'; import 'package:pinball/app/app.dart'; import 'package:pinball/landing/landing.dart'; -class MockLeaderboardRepository extends Mock implements LeaderboardRepository {} +import '../../helpers/mocks.dart'; void main() { group('App', () { diff --git a/test/game/components/ball_test.dart b/test/game/components/ball_test.dart index 6419eef2..a872dc1f 100644 --- a/test/game/components/ball_test.dart +++ b/test/game/components/ball_test.dart @@ -4,6 +4,7 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; + import 'package:pinball_components/pinball_components.dart'; import '../../helpers/helpers.dart'; diff --git a/test/game/view/pinball_game_page_test.dart b/test/game/view/pinball_game_page_test.dart index 5298d6ac..f16b8ef1 100644 --- a/test/game/view/pinball_game_page_test.dart +++ b/test/game/view/pinball_game_page_test.dart @@ -104,10 +104,7 @@ void main() { ); await tester.pump(); - expect( - find.text('Game Over'), - findsOneWidget, - ); + expect(find.byType(GameOverDialog), findsOneWidget); }, ); diff --git a/test/game/view/widgets/game_over_dialog_test.dart b/test/game/view/widgets/game_over_dialog_test.dart new file mode 100644 index 00000000..8150bcd5 --- /dev/null +++ b/test/game/view/widgets/game_over_dialog_test.dart @@ -0,0 +1,44 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockingjay/mockingjay.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +import '../../../helpers/helpers.dart'; + +void main() { + group('GameOverDialog', () { + testWidgets('renders correctly', (tester) async { + final l10n = await AppLocalizations.delegate.load(Locale('en')); + await tester.pumpApp( + const GameOverDialog( + theme: DashTheme(), + ), + ); + + expect(find.text(l10n.gameOver), findsOneWidget); + expect(find.text(l10n.leaderboard), findsOneWidget); + }); + + testWidgets('tapping on leaderboard button navigates to LeaderBoardPage', + (tester) async { + final l10n = await AppLocalizations.delegate.load(Locale('en')); + final navigator = MockNavigator(); + when(() => navigator.push(any())).thenAnswer((_) async {}); + + await tester.pumpApp( + const GameOverDialog( + theme: DashTheme(), + ), + navigator: navigator, + ); + + await tester.tap(find.widgetWithText(TextButton, l10n.leaderboard)); + + verify(() => navigator.push(any())).called(1); + }); + }); +} diff --git a/test/helpers/helpers.dart b/test/helpers/helpers.dart index 88b9c04d..223ec627 100644 --- a/test/helpers/helpers.dart +++ b/test/helpers/helpers.dart @@ -8,4 +8,5 @@ export 'builders.dart'; export 'extensions.dart'; export 'key_testers.dart'; export 'mocks.dart'; +export 'navigator.dart'; export 'pump_app.dart'; diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 8ddab690..c658c531 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -1,3 +1,4 @@ +import 'package:bloc_test/bloc_test.dart'; import 'package:flame/components.dart'; import 'package:flame/input.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; @@ -6,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; +import 'package:pinball/leaderboard/leaderboard.dart'; import 'package:pinball/theme/theme.dart'; import 'package:pinball_components/pinball_components.dart'; @@ -37,6 +39,9 @@ class MockGameState extends Mock implements GameState {} class MockThemeCubit extends Mock implements ThemeCubit {} +class MockLeaderboardBloc extends MockBloc + implements LeaderboardBloc {} + class MockLeaderboardRepository extends Mock implements LeaderboardRepository {} class MockRawKeyDownEvent extends Mock implements RawKeyDownEvent { diff --git a/test/helpers/navigator.dart b/test/helpers/navigator.dart new file mode 100644 index 00000000..5a8ea52e --- /dev/null +++ b/test/helpers/navigator.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'helpers.dart'; + +Future expectNavigatesToRoute( + WidgetTester tester, + Route route, { + bool hasFlameGameInside = false, +}) async { + // ignore: avoid_dynamic_calls + await tester.pumpApp( + Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + Navigator.of(context).push(route); + }, + child: const Text('Tap me'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Tap me')); + if (hasFlameGameInside) { + // We can't use pumpAndSettle here because the page renders a Flame game + // which is an infinity animation, so it will timeout + await tester.pump(); // Runs the button action + await tester.pump(); // Runs the navigation + } else { + await tester.pumpAndSettle(); + } + + expect(find.byType(Type), findsOneWidget); +} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index e0b953d2..d5e819b4 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:mockingjay/mockingjay.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; @@ -22,26 +23,30 @@ extension PumpApp on WidgetTester { MockNavigator? navigator, GameBloc? gameBloc, ThemeCubit? themeCubit, + LeaderboardRepository? leaderboardRepository, }) { return pumpWidget( - MultiBlocProvider( - providers: [ - BlocProvider.value( - value: themeCubit ?? MockThemeCubit(), - ), - BlocProvider.value( - value: gameBloc ?? MockGameBloc(), - ), - ], - child: MaterialApp( - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, + RepositoryProvider.value( + value: leaderboardRepository ?? MockLeaderboardRepository(), + child: MultiBlocProvider( + providers: [ + BlocProvider.value( + value: themeCubit ?? MockThemeCubit(), + ), + BlocProvider.value( + value: gameBloc ?? MockGameBloc(), + ), ], - supportedLocales: AppLocalizations.supportedLocales, - home: navigator != null - ? MockNavigatorProvider(navigator: navigator, child: widget) - : widget, + child: MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: navigator != null + ? MockNavigatorProvider(navigator: navigator, child: widget) + : widget, + ), ), ), ); diff --git a/test/leaderboard/view/leaderboard_page_test.dart b/test/leaderboard/view/leaderboard_page_test.dart new file mode 100644 index 00000000..9460818d --- /dev/null +++ b/test/leaderboard/view/leaderboard_page_test.dart @@ -0,0 +1,150 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:mockingjay/mockingjay.dart'; +import 'package:pinball/l10n/l10n.dart'; +import 'package:pinball/leaderboard/leaderboard.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('LeaderboardPage', () { + testWidgets('renders LeaderboardView', (tester) async { + await tester.pumpApp( + LeaderboardPage( + theme: DashTheme(), + ), + ); + + expect(find.byType(LeaderboardView), findsOneWidget); + }); + + testWidgets('route returns a valid navigation route', (tester) async { + await expectNavigatesToRoute( + tester, + LeaderboardPage.route( + theme: DashTheme(), + ), + ); + }); + }); + + group('LeaderboardView', () { + late LeaderboardBloc leaderboardBloc; + + setUp(() { + leaderboardBloc = MockLeaderboardBloc(); + }); + + testWidgets('renders correctly', (tester) async { + final l10n = await AppLocalizations.delegate.load(Locale('en')); + when(() => leaderboardBloc.state).thenReturn(LeaderboardState.initial()); + + await tester.pumpApp( + BlocProvider.value( + value: leaderboardBloc, + child: LeaderboardView( + theme: DashTheme(), + ), + ), + ); + + expect(find.text(l10n.leaderboard), findsOneWidget); + expect(find.text(l10n.retry), findsOneWidget); + }); + + testWidgets('renders loading view when bloc emits [loading]', + (tester) async { + when(() => leaderboardBloc.state).thenReturn(LeaderboardState.initial()); + + await tester.pumpApp( + BlocProvider.value( + value: leaderboardBloc, + child: LeaderboardView( + theme: DashTheme(), + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('There was en error loading data!'), findsNothing); + expect(find.byType(ListView), findsNothing); + }); + + testWidgets('renders error view when bloc emits [error]', (tester) async { + when(() => leaderboardBloc.state).thenReturn( + LeaderboardState.initial().copyWith(status: LeaderboardStatus.error), + ); + + await tester.pumpApp( + BlocProvider.value( + value: leaderboardBloc, + child: LeaderboardView( + theme: DashTheme(), + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('There was en error loading data!'), findsOneWidget); + expect(find.byType(ListView), findsNothing); + }); + + testWidgets('renders success view when bloc emits [success]', + (tester) async { + final l10n = await AppLocalizations.delegate.load(Locale('en')); + when(() => leaderboardBloc.state).thenReturn( + LeaderboardState( + status: LeaderboardStatus.success, + ranking: LeaderboardRanking(ranking: 0, outOf: 0), + leaderboard: [ + LeaderboardEntry( + rank: '1', + playerInitials: 'ABC', + score: 10000, + character: DashTheme().characterAsset, + ), + ], + ), + ); + + await tester.pumpApp( + BlocProvider.value( + value: leaderboardBloc, + child: LeaderboardView( + theme: DashTheme(), + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('There was en error loading data!'), findsNothing); + expect(find.text(l10n.rank), findsOneWidget); + expect(find.text(l10n.character), findsOneWidget); + expect(find.text(l10n.username), findsOneWidget); + expect(find.text(l10n.score), findsOneWidget); + expect(find.byType(ListView), findsOneWidget); + }); + + testWidgets('navigates to CharacterSelectionPage when retry is tapped', + (tester) async { + final navigator = MockNavigator(); + when(() => navigator.push(any())).thenAnswer((_) async {}); + + await tester.pumpApp( + LeaderboardPage( + theme: DashTheme(), + ), + navigator: navigator, + ); + await tester.ensureVisible(find.byType(TextButton)); + await tester.tap(find.byType(TextButton)); + + verify(() => navigator.push(any())).called(1); + }); + }); +}