diff --git a/lib/colors/colors.dart b/lib/colors/colors.dart index 35e2992..48ff07d 100644 --- a/lib/colors/colors.dart +++ b/lib/colors/colors.dart @@ -13,4 +13,16 @@ abstract class SudokuColors { /// Dark Purple static const darkPurple = Color(0xFF7A57FD); + + /// Green + static const green = Color(0xFF388E3C); + + /// Amber + static const amber = Color(0xFFEBB208); + + /// Orange + static const orange = Color(0xFFF57C00); + + /// Teal + static const teal = Color(0xFF008577); } diff --git a/lib/puzzle/view/puzzle_page.dart b/lib/puzzle/view/puzzle_page.dart index 4687adf..546627f 100644 --- a/lib/puzzle/view/puzzle_page.dart +++ b/lib/puzzle/view/puzzle_page.dart @@ -44,18 +44,42 @@ class PuzzleView extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return Scaffold( - extendBodyBehindAppBar: true, - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - scrolledUnderElevation: 0, - systemOverlayStyle: theme.brightness == Brightness.light - ? SystemUiOverlayStyle.dark - : SystemUiOverlayStyle.light, - ), - body: const SudokuBackground( - child: PuzzleViewLayout(), + return BlocListener( + listenWhen: (p, c) => p.puzzleStatus != c.puzzleStatus, + listener: (context, state) { + if (state.puzzleStatus == PuzzleStatus.complete) { + context.read().add(const TimerStopped()); + final timeInSeconds = context.read().state.secondsElapsed; + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => CongratsDialog( + difficulty: state.puzzle.difficulty, + timeInSeconds: timeInSeconds, + ), + ); + } else if (state.puzzleStatus == PuzzleStatus.failed) { + context.read().add(const TimerStopped()); + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const GameOverDialog(), + ); + } + }, + child: Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + systemOverlayStyle: theme.brightness == Brightness.light + ? SystemUiOverlayStyle.dark + : SystemUiOverlayStyle.light, + ), + body: const SudokuBackground( + child: PuzzleViewLayout(), + ), ), ); } diff --git a/lib/puzzle/widgets/congrats_dialog.dart b/lib/puzzle/widgets/congrats_dialog.dart new file mode 100644 index 0000000..ad25eef --- /dev/null +++ b/lib/puzzle/widgets/congrats_dialog.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:sudoku/colors/colors.dart'; +import 'package:sudoku/layout/layout.dart'; +import 'package:sudoku/models/models.dart'; +import 'package:sudoku/typography/typography.dart'; +import 'package:sudoku/utilities/utilities.dart'; +import 'package:sudoku/widgets/widgets.dart'; + +class CongratsDialog extends StatelessWidget { + const CongratsDialog({ + required this.difficulty, + required this.timeInSeconds, + super.key, + }); + + final Difficulty difficulty; + final int timeInSeconds; + + @override + Widget build(BuildContext context) { + const gradient = LinearGradient( + colors: [ + SudokuColors.darkPurple, + SudokuColors.darkPink, + ], + begin: Alignment.bottomLeft, + end: Alignment.topRight, + ); + + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 440, + ), + child: ResponsiveLayoutBuilder( + small: (_, child) => Padding( + key: const Key('congrats_dialog_small'), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: child, + ), + medium: (_, child) => Padding( + key: const Key('congrats_dialog_medium'), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 28), + child: child, + ), + large: (_, child) => Padding( + key: const Key('congrats_dialog_large'), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: child, + ), + child: (layoutSize) { + final gap = switch (layoutSize) { + ResponsiveLayoutSize.small => 16.0, + ResponsiveLayoutSize.medium => 18.0, + ResponsiveLayoutSize.large => 22.0, + }; + + final titleFontSize = switch (layoutSize) { + ResponsiveLayoutSize.small => 16.0, + ResponsiveLayoutSize.medium => 18.0, + ResponsiveLayoutSize.large => 22.0, + }; + + final subtitleFontSize = switch (layoutSize) { + ResponsiveLayoutSize.small => 12.0, + ResponsiveLayoutSize.medium => 14.0, + ResponsiveLayoutSize.large => 16.0, + }; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ShaderMask( + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) => gradient.createShader( + Rect.fromLTWH(0, 0, bounds.width, bounds.height), + ), + child: Text( + 'Congratulations!!!', + style: SudokuTextStyle.bodyText1.copyWith( + fontWeight: SudokuFontWeight.semiBold, + fontSize: titleFontSize, + ), + ), + ), + SizedBox(height: gap), + RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: SudokuTextStyle.caption.copyWith( + height: 1.4, + fontSize: subtitleFontSize, + color: Theme.of(context).brightness == Brightness.light + ? Colors.black + : Colors.white, + ), + children: [ + const TextSpan( + text: 'You have finished ', + ), + TextSpan( + text: '${difficulty.article} ${difficulty.name}', + style: SudokuTextStyle.caption.copyWith( + fontWeight: SudokuFontWeight.medium, + fontSize: subtitleFontSize, + color: difficulty.color, + ), + ), + const TextSpan( + text: ' level sudoku in ', + ), + TextSpan( + text: timeInSeconds.format, + style: SudokuTextStyle.caption.copyWith( + fontWeight: SudokuFontWeight.semiBold, + ), + ), + ], + ), + ), + SizedBox(height: gap), + Text( + 'Thank you for playing the sudoku puzzle!', + textAlign: TextAlign.center, + style: SudokuTextStyle.caption.copyWith( + fontWeight: SudokuFontWeight.medium, + fontSize: subtitleFontSize, + ), + ), + SizedBox(height: gap), + SudokuElevatedButton( + buttonText: 'Return to Home Page', + onPressed: () => Navigator.of(context).popUntil( + (route) => route.isFirst, + ), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/puzzle/widgets/game_over_dialog.dart b/lib/puzzle/widgets/game_over_dialog.dart new file mode 100644 index 0000000..e5b7533 --- /dev/null +++ b/lib/puzzle/widgets/game_over_dialog.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:sudoku/layout/layout.dart'; +import 'package:sudoku/typography/typography.dart'; +import 'package:sudoku/widgets/widgets.dart'; + +class GameOverDialog extends StatelessWidget { + const GameOverDialog({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 440, + ), + child: ResponsiveLayoutBuilder( + small: (_, child) => Padding( + key: const Key('game_over_dialog_small'), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: child, + ), + medium: (_, child) => Padding( + key: const Key('game_over_dialog_medium'), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 28), + child: child, + ), + large: (_, child) => Padding( + key: const Key('game_over_dialog_large'), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: child, + ), + child: (layoutSize) { + final gap = switch (layoutSize) { + ResponsiveLayoutSize.small => 16.0, + ResponsiveLayoutSize.medium => 18.0, + ResponsiveLayoutSize.large => 22.0, + }; + + final titleFontSize = switch (layoutSize) { + ResponsiveLayoutSize.small => 16.0, + ResponsiveLayoutSize.medium => 18.0, + ResponsiveLayoutSize.large => 22.0, + }; + + final subtitleFontSize = switch (layoutSize) { + ResponsiveLayoutSize.small => 12.0, + ResponsiveLayoutSize.medium => 14.0, + ResponsiveLayoutSize.large => 16.0, + }; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Game Over!!!', + style: SudokuTextStyle.bodyText1.copyWith( + fontWeight: SudokuFontWeight.semiBold, + fontSize: titleFontSize, + color: theme.colorScheme.error, + ), + ), + SizedBox(height: gap), + RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: SudokuTextStyle.caption.copyWith( + height: 1.4, + fontSize: subtitleFontSize, + color: Theme.of(context).brightness == Brightness.light + ? Colors.black + : Colors.white, + ), + children: [ + const TextSpan( + text: 'You have exhausted all of the ', + ), + TextSpan( + text: '3 allowed mistakes', + style: SudokuTextStyle.caption.copyWith( + fontWeight: SudokuFontWeight.medium, + fontSize: subtitleFontSize, + ), + ), + const TextSpan( + text: ' in this puzzle.', + ), + ], + ), + ), + SizedBox(height: gap), + Text( + 'Thank you for playing the sudoku puzzle!', + textAlign: TextAlign.center, + style: SudokuTextStyle.caption.copyWith( + fontWeight: SudokuFontWeight.medium, + fontSize: subtitleFontSize, + ), + ), + SizedBox(height: gap), + SudokuElevatedButton( + buttonText: 'Return to Home Page', + onPressed: () => Navigator.popUntil( + context, + (route) => route.isFirst, + ), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/puzzle/widgets/widgets.dart b/lib/puzzle/widgets/widgets.dart index 734dc5a..9a1d162 100644 --- a/lib/puzzle/widgets/widgets.dart +++ b/lib/puzzle/widgets/widgets.dart @@ -1 +1,3 @@ +export 'congrats_dialog.dart'; +export 'game_over_dialog.dart'; export 'mistakes_count_view.dart'; diff --git a/lib/sudoku/widgets/sudoku_block.dart b/lib/sudoku/widgets/sudoku_block.dart index b76b70a..ccb95c0 100644 --- a/lib/sudoku/widgets/sudoku_block.dart +++ b/lib/sudoku/widgets/sudoku_block.dart @@ -72,9 +72,9 @@ class SudokuBlock extends StatelessWidget { child: DecoratedBox( decoration: BoxDecoration( color: isBlockSelected - ? theme.primaryColorLight + ? SudokuColors.lightPurple.withOpacity(0.27) : isBlockHighlighted - ? theme.splashColor.withOpacity(0.27) + ? theme.splashColor.withOpacity(0.2) : null, border: Border.all( color: theme.highlightColor, @@ -88,7 +88,9 @@ class SudokuBlock extends StatelessWidget { ? theme.colorScheme.error : block.isGenerated ? null - : SudokuColors.darkPurple, + : theme.brightness == Brightness.light + ? SudokuColors.darkPurple + : SudokuColors.lightPurple.withGreen(224), ), ), ), diff --git a/lib/sudoku/widgets/sudoku_board.dart b/lib/sudoku/widgets/sudoku_board.dart index b0f38e9..e3273ef 100644 --- a/lib/sudoku/widgets/sudoku_board.dart +++ b/lib/sudoku/widgets/sudoku_board.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:sudoku/colors/colors.dart'; import 'package:sudoku/l10n/l10n.dart'; import 'package:sudoku/layout/layout.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; import 'package:sudoku/sudoku/sudoku.dart'; import 'package:sudoku/timer/timer.dart'; import 'package:sudoku/typography/typography.dart'; @@ -30,6 +31,10 @@ class SudokuBoard extends StatelessWidget { (TimerBloc bloc) => !bloc.state.isRunning, ); + final isPuzzleOngoing = context.select( + (PuzzleBloc bloc) => bloc.state.puzzleStatus == PuzzleStatus.incomplete, + ); + const gradient = LinearGradient( colors: [ SudokuColors.darkPurple, @@ -69,7 +74,7 @@ class SudokuBoard extends StatelessWidget { final subGridSize = subGridDimension * blockSize; return Stack( children: [ - if (!isTimerPaused) ...blocks, + if (!(isTimerPaused && isPuzzleOngoing)) ...blocks, IgnorePointer( child: SudokuBoardDivider( dimension: boardSize, @@ -87,7 +92,7 @@ class SudokuBoard extends StatelessWidget { ), ), ), - if (isTimerPaused) + if (isTimerPaused && isPuzzleOngoing) Center( child: DecoratedBox( decoration: BoxDecoration( diff --git a/lib/utilities/difficulty_extension.dart b/lib/utilities/difficulty_extension.dart new file mode 100644 index 0000000..b848fe3 --- /dev/null +++ b/lib/utilities/difficulty_extension.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:sudoku/colors/colors.dart'; +import 'package:sudoku/models/models.dart'; + +extension DifficultyExtension on Difficulty { + Color get color { + switch (this) { + case Difficulty.easy: + return SudokuColors.green; + case Difficulty.medium: + return SudokuColors.amber; + case Difficulty.difficult: + return SudokuColors.orange; + case Difficulty.expert: + return SudokuColors.teal; + } + } + + String get article { + switch (this) { + case Difficulty.easy: + case Difficulty.expert: + return 'an'; + case Difficulty.medium: + case Difficulty.difficult: + return 'a'; + } + } +} diff --git a/lib/utilities/time_formatter.dart b/lib/utilities/time_formatter.dart new file mode 100644 index 0000000..e4095d9 --- /dev/null +++ b/lib/utilities/time_formatter.dart @@ -0,0 +1,15 @@ +extension TimerFormatter on int { + String get format { + final secondsElapsed = this; + + final hour = secondsElapsed ~/ 3600; + final minute = (secondsElapsed - (hour * 3600)) ~/ 60; + final seconds = secondsElapsed - (hour * 3600) - (minute * 60); + + final hourString = hour.toString().padLeft(2, '0'); + final minuteString = minute.toString().padLeft(2, '0'); + final secondsString = seconds.toString().padLeft(2, '0'); + + return '$hourString:$minuteString:$secondsString'; + } +} diff --git a/lib/utilities/utilities.dart b/lib/utilities/utilities.dart new file mode 100644 index 0000000..8cbd0d6 --- /dev/null +++ b/lib/utilities/utilities.dart @@ -0,0 +1,2 @@ +export 'difficulty_extension.dart'; +export 'time_formatter.dart'; diff --git a/lib/widgets/sudoku_elevated_button.dart b/lib/widgets/sudoku_elevated_button.dart index 4fcd71b..ba08bce 100644 --- a/lib/widgets/sudoku_elevated_button.dart +++ b/lib/widgets/sudoku_elevated_button.dart @@ -45,6 +45,8 @@ class SudokuElevatedButton extends StatelessWidget { SudokuColors.darkPurple, SudokuColors.darkPink, ], + begin: Alignment.bottomLeft, + end: Alignment.topRight, ), ), child: child, diff --git a/lib/widgets/sudoku_failure_dialog.dart b/lib/widgets/sudoku_failure_dialog.dart index 0febbd6..9604252 100644 --- a/lib/widgets/sudoku_failure_dialog.dart +++ b/lib/widgets/sudoku_failure_dialog.dart @@ -28,34 +28,28 @@ class SudokuFailureDialog extends StatelessWidget { _ => l10n.errorClientDialogSubtitle, }; - return ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 440, + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), - child: Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 620, + ), child: ResponsiveLayoutBuilder( small: (_, child) => Padding( key: const Key('sudoku_failure_dialog_small'), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 24, - ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), child: child, ), medium: (_, child) => Padding( key: const Key('sudoku_failure_dialog_medium'), - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 32, - ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 28), child: child, ), large: (_, child) => Padding( key: const Key('sudoku_failure_dialog_large'), - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 48, - ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), child: child, ), child: (layoutSize) { diff --git a/lib/widgets/sudoku_loading_dialog.dart b/lib/widgets/sudoku_loading_dialog.dart index b3b358b..bea10ba 100644 --- a/lib/widgets/sudoku_loading_dialog.dart +++ b/lib/widgets/sudoku_loading_dialog.dart @@ -31,11 +31,14 @@ class SudokuLoadingDialog extends StatelessWidget { ], ); - return ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 440, + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), - child: Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 620, + ), child: ResponsiveLayoutBuilder( small: (_, child) => Padding( key: const Key('sudoku_loading_dialog_small'), @@ -48,16 +51,16 @@ class SudokuLoadingDialog extends StatelessWidget { medium: (_, child) => Padding( key: const Key('sudoku_loading_dialog_medium'), padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 32, + horizontal: 20, + vertical: 28, ), child: child, ), large: (_, child) => Padding( key: const Key('sudoku_loading_dialog_large'), padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 48, + horizontal: 24, + vertical: 32, ), child: child, ), diff --git a/pubspec.lock b/pubspec.lock index de3f39f..163257b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -477,6 +477,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + mockingjay: + dependency: "direct dev" + description: + name: mockingjay + sha256: "04beab95a415cda5bd4efa2681ee76eb92bb9377acaf1a0c08cd58efbae70d83" + url: "https://pub.dev" + source: hosted + version: "0.5.0" mocktail: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 38ac123..38e6abf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,6 +31,7 @@ dev_dependencies: flutter_test: sdk: flutter json_serializable: ^6.8.0 + mockingjay: ^0.5.0 mocktail: ^1.0.3 very_good_analysis: ^5.1.0 diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index d9b62f6..e89fc3e 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -40,3 +40,5 @@ class MockPuzzleState extends Mock implements PuzzleState {} class MockSharedPreferences extends Mock implements SharedPreferences {} class MockHint extends Mock implements Hint {} + +class MockTimerState extends Mock implements TimerState {} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index 2ad2969..0826407 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockingjay/mockingjay.dart'; import 'package:sudoku/api/api.dart'; import 'package:sudoku/home/home.dart'; import 'package:sudoku/l10n/l10n.dart'; @@ -17,6 +18,8 @@ extension PumpApp on WidgetTester { HomeBloc? homeBloc, TimerBloc? timerBloc, PuzzleBloc? puzzleBloc, + Brightness? brightness, + MockNavigator? navigator, }) { return pumpWidget( MultiRepositoryProvider( @@ -43,7 +46,13 @@ extension PumpApp on WidgetTester { child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: widget, + theme: ThemeData(brightness: brightness ?? Brightness.light), + home: navigator != null + ? MockNavigatorProvider( + navigator: navigator, + child: widget, + ) + : widget, ), ), ), diff --git a/test/puzzle/view/puzzle_page_test.dart b/test/puzzle/view/puzzle_page_test.dart index 9c0a577..da1b0b0 100644 --- a/test/puzzle/view/puzzle_page_test.dart +++ b/test/puzzle/view/puzzle_page_test.dart @@ -1,5 +1,6 @@ // ignore_for_file: prefer_const_constructors +import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -13,16 +14,27 @@ import '../../helpers/helpers.dart'; void main() { group('PuzzlePage', () { late Puzzle puzzle; + late PuzzleBloc puzzleBloc; + late TimerBloc timerBloc; + late TimerState timerState; late PuzzleRepository puzzleRepository; setUp(() { puzzle = MockPuzzle(); puzzleRepository = MockPuzzleRepository(); + puzzleBloc = MockPuzzleBloc(); + timerBloc = MockTimerBloc(); + timerState = MockTimerState(); when(() => puzzle.sudoku).thenReturn(sudoku3x3); when(() => puzzle.difficulty).thenReturn(Difficulty.medium); + when(() => puzzle.remainingMistakes).thenReturn(3); when(() => puzzleRepository.getPuzzle()).thenReturn(puzzle); + + when(() => timerState.secondsElapsed).thenReturn(167); + when(() => timerState.isRunning).thenReturn(true); + when(() => timerBloc.state).thenReturn(timerState); }); testWidgets('renders PuzzleView on a large display', (tester) async { @@ -55,208 +67,315 @@ void main() { expect(find.byType(PuzzleView), findsOneWidget); }); - group('PageHeader', () { - late Puzzle puzzle; - late PuzzleBloc puzzleBloc; - late PuzzleState puzzleState; + testWidgets( + 'shows congrats dialog when puzzle status is completed', + (tester) async { + whenListen( + puzzleBloc, + Stream.fromIterable( + [ + PuzzleState( + puzzle: puzzle, + puzzleStatus: PuzzleStatus.complete, + ), + ], + ), + initialState: PuzzleState(puzzle: puzzle), + ); - setUp(() { - puzzle = MockPuzzle(); - puzzleBloc = MockPuzzleBloc(); - puzzleState = MockPuzzleState(); + await tester.pumpApp( + PuzzleView(), + puzzleBloc: puzzleBloc, + timerBloc: timerBloc, + ); + await tester.pump(); + + expect( + find.byWidgetPredicate( + (widget) => + widget is CongratsDialog && + widget.difficulty == Difficulty.medium && + widget.timeInSeconds == 167, + ), + findsOneWidget, + ); + verify(() => timerBloc.add(TimerStopped())).called(1); + }, + ); + + testWidgets( + 'shows game over dialog when puzzle status is failed', + (tester) async { + whenListen( + puzzleBloc, + Stream.fromIterable( + [ + PuzzleState( + puzzleStatus: PuzzleStatus.failed, + ), + ], + ), + initialState: PuzzleState(), + ); - when(() => puzzle.difficulty).thenReturn(Difficulty.medium); - when(() => puzzleState.puzzle).thenReturn(puzzle); - when(() => puzzleBloc.state).thenReturn(puzzleState); - }); + await tester.pumpApp( + PuzzleView(), + puzzleBloc: puzzleBloc, + timerBloc: timerBloc, + ); + await tester.pump(); - testWidgets('renders difficulty on a large layout', (tester) async { - tester.setLargeDisplaySize(); - await tester.pumpApp(const PageHeader(), puzzleBloc: puzzleBloc); - expect(find.text('Medium'), findsOneWidget); - }); + expect(find.byType(GameOverDialog), findsOneWidget); + verify(() => timerBloc.add(TimerStopped())).called(1); + }, + ); + }); - testWidgets('renders difficulty on a medium layout', (tester) async { - tester.setMediumDisplaySize(); - await tester.pumpApp(const PageHeader(), puzzleBloc: puzzleBloc); - expect(find.text('Medium'), findsOneWidget); - }); + group('PageHeader', () { + late Puzzle puzzle; + late PuzzleBloc puzzleBloc; + late PuzzleState puzzleState; - testWidgets('renders difficulty on a small layout', (tester) async { - tester.setSmallDisplaySize(); - await tester.pumpApp(const PageHeader(), puzzleBloc: puzzleBloc); - expect(find.text('Medium'), findsOneWidget); - }); + setUp(() { + puzzle = MockPuzzle(); + puzzleBloc = MockPuzzleBloc(); + puzzleState = MockPuzzleState(); + + when(() => puzzle.difficulty).thenReturn(Difficulty.medium); + when(() => puzzleState.puzzle).thenReturn(puzzle); + when(() => puzzleBloc.state).thenReturn(puzzleState); }); - group('PuzzleViewLayout', () { - late Sudoku sudoku; - late Puzzle puzzle; - late PuzzleState puzzleState; - late PuzzleBloc puzzleBloc; - late TimerBloc timerBloc; + testWidgets('renders difficulty on a large layout', (tester) async { + tester.setLargeDisplaySize(); + await tester.pumpApp(const PageHeader(), puzzleBloc: puzzleBloc); + expect(find.text('Medium'), findsOneWidget); + }); + + testWidgets('renders difficulty on a medium layout', (tester) async { + tester.setMediumDisplaySize(); + await tester.pumpApp(const PageHeader(), puzzleBloc: puzzleBloc); + expect(find.text('Medium'), findsOneWidget); + }); - setUp(() { - sudoku = MockSudoku(); - puzzle = MockPuzzle(); - puzzleState = MockPuzzleState(); + testWidgets('renders difficulty on a small layout', (tester) async { + tester.setSmallDisplaySize(); + await tester.pumpApp(const PageHeader(), puzzleBloc: puzzleBloc); + expect(find.text('Medium'), findsOneWidget); + }); + }); - puzzleBloc = MockPuzzleBloc(); - timerBloc = MockTimerBloc(); + group('PuzzleViewLayout', () { + late Sudoku sudoku; + late Puzzle puzzle; + late PuzzleState puzzleState; + late PuzzleBloc puzzleBloc; + late TimerBloc timerBloc; - when(() => sudoku.blocks).thenReturn([]); - when(() => sudoku.getDimesion()).thenReturn(3); + setUp(() { + sudoku = MockSudoku(); + puzzle = MockPuzzle(); + puzzleState = MockPuzzleState(); - when(() => puzzle.sudoku).thenReturn(sudoku); - when(() => puzzle.difficulty).thenReturn(Difficulty.medium); - when(() => puzzle.remainingMistakes).thenReturn(3); + puzzleBloc = MockPuzzleBloc(); + timerBloc = MockTimerBloc(); - when(() => puzzleState.puzzle).thenReturn(puzzle); - when(() => puzzleBloc.state).thenReturn(puzzleState); + when(() => sudoku.blocks).thenReturn([]); + when(() => sudoku.getDimesion()).thenReturn(3); - when(() => timerBloc.state).thenReturn( - TimerState(secondsElapsed: 1, isRunning: true), - ); - }); - - testWidgets( - 'renders [PageHeader], [SudokuBoardView], [MistakesCountView], ' - '[SudokuTimer], [SudokuInputView], [InputEraseViewForLargeLayout] ' - 'on a large display', - (tester) async { - tester.setLargeDisplaySize(); - - await tester.pumpApp( - PuzzleViewLayout(), - puzzleBloc: puzzleBloc, - timerBloc: timerBloc, - ); - - expect(find.byType(PageHeader), findsOneWidget); - expect(find.byType(SudokuBoardView), findsOneWidget); - expect(find.byType(MistakesCountView), findsOneWidget); - - expect(find.byType(SudokuTimer), findsOneWidget); - expect(find.byType(SudokuInputView), findsOneWidget); - expect(find.byType(InputEraseViewForLargeLayout), findsOneWidget); - }, - ); + when(() => puzzle.sudoku).thenReturn(sudoku); + when(() => puzzle.difficulty).thenReturn(Difficulty.medium); + when(() => puzzle.remainingMistakes).thenReturn(3); - testWidgets( - 'renders [PageHeader], [SudokuBoardView], [MistakesCountView], ' - '[SudokuTimer], and [SudokuInputView] on a medium display', - (tester) async { - tester.setMediumDisplaySize(); - - await tester.pumpApp( - PuzzleViewLayout(), - puzzleBloc: puzzleBloc, - timerBloc: timerBloc, - ); - - expect(find.byType(PageHeader), findsOneWidget); - expect(find.byType(SudokuBoardView), findsOneWidget); - expect(find.byType(MistakesCountView), findsOneWidget); - - expect(find.byType(SudokuTimer), findsOneWidget); - expect(find.byType(SudokuInputView), findsOneWidget); - expect(find.byType(InputEraseViewForLargeLayout), findsNothing); - }, + when(() => puzzleState.puzzle).thenReturn(puzzle); + when(() => puzzleState.puzzleStatus).thenReturn( + PuzzleStatus.incomplete, ); + when(() => puzzleBloc.state).thenReturn(puzzleState); - testWidgets( - 'renders [PageHeader], [SudokuBoardView], [MistakesCountView], ' - '[SudokuTimer], and [SudokuInputView] on a small display', - (tester) async { - tester.setSmallDisplaySize(); - - await tester.pumpApp( - PuzzleViewLayout(), - puzzleBloc: puzzleBloc, - timerBloc: timerBloc, - ); - - expect(find.byType(PageHeader), findsOneWidget); - expect(find.byType(SudokuBoardView), findsOneWidget); - expect(find.byType(MistakesCountView), findsOneWidget); - - expect(find.byType(SudokuTimer), findsOneWidget); - expect(find.byType(SudokuInputView), findsOneWidget); - expect(find.byType(InputEraseViewForLargeLayout), findsNothing); - }, + when(() => timerBloc.state).thenReturn( + TimerState(secondsElapsed: 1, isRunning: true), ); }); - group('InputEraseViewForLargeLayout', () { - late PuzzleBloc puzzleBloc; - - setUp(() { - puzzleBloc = MockPuzzleBloc(); - }); + testWidgets( + 'renders [PageHeader], [SudokuBoardView], [MistakesCountView], ' + '[SudokuTimer], [SudokuInputView], [InputEraseViewForLargeLayout] ' + 'on a large display', + (tester) async { + tester.setLargeDisplaySize(); - testWidgets('adds [SudokuInputErased] when tapped', (tester) async { await tester.pumpApp( - InputEraseViewForLargeLayout(), + PuzzleViewLayout(), puzzleBloc: puzzleBloc, + timerBloc: timerBloc, ); - await tester.tap(find.byType(GestureDetector)); - await tester.pumpAndSettle(); - verify(() => puzzleBloc.add(SudokuInputErased())).called(1); - }); - }); + expect(find.byType(PageHeader), findsOneWidget); + expect(find.byType(SudokuBoardView), findsOneWidget); + expect(find.byType(MistakesCountView), findsOneWidget); - group('SudokuBoardView', () { - late PuzzleBloc puzzleBloc; - late TimerBloc timerBloc; - late Puzzle puzzle; - late Sudoku sudoku; + expect(find.byType(SudokuTimer), findsOneWidget); + expect(find.byType(SudokuInputView), findsOneWidget); + expect(find.byType(InputEraseViewForLargeLayout), findsOneWidget); + }, + ); - setUp(() { - puzzleBloc = MockPuzzleBloc(); - timerBloc = MockTimerBloc(); - puzzle = MockPuzzle(); - sudoku = MockSudoku(); - - when(() => sudoku.getDimesion()).thenReturn(3); - when(() => sudoku.blocks).thenReturn([]); - when(() => puzzle.sudoku).thenReturn(sudoku); - - when(() => puzzleBloc.state).thenReturn( - PuzzleState(puzzle: puzzle), - ); - - when(() => timerBloc.state).thenReturn( - TimerState(secondsElapsed: 1, isRunning: true), - ); - }); - - testWidgets('renders on a large layout', (tester) async { - tester.setLargeDisplaySize(); + testWidgets( + 'renders [PageHeader], [SudokuBoardView], [MistakesCountView], ' + '[SudokuTimer], and [SudokuInputView] on a medium display', + (tester) async { + tester.setMediumDisplaySize(); await tester.pumpApp( - const SudokuBoardView(), + PuzzleViewLayout(), puzzleBloc: puzzleBloc, timerBloc: timerBloc, ); - expect(find.byType(SudokuBoard), findsOneWidget); - }); + expect(find.byType(PageHeader), findsOneWidget); + expect(find.byType(SudokuBoardView), findsOneWidget); + expect(find.byType(MistakesCountView), findsOneWidget); - testWidgets('renders on a medium layout', (tester) async { - tester.setMediumDisplaySize(); + expect(find.byType(SudokuTimer), findsOneWidget); + expect(find.byType(SudokuInputView), findsOneWidget); + expect(find.byType(InputEraseViewForLargeLayout), findsNothing); + }, + ); + + testWidgets( + 'renders [PageHeader], [SudokuBoardView], [MistakesCountView], ' + '[SudokuTimer], and [SudokuInputView] on a small display', + (tester) async { + tester.setSmallDisplaySize(); await tester.pumpApp( - const SudokuBoardView(), + PuzzleViewLayout(), puzzleBloc: puzzleBloc, timerBloc: timerBloc, ); - expect(find.byType(SudokuBoard), findsOneWidget); - }); + expect(find.byType(PageHeader), findsOneWidget); + expect(find.byType(SudokuBoardView), findsOneWidget); + expect(find.byType(MistakesCountView), findsOneWidget); - testWidgets('renders on a small layout', (tester) async { - tester.setSmallDisplaySize(); + expect(find.byType(SudokuTimer), findsOneWidget); + expect(find.byType(SudokuInputView), findsOneWidget); + expect(find.byType(InputEraseViewForLargeLayout), findsNothing); + }, + ); + }); + + group('InputEraseViewForLargeLayout', () { + late PuzzleBloc puzzleBloc; + + setUp(() { + puzzleBloc = MockPuzzleBloc(); + }); + + testWidgets('adds [SudokuInputErased] when tapped', (tester) async { + await tester.pumpApp( + InputEraseViewForLargeLayout(), + puzzleBloc: puzzleBloc, + ); + await tester.tap(find.byType(GestureDetector)); + await tester.pumpAndSettle(); + + verify(() => puzzleBloc.add(SudokuInputErased())).called(1); + }); + }); + + group('SudokuBoardView', () { + late PuzzleBloc puzzleBloc; + late TimerBloc timerBloc; + late Puzzle puzzle; + late Sudoku sudoku; + + setUp(() { + puzzleBloc = MockPuzzleBloc(); + timerBloc = MockTimerBloc(); + puzzle = MockPuzzle(); + sudoku = MockSudoku(); + + when(() => sudoku.getDimesion()).thenReturn(3); + when(() => sudoku.blocks).thenReturn([]); + when(() => puzzle.sudoku).thenReturn(sudoku); + + when(() => puzzleBloc.state).thenReturn( + PuzzleState(puzzle: puzzle), + ); + + when(() => timerBloc.state).thenReturn( + TimerState(secondsElapsed: 1, isRunning: true), + ); + }); + + testWidgets('renders on a large layout', (tester) async { + tester.setLargeDisplaySize(); + + await tester.pumpApp( + const SudokuBoardView(), + puzzleBloc: puzzleBloc, + timerBloc: timerBloc, + ); + + expect(find.byType(SudokuBoard), findsOneWidget); + }); + + testWidgets('renders on a medium layout', (tester) async { + tester.setMediumDisplaySize(); + + await tester.pumpApp( + const SudokuBoardView(), + puzzleBloc: puzzleBloc, + timerBloc: timerBloc, + ); + + expect(find.byType(SudokuBoard), findsOneWidget); + }); + + testWidgets('renders on a small layout', (tester) async { + tester.setSmallDisplaySize(); + + await tester.pumpApp( + const SudokuBoardView(), + puzzleBloc: puzzleBloc, + timerBloc: timerBloc, + ); + + expect(find.byType(SudokuBoard), findsOneWidget); + }); + + testWidgets( + 're-renders only when sudoku from [PuzzleState] changes', + (tester) async { + final block1 = Block( + position: Position(x: 0, y: 0), + correctValue: 1, + currentValue: 1, + ); + final block2 = Block( + position: Position(x: 0, y: 1), + correctValue: 2, + currentValue: 2, + ); + + when(() => puzzleBloc.stream).thenAnswer( + (_) => Stream.fromIterable([ + PuzzleState( + puzzle: Puzzle( + sudoku: Sudoku(blocks: const []), + difficulty: Difficulty.medium, + ), + ), + PuzzleState( + puzzle: Puzzle( + sudoku: Sudoku(blocks: [block1, block2]), + difficulty: Difficulty.medium, + ), + ), + ]), + ); await tester.pumpApp( const SudokuBoardView(), @@ -264,51 +383,10 @@ void main() { timerBloc: timerBloc, ); - expect(find.byType(SudokuBoard), findsOneWidget); - }); - - testWidgets( - 're-renders only when sudoku from [PuzzleState] changes', - (tester) async { - final block1 = Block( - position: Position(x: 0, y: 0), - correctValue: 1, - currentValue: 1, - ); - final block2 = Block( - position: Position(x: 0, y: 1), - correctValue: 2, - currentValue: 2, - ); - - when(() => puzzleBloc.stream).thenAnswer( - (_) => Stream.fromIterable([ - PuzzleState( - puzzle: Puzzle( - sudoku: Sudoku(blocks: const []), - difficulty: Difficulty.medium, - ), - ), - PuzzleState( - puzzle: Puzzle( - sudoku: Sudoku(blocks: [block1, block2]), - difficulty: Difficulty.medium, - ), - ), - ]), - ); - - await tester.pumpApp( - const SudokuBoardView(), - puzzleBloc: puzzleBloc, - timerBloc: timerBloc, - ); - - await tester.pumpAndSettle(); - expect(find.byKey(Key('sudoku_board_view_0_0')), findsOneWidget); - expect(find.byKey(Key('sudoku_board_view_0_1')), findsOneWidget); - }, - ); - }); + await tester.pumpAndSettle(); + expect(find.byKey(Key('sudoku_board_view_0_0')), findsOneWidget); + expect(find.byKey(Key('sudoku_board_view_0_1')), findsOneWidget); + }, + ); }); } diff --git a/test/puzzle/widgets/congrats_dialog_test.dart b/test/puzzle/widgets/congrats_dialog_test.dart new file mode 100644 index 0000000..23d58a0 --- /dev/null +++ b/test/puzzle/widgets/congrats_dialog_test.dart @@ -0,0 +1,57 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sudoku/models/models.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; +import 'package:sudoku/widgets/widgets.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('CongratsDialog', () { + const largeKey = Key('congrats_dialog_large'); + const mediumKey = Key('congrats_dialog_medium'); + const smallKey = Key('congrats_dialog_small'); + + CongratsDialog createWidget() => CongratsDialog( + difficulty: Difficulty.medium, + timeInSeconds: 167, + ); + + testWidgets('renders on a large display', (tester) async { + tester.setLargeDisplaySize(); + await tester.pumpApp(createWidget()); + expect(find.byKey(largeKey), findsOneWidget); + }); + + testWidgets('renders on a medium display', (tester) async { + tester.setMediumDisplaySize(); + await tester.pumpApp(createWidget()); + expect(find.byKey(mediumKey), findsOneWidget); + }); + + testWidgets('renders on a small display', (tester) async { + tester.setSmallDisplaySize(); + await tester.pumpApp(createWidget()); + expect(find.byKey(smallKey), findsOneWidget); + }); + + group('Navigator', () { + testWidgets( + 'pops back to first route when tapped [SudokuElevatedButton]', + (tester) async { + await tester.pumpApp(createWidget()); + + final finder = find.byType(SudokuElevatedButton); + expect(finder, findsOneWidget); + + await tester.tap(finder); + await tester.pumpAndSettle(); + + expect(Navigator.of(tester.element(finder)).canPop(), isFalse); + }, + ); + }); + }); +} diff --git a/test/puzzle/widgets/game_over_dialog_test.dart b/test/puzzle/widgets/game_over_dialog_test.dart new file mode 100644 index 0000000..4d430b1 --- /dev/null +++ b/test/puzzle/widgets/game_over_dialog_test.dart @@ -0,0 +1,53 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; +import 'package:sudoku/widgets/widgets.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('GameOverDialog', () { + const largeKey = Key('game_over_dialog_large'); + const mediumKey = Key('game_over_dialog_medium'); + const smallKey = Key('game_over_dialog_small'); + + GameOverDialog createWidget() => GameOverDialog(); + + testWidgets('renders on a large display', (tester) async { + tester.setLargeDisplaySize(); + await tester.pumpApp(createWidget()); + expect(find.byKey(largeKey), findsOneWidget); + }); + + testWidgets('renders on a medium display', (tester) async { + tester.setMediumDisplaySize(); + await tester.pumpApp(createWidget()); + expect(find.byKey(mediumKey), findsOneWidget); + }); + + testWidgets('renders on a small display', (tester) async { + tester.setSmallDisplaySize(); + await tester.pumpApp(createWidget()); + expect(find.byKey(smallKey), findsOneWidget); + }); + + group('Navigator', () { + testWidgets( + 'pops back to first route when tapped [SudokuElevatedButton]', + (tester) async { + await tester.pumpApp(createWidget()); + + final finder = find.byType(SudokuElevatedButton); + expect(finder, findsOneWidget); + + await tester.tap(finder); + await tester.pumpAndSettle(); + + expect(Navigator.of(tester.element(finder)).canPop(), isFalse); + }, + ); + }); + }); +} diff --git a/test/sudoku/widgets/sudoku_block_test.dart b/test/sudoku/widgets/sudoku_block_test.dart index ee6c956..801b7a2 100644 --- a/test/sudoku/widgets/sudoku_block_test.dart +++ b/test/sudoku/widgets/sudoku_block_test.dart @@ -90,6 +90,18 @@ void main() { expect(find.byKey(Key(smallBlockKey)), findsOneWidget); }); + testWidgets('renders correctly in dark mode', (tester) async { + tester.setSmallDisplaySize(); + + await tester.pumpApp( + SudokuBlock(block: block, state: state), + puzzleBloc: bloc, + brightness: Brightness.dark, + ); + + expect(find.byKey(Key(smallBlockKey)), findsOneWidget); + }); + testWidgets( 'renders block when block is part of highlighted, but not selcted', (tester) async { diff --git a/test/sudoku/widgets/sudoku_board_test.dart b/test/sudoku/widgets/sudoku_board_test.dart index 6dc1efa..9b34f68 100644 --- a/test/sudoku/widgets/sudoku_board_test.dart +++ b/test/sudoku/widgets/sudoku_board_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; import 'package:sudoku/sudoku/sudoku.dart'; import 'package:sudoku/timer/timer.dart'; @@ -11,6 +12,8 @@ import '../../helpers/helpers.dart'; void main() { group('SudokuBoard', () { late TimerBloc timerBloc; + late PuzzleBloc puzzleBloc; + late PuzzleState puzzleState; const largeKey = Key('sudoku_board_large'); const mediumKey = Key('sudoku_board_medium'); @@ -18,7 +21,12 @@ void main() { setUp(() { timerBloc = MockTimerBloc(); + puzzleBloc = MockPuzzleBloc(); + puzzleState = MockPuzzleState(); + when(() => timerBloc.state).thenReturn(TimerState()); + when(() => puzzleState.puzzleStatus).thenReturn(PuzzleStatus.incomplete); + when(() => puzzleBloc.state).thenReturn(puzzleState); }); testWidgets('renders on a large layout', (tester) async { @@ -26,6 +34,7 @@ void main() { await tester.pumpApp( SudokuBoard(blocks: const []), timerBloc: timerBloc, + puzzleBloc: puzzleBloc, ); expect(find.byKey(largeKey), findsOneWidget); }); @@ -35,6 +44,7 @@ void main() { await tester.pumpApp( SudokuBoard(blocks: const []), timerBloc: timerBloc, + puzzleBloc: puzzleBloc, ); expect(find.byKey(mediumKey), findsOneWidget); }); @@ -44,6 +54,7 @@ void main() { await tester.pumpApp( SudokuBoard(blocks: const []), timerBloc: timerBloc, + puzzleBloc: puzzleBloc, ); expect(find.byKey(smallKey), findsOneWidget); }); @@ -57,6 +68,7 @@ void main() { await tester.pumpApp( SudokuBoard(blocks: const []), timerBloc: timerBloc, + puzzleBloc: puzzleBloc, ); expect(find.byType(FloatingActionButton), findsNothing); }, @@ -71,6 +83,7 @@ void main() { await tester.pumpApp( SudokuBoard(blocks: const []), timerBloc: timerBloc, + puzzleBloc: puzzleBloc, ); expect(find.byType(FloatingActionButton), findsOneWidget); }, @@ -85,6 +98,7 @@ void main() { await tester.pumpApp( SudokuBoard(blocks: const []), timerBloc: timerBloc, + puzzleBloc: puzzleBloc, ); await tester.tap(find.byType(FloatingActionButton)); verify(() => timerBloc.add(TimerResumed())).called(1); diff --git a/test/utilities/difficulty_extension_test.dart b/test/utilities/difficulty_extension_test.dart new file mode 100644 index 0000000..c1ff4bb --- /dev/null +++ b/test/utilities/difficulty_extension_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sudoku/colors/colors.dart'; +import 'package:sudoku/models/models.dart'; +import 'package:sudoku/utilities/utilities.dart'; + +void main() { + group('DifficultyExtension', () { + group('color', () { + test('returns correct SudokuColor', () { + expect(Difficulty.easy.color, equals(SudokuColors.green)); + expect(Difficulty.medium.color, equals(SudokuColors.amber)); + expect(Difficulty.difficult.color, equals(SudokuColors.orange)); + expect(Difficulty.expert.color, equals(SudokuColors.teal)); + }); + }); + + group('article', () { + test('returns correct article', () { + expect(Difficulty.easy.article, equals('an')); + expect(Difficulty.medium.article, equals('a')); + expect(Difficulty.difficult.article, equals('a')); + expect(Difficulty.expert.article, equals('an')); + }); + }); + }); +} diff --git a/test/utilities/time_formatter_test.dart b/test/utilities/time_formatter_test.dart new file mode 100644 index 0000000..aeea0c4 --- /dev/null +++ b/test/utilities/time_formatter_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sudoku/utilities/utilities.dart'; + +void main() { + group('TimeFormatter', () { + test('returns correct formatted string', () { + expect(15.format, equals('00:00:15')); + expect(137.format, equals('00:02:17')); + expect(3662.format, equals('01:01:02')); + }); + }); +}