diff --git a/assets/icons/heart.png b/assets/icons/heart.png new file mode 100644 index 0000000..07dad0f Binary files /dev/null and b/assets/icons/heart.png differ diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 4c10710..a6bab93 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -4,20 +4,31 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:sudoku/api/api.dart'; import 'package:sudoku/home/home.dart'; import 'package:sudoku/l10n/l10n.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; import 'package:sudoku/theme/theme.dart'; class App extends StatelessWidget { const App({ required SudokuAPI apiClient, + required PuzzleRepository puzzleRepository, super.key, - }) : _apiClient = apiClient; + }) : _apiClient = apiClient, + _puzzleRepository = puzzleRepository; final SudokuAPI _apiClient; + final PuzzleRepository _puzzleRepository; @override Widget build(BuildContext context) { - return RepositoryProvider.value( - value: _apiClient, + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value( + value: _apiClient, + ), + RepositoryProvider.value( + value: _puzzleRepository, + ), + ], child: const AppView(), ); } diff --git a/lib/assets/assets.dart b/lib/assets/assets.dart index 4f883b6..6dda0ab 100644 --- a/lib/assets/assets.dart +++ b/lib/assets/assets.dart @@ -20,4 +20,7 @@ abstract class Assets { /// Gemini icon. static const geminiIcon = 'assets/icons/gemini.png'; + + /// Heart icon. + static const heartIcon = 'assets/icons/heart.png'; } diff --git a/lib/home/bloc/home_bloc.dart b/lib/home/bloc/home_bloc.dart index 8f590a7..3096017 100644 --- a/lib/home/bloc/home_bloc.dart +++ b/lib/home/bloc/home_bloc.dart @@ -4,6 +4,7 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:sudoku/api/api.dart'; import 'package:sudoku/models/models.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; part 'home_event.dart'; part 'home_state.dart'; @@ -11,12 +12,15 @@ part 'home_state.dart'; class HomeBloc extends Bloc { HomeBloc({ required SudokuAPI apiClient, + required PuzzleRepository puzzleRepository, }) : _apiClient = apiClient, + _puzzleRepository = puzzleRepository, super(const HomeState()) { on(_onSudokuCreationRequested); } final SudokuAPI _apiClient; + final PuzzleRepository _puzzleRepository; FutureOr _onSudokuCreationRequested( SudokuCreationRequested event, @@ -24,7 +28,6 @@ class HomeBloc extends Bloc { ) async { emit( state.copyWith( - sudoku: () => null, difficulty: () => event.difficulty, sudokuCreationStatus: () => SudokuCreationStatus.inProgress, sudokuCreationError: () => null, @@ -35,9 +38,11 @@ class HomeBloc extends Bloc { final sudoku = await _apiClient.createSudoku( difficulty: event.difficulty, ); + _puzzleRepository.storePuzzle( + puzzle: Puzzle(sudoku: sudoku, difficulty: event.difficulty), + ); emit( state.copyWith( - sudoku: () => sudoku, sudokuCreationStatus: () => SudokuCreationStatus.completed, ), ); diff --git a/lib/home/bloc/home_state.dart b/lib/home/bloc/home_state.dart index 287af7e..6d8c224 100644 --- a/lib/home/bloc/home_state.dart +++ b/lib/home/bloc/home_state.dart @@ -7,33 +7,28 @@ enum SudokuCreationErrorType { unexpected, invalidRawData, apiClient } class HomeState extends Equatable { const HomeState({ - this.sudoku, this.difficulty, this.sudokuCreationStatus = SudokuCreationStatus.initial, this.sudokuCreationError, }); - final Sudoku? sudoku; final Difficulty? difficulty; final SudokuCreationStatus sudokuCreationStatus; final SudokuCreationErrorType? sudokuCreationError; @override List get props => [ - sudoku, difficulty, sudokuCreationStatus, sudokuCreationError, ]; HomeState copyWith({ - Sudoku? Function()? sudoku, Difficulty? Function()? difficulty, SudokuCreationStatus Function()? sudokuCreationStatus, SudokuCreationErrorType? Function()? sudokuCreationError, }) { return HomeState( - sudoku: sudoku != null ? sudoku() : this.sudoku, difficulty: difficulty != null ? difficulty() : this.difficulty, sudokuCreationStatus: sudokuCreationStatus != null ? sudokuCreationStatus() diff --git a/lib/home/view/home_page.dart b/lib/home/view/home_page.dart index f2e9ab2..adeebcf 100644 --- a/lib/home/view/home_page.dart +++ b/lib/home/view/home_page.dart @@ -9,7 +9,7 @@ import 'package:sudoku/home/home.dart'; import 'package:sudoku/l10n/l10n.dart'; import 'package:sudoku/layout/layout.dart'; import 'package:sudoku/models/models.dart'; -import 'package:sudoku/sudoku/sudoku.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; import 'package:sudoku/typography/typography.dart'; import 'package:sudoku/widgets/widgets.dart'; @@ -25,6 +25,7 @@ class HomePage extends StatelessWidget { return BlocProvider( create: (context) => HomeBloc( apiClient: context.read(), + puzzleRepository: context.read(), ), child: const HomeView(), ); @@ -54,13 +55,12 @@ class HomeView extends StatelessWidget { ); } - if (state.sudoku != null && - state.sudokuCreationStatus == SudokuCreationStatus.completed) { + if (state.sudokuCreationStatus == SudokuCreationStatus.completed) { Navigator.pop(context); Navigator.push( context, MaterialPageRoute( - builder: (context) => SudokuPage(sudoku: state.sudoku!), + builder: (context) => const PuzzlePage(), ), ); } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 4b7a3bb..2b2415a 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -8,10 +8,6 @@ "@sudokuGameSubtitle": { "description": "Text shown in Home Page for Medium and Large screen as the subtitle" }, - "sudokuAppBarTitle": "New Sudoku Game", - "@sudokuAppBarTitle": { - "description": "Text shown in the AppBar of the Sudoku Page" - }, "resumeTimerButtonText": "Resume the puzzle", "@resumeTimerButtonText": { "description": "Text shown in the FloatingActionButton of the Sudoku Board" @@ -96,5 +92,26 @@ "errorClientDialogSubtitle": "There has been an error while communicating to the backend service. Please try again. If this issue persists, please try after some time.", "@errorClientDialogSubtitle": { "description": "Text shown as subtitle in the error due to client dialog in Home Page" + }, + "puzzleAppBarDifficulty": "{difficulty, select, easy{Easy} medium{Medium} difficult{Difficult} expert{Expert} other{New}}", + "@puzzleAppBarDifficulty": { + "description": "Text shown in app bar of the Puzzle Page", + "placeholders": { + "difficulty": { + "type": "String" + } + } + }, + "puzzleAppBarSudoku": "Sudoku", + "@puzzleAppBarSudoku": { + "description": "Text shown in app bar of the Puzzle Page" + }, + "eraseInputButtonText": "Erase number", + "@eraseInputButtonText": { + "description": "Text shown in the input erase button in Puzzle Page" + }, + "sudokuLoadingText": "Building your sudoku", + "@sudokuLoadingText": { + "description": "Text shown when sudoku is getting initialized in Puzzle Page" } } \ No newline at end of file diff --git a/lib/main_development.dart b/lib/main_development.dart index 4869477..94a9ce5 100644 --- a/lib/main_development.dart +++ b/lib/main_development.dart @@ -1,11 +1,20 @@ import 'package:sudoku/api/api.dart'; import 'package:sudoku/app/app.dart'; import 'package:sudoku/bootstrap.dart'; +import 'package:sudoku/cache/cache.dart'; import 'package:sudoku/env/env.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; void main() { bootstrap(() { final apiClient = SudokuDioClient(baseUrl: Env.apiBaseUrl); - return App(apiClient: apiClient); + + final cacheClient = CacheClient(); + final puzzleRepository = PuzzleRepository(cacheClient: cacheClient); + + return App( + apiClient: apiClient, + puzzleRepository: puzzleRepository, + ); }); } diff --git a/lib/main_production.dart b/lib/main_production.dart index 4869477..94a9ce5 100644 --- a/lib/main_production.dart +++ b/lib/main_production.dart @@ -1,11 +1,20 @@ import 'package:sudoku/api/api.dart'; import 'package:sudoku/app/app.dart'; import 'package:sudoku/bootstrap.dart'; +import 'package:sudoku/cache/cache.dart'; import 'package:sudoku/env/env.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; void main() { bootstrap(() { final apiClient = SudokuDioClient(baseUrl: Env.apiBaseUrl); - return App(apiClient: apiClient); + + final cacheClient = CacheClient(); + final puzzleRepository = PuzzleRepository(cacheClient: cacheClient); + + return App( + apiClient: apiClient, + puzzleRepository: puzzleRepository, + ); }); } diff --git a/lib/main_staging.dart b/lib/main_staging.dart index 4869477..94a9ce5 100644 --- a/lib/main_staging.dart +++ b/lib/main_staging.dart @@ -1,11 +1,20 @@ import 'package:sudoku/api/api.dart'; import 'package:sudoku/app/app.dart'; import 'package:sudoku/bootstrap.dart'; +import 'package:sudoku/cache/cache.dart'; import 'package:sudoku/env/env.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; void main() { bootstrap(() { final apiClient = SudokuDioClient(baseUrl: Env.apiBaseUrl); - return App(apiClient: apiClient); + + final cacheClient = CacheClient(); + final puzzleRepository = PuzzleRepository(cacheClient: cacheClient); + + return App( + apiClient: apiClient, + puzzleRepository: puzzleRepository, + ); }); } diff --git a/lib/puzzle/puzzle.dart b/lib/puzzle/puzzle.dart index 64ad6f3..3b17992 100644 --- a/lib/puzzle/puzzle.dart +++ b/lib/puzzle/puzzle.dart @@ -1,3 +1,5 @@ export 'bloc/puzzle_bloc.dart'; export 'models/models.dart'; export 'repository/puzzle_repository.dart'; +export 'view/view.dart'; +export 'widgets/widgets.dart'; diff --git a/lib/puzzle/view/puzzle_page.dart b/lib/puzzle/view/puzzle_page.dart new file mode 100644 index 0000000..5714c34 --- /dev/null +++ b/lib/puzzle/view/puzzle_page.dart @@ -0,0 +1,338 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +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/models/models.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'; +import 'package:sudoku/widgets/widgets.dart'; + +class PuzzlePage extends StatelessWidget { + const PuzzlePage({super.key}); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => PuzzleBloc( + puzzleRepository: context.read(), + )..add(const PuzzleInitialized()), + ), + BlocProvider( + create: (context) => TimerBloc( + ticker: const Ticker(), + )..add(const TimerStarted()), + ), + ], + child: const PuzzleView(), + ); + } +} + +@visibleForTesting +class PuzzleView extends StatelessWidget { + const PuzzleView({super.key}); + + @override + 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(), + ), + ); + } +} + +@visibleForTesting +class PuzzleViewLayout extends StatelessWidget { + const PuzzleViewLayout({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final dimension = context.select( + (PuzzleBloc bloc) => bloc.state.puzzle.sudoku.getDimesion(), + ); + + if (dimension <= 0) { + return Center( + child: Text( + l10n.sudokuLoadingText, + style: SudokuTextStyle.caption, + ), + ); + } + + return ResponsiveLayoutBuilder( + small: (_, child) => child!, + medium: (_, child) => child!, + large: (_, child) => Align( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: MediaQuery.of(context).size.height, + maxWidth: SudokuBreakpoint.large, + ), + child: const Align( + child: SingleChildScrollView( + child: Column( + children: [ + PageHeader(), + ResponsiveGap(large: 96), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SudokuBoardView(), + SizedBox(width: 56), + Column( + children: [ + SizedBox( + width: SudokuInputSize.large * 3, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MistakesCountView(), + SudokuTimer(), + ], + ), + ), + ResponsiveGap(large: 32), + SudokuInputView(), + SizedBox(height: 32), + InputEraseViewForLargeLayout(), + ], + ), + ], + ), + ], + ), + ), + ), + ), + ), + child: (layoutSize) { + final maxWidth = switch (layoutSize) { + ResponsiveLayoutSize.small => SudokuBoardSize.small, + ResponsiveLayoutSize.medium => SudokuBoardSize.medium, + ResponsiveLayoutSize.large => SudokuInputSize.large * dimension, + }; + + return Align( + alignment: Alignment.topCenter, + child: SingleChildScrollView( + child: Column( + children: [ + const ResponsiveGap( + small: 72, + medium: 24, + large: 32, + ), + const PageHeader(), + const ResponsiveGap( + small: 24, + medium: 32, + large: 48, + ), + SizedBox( + width: maxWidth, + child: const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MistakesCountView(), + SudokuTimer(), + ], + ), + ), + const ResponsiveGap( + small: 16, + medium: 24, + large: 32, + ), + const SudokuBoardView(), + const ResponsiveGap( + small: 16, + medium: 24, + large: 32, + ), + const SudokuInputView(), + ], + ), + ), + ); + }, + ); + } +} + +class InputEraseViewForLargeLayout extends StatelessWidget { + const InputEraseViewForLargeLayout({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return GestureDetector( + onTap: () => context.read().add(const SudokuInputErased()), + child: SizedBox( + width: SudokuInputSize.large * 3, + height: 56, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + gradient: const LinearGradient( + colors: [ + SudokuColors.darkPurple, + SudokuColors.darkPink, + ], + begin: Alignment.bottomLeft, + end: Alignment.topRight, + ), + ), + child: Center( + child: Text( + l10n.eraseInputButtonText, + textAlign: TextAlign.center, + style: SudokuTextStyle.button.copyWith( + color: Colors.white, + ), + ), + ), + ), + ), + ); + } +} + +@visibleForTesting +class SudokuInputView extends StatelessWidget { + const SudokuInputView({super.key}); + + @override + Widget build(BuildContext context) { + final sudoku = context.select( + (PuzzleBloc bloc) => bloc.state.puzzle.sudoku, + ); + + return SudokuInput( + sudokuDimension: sudoku.getDimesion(), + ); + } +} + +@visibleForTesting +class PageHeader extends StatelessWidget { + const PageHeader({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + const gradient = LinearGradient( + colors: [ + SudokuColors.darkPurple, + SudokuColors.darkPink, + ], + begin: Alignment.bottomLeft, + end: Alignment.topRight, + ); + + final difficulty = context.select( + (PuzzleBloc bloc) => bloc.state.puzzle.difficulty, + ); + + return ResponsiveLayoutBuilder( + small: (_, child) => child!, + medium: (_, child) => child!, + large: (_, child) => child!, + child: (layoutSize) { + final titleTextStyle = switch (layoutSize) { + ResponsiveLayoutSize.small => SudokuTextStyle.headline6, + ResponsiveLayoutSize.medium => SudokuTextStyle.headline5, + ResponsiveLayoutSize.large => SudokuTextStyle.headline1, + }; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + ShaderMask( + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) => gradient.createShader( + Rect.fromLTWH(0, 0, bounds.width, bounds.height), + ), + child: Text( + l10n.puzzleAppBarDifficulty(difficulty.name), + style: titleTextStyle, + ), + ), + Text( + ' ${l10n.puzzleAppBarSudoku}', + style: titleTextStyle, + ), + ], + ); + }, + ); + } +} + +@visibleForTesting +class SudokuBoardView extends StatelessWidget { + const SudokuBoardView({super.key}); + + @override + Widget build(BuildContext context) { + final dimension = context.select( + (PuzzleBloc bloc) => bloc.state.puzzle.sudoku.getDimesion(), + ); + + return ResponsiveLayoutBuilder( + small: (_, child) => child!, + medium: (_, child) => child!, + large: (_, child) => child!, + child: (layoutSize) { + final blockSize = switch (layoutSize) { + ResponsiveLayoutSize.small => SudokuBoardSize.small / dimension, + ResponsiveLayoutSize.medium => SudokuBoardSize.medium / dimension, + ResponsiveLayoutSize.large => SudokuBoardSize.large / dimension, + }; + + const key = 'sudoku_board_view'; + + return BlocBuilder( + buildWhen: (p, c) => p.puzzle.sudoku != c.puzzle.sudoku, + builder: (context, state) { + return SudokuBoard( + blocks: [ + for (final block in state.puzzle.sudoku.blocks) + Positioned( + key: Key('${key}_${block.position.x}_${block.position.y}'), + top: block.position.x * blockSize, + left: block.position.y * blockSize, + child: SudokuBlock( + block: block, + state: state, + ), + ), + ], + ); + }, + ); + }, + ); + } +} diff --git a/lib/puzzle/view/view.dart b/lib/puzzle/view/view.dart new file mode 100644 index 0000000..f4f5aa1 --- /dev/null +++ b/lib/puzzle/view/view.dart @@ -0,0 +1 @@ +export 'puzzle_page.dart'; diff --git a/lib/puzzle/widgets/mistakes_count_view.dart b/lib/puzzle/widgets/mistakes_count_view.dart new file mode 100644 index 0000000..d852189 --- /dev/null +++ b/lib/puzzle/widgets/mistakes_count_view.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:sudoku/assets/assets.dart'; +import 'package:sudoku/layout/layout.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; +import 'package:sudoku/typography/typography.dart'; + +/// {@template mistakes_count_view} +/// Displays how many mistakes are remaining for the current puzzle. +/// {@endtemplate} +class MistakesCountView extends StatelessWidget { + /// {@macro mistakes_count_view} + const MistakesCountView({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final mistakesCount = context.select( + (PuzzleBloc bloc) => bloc.state.puzzle.remainingMistakes, + ); + + return ResponsiveLayoutBuilder( + small: (_, child) => child!, + medium: (_, child) => child!, + large: (_, child) => child!, + child: (layoutSize) { + final timerTextStyle = switch (layoutSize) { + ResponsiveLayoutSize.large => SudokuTextStyle.bodyText1, + _ => SudokuTextStyle.bodyText1, + }; + + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: theme.dividerColor, + width: 1.4, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + Assets.heartIcon, + height: timerTextStyle.fontSize, + width: timerTextStyle.fontSize, + ), + Text( + ' x $mistakesCount', + style: timerTextStyle, + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/puzzle/widgets/widgets.dart b/lib/puzzle/widgets/widgets.dart new file mode 100644 index 0000000..734dc5a --- /dev/null +++ b/lib/puzzle/widgets/widgets.dart @@ -0,0 +1 @@ +export 'mistakes_count_view.dart'; diff --git a/lib/sudoku/bloc/sudoku_bloc.dart b/lib/sudoku/bloc/sudoku_bloc.dart deleted file mode 100644 index e1794ff..0000000 --- a/lib/sudoku/bloc/sudoku_bloc.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:sudoku/models/models.dart'; - -part 'sudoku_event.dart'; -part 'sudoku_state.dart'; - -class SudokuBloc extends Bloc { - SudokuBloc({required Sudoku sudoku}) : super(SudokuState(sudoku: sudoku)) { - on(_onBlockSelected); - on(_onInputTapped); - } - - void _onBlockSelected(SudokuBlockSelected event, Emitter emit) { - if (event.block.isGenerated) { - emit( - state.copyWith( - blockSelectionStatus: () => BlockSelectionStatus.cannotBeSelected, - currentSelectedBlock: () => null, - highlightedBlocks: () => {}, - ), - ); - } else { - final subGridBlocks = state.sudoku.getSubGridBlocks(event.block); - final rowBlocks = state.sudoku.getRowBlocks(event.block); - final columnBlocks = state.sudoku.getColumnBlocks(event.block); - - final highlightedBlocks = [ - ...subGridBlocks, - ...rowBlocks, - ...columnBlocks, - ]; - - emit( - state.copyWith( - blockSelectionStatus: () => BlockSelectionStatus.selected, - currentSelectedBlock: () => event.block, - highlightedBlocks: highlightedBlocks.toSet, - ), - ); - } - } - - void _onInputTapped(SudokuInputTapped event, Emitter emit) { - if (state.currentSelectedBlock == null) return; - - final mutableSudoku = Sudoku(blocks: [...state.sudoku.blocks]); - final blockToUpdate = state.currentSelectedBlock!; - - final sudoku = mutableSudoku.updateBlock(blockToUpdate, event.input); - - if (sudoku.isComplete()) { - emit( - state.copyWith( - puzzleStatus: () => SudokuPuzzleStatus.complete, - sudoku: () => sudoku, - ), - ); - } else { - emit( - state.copyWith(sudoku: () => sudoku), - ); - } - } -} diff --git a/lib/sudoku/bloc/sudoku_event.dart b/lib/sudoku/bloc/sudoku_event.dart deleted file mode 100644 index 132a2bb..0000000 --- a/lib/sudoku/bloc/sudoku_event.dart +++ /dev/null @@ -1,21 +0,0 @@ -part of 'sudoku_bloc.dart'; - -sealed class SudokuEvent extends Equatable { - const SudokuEvent(); -} - -final class SudokuBlockSelected extends SudokuEvent { - const SudokuBlockSelected(this.block); - final Block block; - - @override - List get props => [block]; -} - -final class SudokuInputTapped extends SudokuEvent { - const SudokuInputTapped(this.input); - final int input; - - @override - List get props => [input]; -} diff --git a/lib/sudoku/bloc/sudoku_state.dart b/lib/sudoku/bloc/sudoku_state.dart deleted file mode 100644 index 385b4fb..0000000 --- a/lib/sudoku/bloc/sudoku_state.dart +++ /dev/null @@ -1,53 +0,0 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first -part of 'sudoku_bloc.dart'; - -enum SudokuPuzzleStatus { incomplete, complete } - -enum BlockSelectionStatus { nothingSelected, cannotBeSelected, selected } - -class SudokuState extends Equatable { - const SudokuState({ - required this.sudoku, - this.puzzleStatus = SudokuPuzzleStatus.incomplete, - this.blockSelectionStatus = BlockSelectionStatus.nothingSelected, - this.highlightedBlocks = const {}, - this.currentSelectedBlock, - }); - - final Sudoku sudoku; - final SudokuPuzzleStatus puzzleStatus; - final BlockSelectionStatus blockSelectionStatus; - final Set highlightedBlocks; - final Block? currentSelectedBlock; - - @override - List get props => [ - sudoku, - puzzleStatus, - blockSelectionStatus, - highlightedBlocks, - currentSelectedBlock, - ]; - - SudokuState copyWith({ - Sudoku Function()? sudoku, - SudokuPuzzleStatus Function()? puzzleStatus, - BlockSelectionStatus Function()? blockSelectionStatus, - Set Function()? highlightedBlocks, - Block? Function()? currentSelectedBlock, - }) { - return SudokuState( - sudoku: sudoku != null ? sudoku() : this.sudoku, - puzzleStatus: puzzleStatus != null ? puzzleStatus() : this.puzzleStatus, - blockSelectionStatus: blockSelectionStatus != null - ? blockSelectionStatus() - : this.blockSelectionStatus, - highlightedBlocks: highlightedBlocks != null - ? highlightedBlocks() - : this.highlightedBlocks, - currentSelectedBlock: currentSelectedBlock != null - ? currentSelectedBlock() - : this.currentSelectedBlock, - ); - } -} diff --git a/lib/sudoku/sudoku.dart b/lib/sudoku/sudoku.dart index 8c02f88..ec0822d 100644 --- a/lib/sudoku/sudoku.dart +++ b/lib/sudoku/sudoku.dart @@ -1,4 +1,2 @@ -export 'bloc/sudoku_bloc.dart'; export 'models/models.dart'; -export 'view/view.dart'; export 'widgets/widgets.dart'; diff --git a/lib/sudoku/view/sudoku_page.dart b/lib/sudoku/view/sudoku_page.dart deleted file mode 100644 index cf30ba9..0000000 --- a/lib/sudoku/view/sudoku_page.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:sudoku/l10n/l10n.dart'; -import 'package:sudoku/layout/layout.dart'; -import 'package:sudoku/models/models.dart'; -import 'package:sudoku/sudoku/sudoku.dart'; -import 'package:sudoku/timer/timer.dart'; -import 'package:sudoku/typography/typography.dart'; - -/// {@template sudoku_page} -/// The root page of the Sudoku UI. -/// {@endtemplate} -class SudokuPage extends StatelessWidget { - /// {@macro sudoku_page} - const SudokuPage({required this.sudoku, super.key}); - final Sudoku sudoku; - - @override - Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => SudokuBloc( - sudoku: sudoku, - ), - ), - BlocProvider( - create: (context) => TimerBloc( - ticker: const Ticker(), - )..add(const TimerStarted()), - ), - ], - child: const SudokuView(), - ); - } -} - -/// {@template sudoku_view} -/// Displays the content for the [SudokuPage]. -/// {@endtemplate} -class SudokuView extends StatelessWidget { - /// {@macro sudoku_view} - const SudokuView({super.key}); - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final sudoku = context.select( - (SudokuBloc bloc) => bloc.state.sudoku, - ); - - return ResponsiveLayoutBuilder( - small: (_, child) => child!, - medium: (_, child) => child!, - large: (_, __) => Scaffold( - body: SingleChildScrollView( - child: Column( - children: [ - const ResponsiveGap(large: 246), - const Center( - child: SudokuTimer(), - ), - const ResponsiveGap(large: 96), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - width: 250, - child: Text( - l10n.sudokuAppBarTitle, - style: SudokuTextStyle.headline1.copyWith( - fontWeight: SudokuFontWeight.black, - ), - ), - ), - const SizedBox(width: 60), - const SudokuBoardView( - layoutSize: ResponsiveLayoutSize.large, - ), - const SizedBox(width: 96), - SudokuInput( - sudokuDimension: sudoku.getDimesion(), - ), - ], - ), - const ResponsiveGap(large: 246), - ], - ), - ), - ), - child: (layoutSize) { - return Scaffold( - appBar: AppBar( - title: Text(l10n.sudokuAppBarTitle), - ), - body: SingleChildScrollView( - child: SizedBox( - width: double.maxFinite, - child: Column( - children: [ - const ResponsiveGap(small: 16, medium: 24), - const SudokuTimer(), - const ResponsiveGap(small: 16, medium: 24), - SudokuBoardView(layoutSize: layoutSize), - const ResponsiveGap(small: 32, medium: 56), - SudokuInput( - sudokuDimension: sudoku.getDimesion(), - ), - ], - ), - ), - ), - ); - }, - ); - } -} - -@visibleForTesting -class SudokuBoardView extends StatelessWidget { - const SudokuBoardView({ - required this.layoutSize, - super.key, - }); - final ResponsiveLayoutSize layoutSize; - - @override - Widget build(BuildContext context) { - final dimension = context.select( - (SudokuBloc bloc) => bloc.state.sudoku.getDimesion(), - ); - - final blockSize = switch (layoutSize) { - ResponsiveLayoutSize.small => SudokuBoardSize.small / dimension, - ResponsiveLayoutSize.medium => SudokuBoardSize.medium / dimension, - ResponsiveLayoutSize.large => SudokuBoardSize.large / dimension, - }; - - return BlocBuilder( - buildWhen: (previous, current) => previous.sudoku != current.sudoku, - builder: (context, state) { - return SudokuBoard( - blocks: [ - for (final block in state.sudoku.blocks) - Positioned( - key: Key( - 'sudoku_board_view_${block.position.x}_${block.position.y}', - ), - top: block.position.x * blockSize, - left: block.position.y * blockSize, - child: SudokuBlock( - block: block, - state: state, - ), - ), - ], - ); - }, - ); - } -} diff --git a/lib/sudoku/view/view.dart b/lib/sudoku/view/view.dart deleted file mode 100644 index c3b3560..0000000 --- a/lib/sudoku/view/view.dart +++ /dev/null @@ -1 +0,0 @@ -export 'sudoku_page.dart'; diff --git a/lib/sudoku/widgets/sudoku_block.dart b/lib/sudoku/widgets/sudoku_block.dart index 8ae64db..b76b70a 100644 --- a/lib/sudoku/widgets/sudoku_block.dart +++ b/lib/sudoku/widgets/sudoku_block.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:sudoku/colors/colors.dart'; import 'package:sudoku/layout/layout.dart'; import 'package:sudoku/models/models.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; import 'package:sudoku/sudoku/sudoku.dart'; +// import 'package:sudoku/sudoku/sudoku.dart'; import 'package:sudoku/typography/typography.dart'; /// {@template sudoku_block} -/// Displays the Sudoku [block] based upon the current [state]. +/// Displays the Sudoku [block] based upon the current puzzle [state]. /// {@endtemplate} class SudokuBlock extends StatelessWidget { /// {@macro sudoku_block} @@ -20,18 +23,18 @@ class SudokuBlock extends StatelessWidget { final Block block; /// The state of the sudoku. - final SudokuState state; + final PuzzleState state; @override Widget build(BuildContext context) { final theme = Theme.of(context); - final dimension = state.sudoku.getDimesion(); + final dimension = state.puzzle.sudoku.getDimesion(); final selectedBlock = context.select( - (SudokuBloc bloc) => bloc.state.currentSelectedBlock, + (PuzzleBloc bloc) => bloc.state.selectedBlock, ); final highlightedBlocks = context.select( - (SudokuBloc bloc) => bloc.state.highlightedBlocks, + (PuzzleBloc bloc) => bloc.state.highlightedBlocks, ); // Comparing with the current block's position, otherwise @@ -64,7 +67,7 @@ class SudokuBlock extends StatelessWidget { child: (_) { return GestureDetector( onTap: () { - context.read().add(SudokuBlockSelected(block)); + context.read().add(SudokuBlockSelected(block)); }, child: DecoratedBox( decoration: BoxDecoration( @@ -81,7 +84,11 @@ class SudokuBlock extends StatelessWidget { child: Text( block.currentValue != -1 ? '${block.currentValue}' : '', style: SudokuTextStyle.bodyText1.copyWith( - color: block.isGenerated ? null : theme.colorScheme.secondary, + color: block.correctValue != block.currentValue + ? theme.colorScheme.error + : block.isGenerated + ? null + : SudokuColors.darkPurple, ), ), ), diff --git a/lib/sudoku/widgets/sudoku_board.dart b/lib/sudoku/widgets/sudoku_board.dart index 52d164b..b0f38e9 100644 --- a/lib/sudoku/widgets/sudoku_board.dart +++ b/lib/sudoku/widgets/sudoku_board.dart @@ -2,10 +2,12 @@ import 'dart:math'; import 'package:flutter/material.dart'; 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/sudoku/sudoku.dart'; import 'package:sudoku/timer/timer.dart'; +import 'package:sudoku/typography/typography.dart'; /// {@template sudoku_board} /// Displays the Sudoku board in a [Stack] containing [blocks]. @@ -28,6 +30,15 @@ class SudokuBoard extends StatelessWidget { (TimerBloc bloc) => !bloc.state.isRunning, ); + const gradient = LinearGradient( + colors: [ + SudokuColors.darkPurple, + SudokuColors.darkPink, + ], + begin: Alignment.bottomLeft, + end: Alignment.topRight, + ); + return ResponsiveLayoutBuilder( small: (_, child) => SizedBox.square( key: const Key('sudoku_board_small'), @@ -78,12 +89,22 @@ class SudokuBoard extends StatelessWidget { ), if (isTimerPaused) Center( - child: FloatingActionButton.extended( - onPressed: () => context.read().add( - const TimerResumed(), - ), - label: Text(l10n.resumeTimerButtonText), - icon: const Icon(Icons.play_arrow), + child: DecoratedBox( + decoration: BoxDecoration( + gradient: gradient, + borderRadius: BorderRadius.circular(16), + ), + child: FloatingActionButton.extended( + backgroundColor: Colors.transparent, + onPressed: () => context.read().add( + const TimerResumed(), + ), + label: Text( + l10n.resumeTimerButtonText, + style: SudokuTextStyle.button, + ), + icon: const Icon(Icons.play_arrow), + ), ), ), ], diff --git a/lib/sudoku/widgets/sudoku_input.dart b/lib/sudoku/widgets/sudoku_input.dart index 9006b85..a45534f 100644 --- a/lib/sudoku/widgets/sudoku_input.dart +++ b/lib/sudoku/widgets/sudoku_input.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:sudoku/colors/colors.dart'; import 'package:sudoku/layout/layout.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; import 'package:sudoku/sudoku/sudoku.dart'; import 'package:sudoku/typography/typography.dart'; @@ -54,6 +56,7 @@ class SudokuInput extends StatelessWidget { sudokuDimension: sudokuDimension, inputsPerRow: (sudokuDimension / 3).ceil(), inputSize: SudokuInputSize.large, + showEraserInSameLine: false, ), ), ); @@ -65,46 +68,71 @@ class _SudokuInputView extends StatelessWidget { required this.sudokuDimension, required this.inputsPerRow, required this.inputSize, + this.showEraserInSameLine = true, }); final int sudokuDimension; final int inputsPerRow; final double inputSize; + final bool showEraserInSameLine; @override Widget build(BuildContext context) { final theme = Theme.of(context); + final keySize = switch (inputSize) { SudokuInputSize.small => 'small', SudokuInputSize.medium => 'medium', SudokuInputSize.large => 'large', _ => 'other', }; + + final elementsCount = + showEraserInSameLine ? sudokuDimension + 1 : sudokuDimension; + return Stack( children: [ - for (var i = 0; i < sudokuDimension; i++) + for (var i = 0; i < elementsCount; i++) Positioned( top: (i ~/ inputsPerRow) * inputSize, left: (i % inputsPerRow) * inputSize, child: GestureDetector( - onTap: () => context.read().add( - SudokuInputTapped(i + 1), - ), + onTap: () => showEraserInSameLine && i == elementsCount - 1 + ? context.read().add(const SudokuInputErased()) + : context.read().add(SudokuInputEntered(i + 1)), child: Container( key: Key('sudoku_input_${keySize}_block_${i + 1}'), alignment: Alignment.center, - height: inputSize, - width: inputSize, + margin: const EdgeInsets.all(8), + height: inputSize - 16, + width: inputSize - 16, decoration: BoxDecoration( border: Border.all( - color: theme.dividerColor, - width: 0.8, + color: showEraserInSameLine && i == elementsCount - 1 + ? Colors.transparent + : theme.dividerColor, + width: 1.4, ), + gradient: showEraserInSameLine && i == elementsCount - 1 + ? const LinearGradient( + colors: [ + SudokuColors.darkPurple, + SudokuColors.darkPink, + ], + begin: Alignment.bottomLeft, + end: Alignment.topRight, + ) + : null, + borderRadius: BorderRadius.circular(14), ), child: Text( - '${i + 1}', + showEraserInSameLine && i == elementsCount - 1 + ? 'X' + : '${i + 1}', style: SudokuTextStyle.headline6.copyWith( - color: theme.colorScheme.secondary, + color: showEraserInSameLine && i == elementsCount - 1 + ? Colors.white + : theme.colorScheme.secondary, ), ), ), diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart index 3cf9f26..371d798 100644 --- a/test/app/view/app_test.dart +++ b/test/app/view/app_test.dart @@ -2,19 +2,27 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sudoku/api/api.dart'; import 'package:sudoku/app/app.dart'; import 'package:sudoku/home/home.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; import '../../helpers/helpers.dart'; void main() { group('App', () { late SudokuAPI apiClient; + late PuzzleRepository puzzleRepository; setUp(() { apiClient = MockSudokuAPI(); + puzzleRepository = MockPuzzleRepository(); }); testWidgets('renders HomePage', (tester) async { - await tester.pumpWidget(App(apiClient: apiClient)); + await tester.pumpWidget( + App( + apiClient: apiClient, + puzzleRepository: puzzleRepository, + ), + ); expect(find.byType(HomePage), findsOneWidget); }); }); diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 0f1a6b6..a2b07f8 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -6,16 +6,10 @@ import 'package:sudoku/cache/cache.dart'; import 'package:sudoku/home/home.dart'; import 'package:sudoku/models/models.dart'; import 'package:sudoku/puzzle/puzzle.dart'; -import 'package:sudoku/sudoku/sudoku.dart'; import 'package:sudoku/timer/timer.dart'; class MockSudoku extends Mock implements Sudoku {} -class MockSudokuBloc extends MockBloc - implements SudokuBloc {} - -class MockSudokuState extends Mock implements SudokuState {} - class MockBlock extends Mock implements Block {} class MockPosition extends Mock implements Position {} @@ -36,3 +30,8 @@ class MockCacheClient extends Mock implements CacheClient {} class MockPuzzle extends Mock implements Puzzle {} class MockPuzzleRepository extends Mock implements PuzzleRepository {} + +class MockPuzzleBloc extends MockBloc + implements PuzzleBloc {} + +class MockPuzzleState extends Mock implements PuzzleState {} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index cc3f8c3..2ad2969 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sudoku/api/api.dart'; import 'package:sudoku/home/home.dart'; import 'package:sudoku/l10n/l10n.dart'; -import 'package:sudoku/sudoku/sudoku.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; import 'package:sudoku/timer/timer.dart'; import 'helpers.dart'; @@ -13,9 +13,10 @@ extension PumpApp on WidgetTester { Future pumpApp( Widget widget, { SudokuAPI? apiClient, + PuzzleRepository? puzzleRepository, HomeBloc? homeBloc, - SudokuBloc? sudokuBloc, TimerBloc? timerBloc, + PuzzleBloc? puzzleBloc, }) { return pumpWidget( MultiRepositoryProvider( @@ -23,18 +24,21 @@ extension PumpApp on WidgetTester { RepositoryProvider.value( value: apiClient ?? MockSudokuAPI(), ), + RepositoryProvider.value( + value: puzzleRepository ?? MockPuzzleRepository(), + ), ], child: MultiBlocProvider( providers: [ BlocProvider.value( value: homeBloc ?? MockHomeBloc(), ), - BlocProvider.value( - value: sudokuBloc ?? MockSudokuBloc(), - ), BlocProvider.value( value: timerBloc ?? MockTimerBloc(), ), + BlocProvider.value( + value: puzzleBloc ?? MockPuzzleBloc(), + ), ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, diff --git a/test/helpers/sudoku_helpers.dart b/test/helpers/sudoku_helpers.dart index 8c262a3..205734a 100644 --- a/test/helpers/sudoku_helpers.dart +++ b/test/helpers/sudoku_helpers.dart @@ -133,3 +133,436 @@ final sudoku2x2WithOneRemaining = sudoku2x2 .updateBlock(sudoku2x2Block12, 3) .updateBlock(sudoku2x2Block13, 2) .updateBlock(sudoku2x2Block15, 4); + +const sudoku3x3 = Sudoku( + blocks: [ + Block( + position: Position(x: 0, y: 0), + correctValue: 4, + currentValue: -1, + ), + Block( + position: Position(x: 0, y: 1), + correctValue: 7, + currentValue: -1, + ), + Block( + position: Position(x: 0, y: 2), + correctValue: 5, + currentValue: 5, + isGenerated: true, + ), + Block( + position: Position(x: 0, y: 3), + correctValue: 3, + currentValue: 3, + isGenerated: true, + ), + Block( + position: Position(x: 0, y: 4), + correctValue: 6, + currentValue: -1, + ), + Block( + position: Position(x: 0, y: 5), + correctValue: 9, + currentValue: -1, + ), + Block( + position: Position(x: 0, y: 6), + correctValue: 2, + currentValue: -1, + ), + Block( + position: Position(x: 0, y: 7), + correctValue: 1, + currentValue: -1, + ), + Block( + position: Position(x: 0, y: 8), + correctValue: 8, + currentValue: -1, + ), + Block( + position: Position(x: 1, y: 0), + correctValue: 8, + currentValue: -1, + ), + Block( + position: Position(x: 1, y: 1), + correctValue: 2, + currentValue: -1, + ), + Block( + position: Position(x: 1, y: 2), + correctValue: 9, + currentValue: -1, + ), + Block( + position: Position(x: 1, y: 3), + correctValue: 1, + currentValue: -1, + ), + Block( + position: Position(x: 1, y: 4), + correctValue: 7, + currentValue: -1, + ), + Block( + position: Position(x: 1, y: 5), + correctValue: 5, + currentValue: -1, + ), + Block( + position: Position(x: 1, y: 6), + correctValue: 3, + currentValue: -1, + ), + Block( + position: Position(x: 1, y: 7), + correctValue: 6, + currentValue: -1, + ), + Block( + position: Position(x: 1, y: 8), + correctValue: 6, + currentValue: 6, + isGenerated: true, + ), + Block( + position: Position(x: 2, y: 0), + correctValue: 3, + currentValue: -1, + ), + Block( + position: Position(x: 2, y: 1), + correctValue: 8, + currentValue: 8, + isGenerated: true, + ), + Block( + position: Position(x: 2, y: 2), + correctValue: 6, + currentValue: -1, + ), + Block( + position: Position(x: 2, y: 3), + correctValue: 7, + currentValue: -1, + ), + Block( + position: Position(x: 2, y: 4), + correctValue: 2, + currentValue: -1, + ), + Block( + position: Position(x: 2, y: 5), + correctValue: 1, + currentValue: 1, + isGenerated: true, + ), + Block( + position: Position(x: 2, y: 6), + correctValue: 5, + currentValue: -1, + ), + Block( + position: Position(x: 2, y: 7), + correctValue: 4, + currentValue: 4, + isGenerated: true, + ), + Block( + position: Position(x: 2, y: 8), + correctValue: 9, + currentValue: -1, + ), + Block( + position: Position(x: 3, y: 0), + correctValue: 1, + currentValue: 1, + isGenerated: true, + ), + Block( + position: Position(x: 3, y: 1), + correctValue: 5, + currentValue: -1, + ), + Block( + position: Position(x: 3, y: 2), + correctValue: 7, + currentValue: -1, + ), + Block( + position: Position(x: 3, y: 3), + correctValue: 2, + currentValue: 2, + isGenerated: true, + ), + Block( + position: Position(x: 3, y: 4), + correctValue: 8, + currentValue: -1, + ), + Block( + position: Position(x: 3, y: 5), + correctValue: 3, + currentValue: 3, + isGenerated: true, + ), + Block( + position: Position(x: 3, y: 6), + correctValue: 4, + currentValue: -1, + ), + Block( + position: Position(x: 3, y: 7), + correctValue: 9, + currentValue: -1, + ), + Block( + position: Position(x: 3, y: 8), + correctValue: 6, + currentValue: -1, + ), + Block( + position: Position(x: 4, y: 0), + correctValue: 9, + currentValue: 9, + isGenerated: true, + ), + Block( + position: Position(x: 4, y: 1), + correctValue: 6, + currentValue: -1, + ), + Block( + position: Position(x: 4, y: 2), + correctValue: 2, + currentValue: -1, + ), + Block( + position: Position(x: 4, y: 3), + correctValue: 5, + currentValue: -1, + ), + Block( + position: Position(x: 4, y: 4), + correctValue: 4, + currentValue: -1, + ), + Block( + position: Position(x: 4, y: 5), + correctValue: 7, + currentValue: -1, + ), + Block( + position: Position(x: 4, y: 6), + correctValue: 1, + currentValue: -1, + ), + Block( + position: Position(x: 4, y: 7), + correctValue: 8, + currentValue: 8, + isGenerated: true, + ), + Block( + position: Position(x: 4, y: 8), + correctValue: 3, + currentValue: -1, + ), + Block( + position: Position(x: 5, y: 0), + correctValue: 3, + currentValue: 3, + isGenerated: true, + ), + Block( + position: Position(x: 5, y: 1), + correctValue: 4, + currentValue: -1, + ), + Block( + position: Position(x: 5, y: 2), + correctValue: 1, + currentValue: -1, + ), + Block( + position: Position(x: 5, y: 3), + correctValue: 8, + currentValue: -1, + ), + Block( + position: Position(x: 5, y: 4), + correctValue: 9, + currentValue: -1, + ), + Block( + position: Position(x: 5, y: 5), + correctValue: 6, + currentValue: -1, + ), + Block( + position: Position(x: 5, y: 6), + correctValue: 7, + currentValue: -1, + ), + Block( + position: Position(x: 5, y: 7), + correctValue: 2, + currentValue: 2, + isGenerated: true, + ), + Block( + position: Position(x: 5, y: 8), + correctValue: 5, + currentValue: 5, + isGenerated: true, + ), + Block( + position: Position(x: 6, y: 0), + correctValue: 7, + currentValue: 7, + isGenerated: true, + ), + Block( + position: Position(x: 6, y: 1), + correctValue: 1, + currentValue: -1, + ), + Block( + position: Position(x: 6, y: 2), + correctValue: 8, + currentValue: -1, + ), + Block( + position: Position(x: 6, y: 3), + correctValue: 4, + currentValue: 4, + isGenerated: true, + ), + Block( + position: Position(x: 6, y: 4), + correctValue: 6, + currentValue: -1, + ), + Block( + position: Position(x: 6, y: 5), + correctValue: 2, + currentValue: -1, + ), + Block( + position: Position(x: 6, y: 6), + correctValue: 5, + currentValue: -1, + ), + Block( + position: Position(x: 6, y: 7), + correctValue: 3, + currentValue: -1, + ), + Block( + position: Position(x: 6, y: 8), + correctValue: 3, + currentValue: 3, + isGenerated: true, + ), + Block( + position: Position(x: 7, y: 0), + correctValue: 2, + currentValue: 2, + isGenerated: true, + ), + Block( + position: Position(x: 7, y: 1), + correctValue: 9, + currentValue: -1, + ), + Block( + position: Position(x: 7, y: 2), + correctValue: 4, + currentValue: -1, + ), + Block( + position: Position(x: 7, y: 3), + correctValue: 6, + currentValue: -1, + ), + Block( + position: Position(x: 7, y: 4), + correctValue: 5, + currentValue: 5, + isGenerated: true, + ), + Block( + position: Position(x: 7, y: 5), + correctValue: 8, + currentValue: -1, + ), + Block( + position: Position(x: 7, y: 6), + correctValue: 3, + currentValue: -1, + ), + Block( + position: Position(x: 7, y: 7), + correctValue: 1, + currentValue: 1, + isGenerated: true, + ), + Block( + position: Position(x: 7, y: 8), + correctValue: 7, + currentValue: -1, + ), + Block( + position: Position(x: 8, y: 0), + correctValue: 6, + currentValue: 6, + isGenerated: true, + ), + Block( + position: Position(x: 8, y: 1), + correctValue: 3, + currentValue: -1, + ), + Block( + position: Position(x: 8, y: 2), + correctValue: 3, + currentValue: 3, + isGenerated: true, + ), + Block( + position: Position(x: 8, y: 3), + correctValue: 9, + currentValue: -1, + ), + Block( + position: Position(x: 8, y: 4), + correctValue: 1, + currentValue: -1, + ), + Block( + position: Position(x: 8, y: 5), + correctValue: 4, + currentValue: -1, + ), + Block( + position: Position(x: 8, y: 6), + correctValue: 9, + currentValue: 9, + isGenerated: true, + ), + Block( + position: Position(x: 8, y: 7), + correctValue: 7, + currentValue: -1, + ), + Block( + position: Position(x: 8, y: 8), + correctValue: 2, + currentValue: -1, + ), + ], +); diff --git a/test/home/bloc/home_bloc_test.dart b/test/home/bloc/home_bloc_test.dart index 70e20bf..7f5abb4 100644 --- a/test/home/bloc/home_bloc_test.dart +++ b/test/home/bloc/home_bloc_test.dart @@ -6,12 +6,14 @@ import 'package:mocktail/mocktail.dart'; import 'package:sudoku/api/api.dart'; import 'package:sudoku/home/home.dart'; import 'package:sudoku/models/models.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; import '../../helpers/helpers.dart'; void main() { group('HomeBloc', () { late SudokuAPI apiClient; + late PuzzleRepository puzzleRepository; const sudoku = Sudoku( blocks: [ @@ -25,6 +27,7 @@ void main() { setUp(() { apiClient = MockSudokuAPI(); + puzzleRepository = MockPuzzleRepository(); when(() => apiClient.createSudoku(difficulty: any(named: 'difficulty'))) .thenAnswer((_) => Future.value(sudoku)); }); @@ -34,7 +37,10 @@ void main() { }); HomeBloc buildBloc() { - return HomeBloc(apiClient: apiClient); + return HomeBloc( + apiClient: apiClient, + puzzleRepository: puzzleRepository, + ); } test('constructor works correctly', () { @@ -43,26 +49,31 @@ void main() { blocTest( 'emits state with in progress and completed [SudokuCreationStatus] ' - 'along with created sudoku with defined difficulty', + 'along with defined difficulty', build: buildBloc, act: (bloc) => bloc.add(SudokuCreationRequested(Difficulty.medium)), expect: () => [ HomeState( - sudoku: null, difficulty: Difficulty.medium, sudokuCreationStatus: SudokuCreationStatus.inProgress, sudokuCreationError: null, ), HomeState( - sudoku: sudoku, difficulty: Difficulty.medium, sudokuCreationStatus: SudokuCreationStatus.completed, sudokuCreationError: null, ), ], - verify: (_) => verify( - () => apiClient.createSudoku(difficulty: Difficulty.medium), - ).called(1), + verify: (_) { + verify( + () => apiClient.createSudoku(difficulty: Difficulty.medium), + ).called(1); + verify( + () => puzzleRepository.storePuzzle( + puzzle: Puzzle(sudoku: sudoku, difficulty: Difficulty.medium), + ), + ).called(1); + }, ); blocTest( @@ -75,13 +86,11 @@ void main() { act: (bloc) => bloc.add(SudokuCreationRequested(Difficulty.medium)), expect: () => [ HomeState( - sudoku: null, difficulty: Difficulty.medium, sudokuCreationStatus: SudokuCreationStatus.inProgress, sudokuCreationError: null, ), HomeState( - sudoku: null, difficulty: Difficulty.medium, sudokuCreationStatus: SudokuCreationStatus.failed, sudokuCreationError: SudokuCreationErrorType.apiClient, @@ -99,13 +108,11 @@ void main() { act: (bloc) => bloc.add(SudokuCreationRequested(Difficulty.medium)), expect: () => [ HomeState( - sudoku: null, difficulty: Difficulty.medium, sudokuCreationStatus: SudokuCreationStatus.inProgress, sudokuCreationError: null, ), HomeState( - sudoku: null, difficulty: Difficulty.medium, sudokuCreationStatus: SudokuCreationStatus.failed, sudokuCreationError: SudokuCreationErrorType.invalidRawData, @@ -123,13 +130,11 @@ void main() { act: (bloc) => bloc.add(SudokuCreationRequested(Difficulty.medium)), expect: () => [ HomeState( - sudoku: null, difficulty: Difficulty.medium, sudokuCreationStatus: SudokuCreationStatus.inProgress, sudokuCreationError: null, ), HomeState( - sudoku: null, difficulty: Difficulty.medium, sudokuCreationStatus: SudokuCreationStatus.failed, sudokuCreationError: SudokuCreationErrorType.unexpected, diff --git a/test/home/bloc/home_state_test.dart b/test/home/bloc/home_state_test.dart index a30b435..85d8f9f 100644 --- a/test/home/bloc/home_state_test.dart +++ b/test/home/bloc/home_state_test.dart @@ -7,13 +7,11 @@ import 'package:sudoku/models/models.dart'; void main() { group('HomeState', () { HomeState createSubject({ - Sudoku? sudoku, Difficulty? difficulty, SudokuCreationStatus? sudokuCreationStatus, SudokuCreationErrorType? sudokuCreationError, }) { return HomeState( - sudoku: sudoku, difficulty: difficulty, sudokuCreationStatus: sudokuCreationStatus ?? SudokuCreationStatus.initial, @@ -30,7 +28,6 @@ void main() { createSubject().props, equals( [ - null, null, SudokuCreationStatus.initial, null, @@ -47,7 +44,6 @@ void main() { test('returns the old value for each parameter if null is provided', () { expect( createSubject().copyWith( - sudoku: null, difficulty: null, sudokuCreationStatus: null, sudokuCreationError: null, @@ -59,14 +55,12 @@ void main() { test('returns the updated copy of this for every non-null parameter', () { expect( createSubject().copyWith( - sudoku: () => Sudoku(blocks: const []), difficulty: () => Difficulty.expert, sudokuCreationStatus: () => SudokuCreationStatus.inProgress, sudokuCreationError: () => SudokuCreationErrorType.unexpected, ), equals( createSubject( - sudoku: Sudoku(blocks: const []), difficulty: Difficulty.expert, sudokuCreationStatus: SudokuCreationStatus.inProgress, sudokuCreationError: SudokuCreationErrorType.unexpected, @@ -79,13 +73,11 @@ void main() { test('can copyWith null parameters', () { expect( createSubject().copyWith( - sudoku: () => null, difficulty: () => null, sudokuCreationError: () => null, ), equals( createSubject( - sudoku: null, difficulty: null, sudokuCreationError: null, ), diff --git a/test/home/home_page_test.dart b/test/home/view/home_page_test.dart similarity index 94% rename from test/home/home_page_test.dart rename to test/home/view/home_page_test.dart index 8ee5a79..860c504 100644 --- a/test/home/home_page_test.dart +++ b/test/home/view/home_page_test.dart @@ -6,10 +6,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:sudoku/home/home.dart'; import 'package:sudoku/models/models.dart'; -import 'package:sudoku/sudoku/sudoku.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; import 'package:sudoku/widgets/widgets.dart'; -import '../helpers/helpers.dart'; +import '../../helpers/helpers.dart'; void main() { group('HomePage', () { @@ -120,8 +120,16 @@ void main() { ); testWidgets( - 'routes to [SudokuPage] when [SudokuCreationStatus] is completed', + 'routes to [PuzzlePage] when [SudokuCreationStatus] is completed', (tester) async { + final puzzle = MockPuzzle(); + when(() => puzzle.sudoku).thenReturn(sudoku3x3); + when(() => puzzle.difficulty).thenReturn(Difficulty.medium); + when(() => puzzle.remainingMistakes).thenReturn(3); + + final puzzleRepository = MockPuzzleRepository(); + when(puzzleRepository.getPuzzle).thenReturn(puzzle); + whenListen( homeBloc, Stream.fromIterable( @@ -131,7 +139,6 @@ void main() { sudokuCreationStatus: SudokuCreationStatus.inProgress, ), HomeState( - sudoku: Sudoku(blocks: const []), sudokuCreationStatus: SudokuCreationStatus.completed, ), ], @@ -139,10 +146,14 @@ void main() { initialState: HomeState(), ); - await tester.pumpApp(HomeView(), homeBloc: homeBloc); + await tester.pumpApp( + HomeView(), + homeBloc: homeBloc, + puzzleRepository: puzzleRepository, + ); await tester.pumpAndSettle(); - expect(find.byType(SudokuPage), findsOneWidget); + expect(find.byType(PuzzlePage), findsOneWidget); }, ); diff --git a/test/puzzle/view/puzzle_page_test.dart b/test/puzzle/view/puzzle_page_test.dart new file mode 100644 index 0000000..9c0a577 --- /dev/null +++ b/test/puzzle/view/puzzle_page_test.dart @@ -0,0 +1,314 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:sudoku/models/models.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; +import 'package:sudoku/sudoku/sudoku.dart'; +import 'package:sudoku/timer/timer.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('PuzzlePage', () { + late Puzzle puzzle; + late PuzzleRepository puzzleRepository; + + setUp(() { + puzzle = MockPuzzle(); + puzzleRepository = MockPuzzleRepository(); + + when(() => puzzle.sudoku).thenReturn(sudoku3x3); + when(() => puzzle.difficulty).thenReturn(Difficulty.medium); + + when(() => puzzleRepository.getPuzzle()).thenReturn(puzzle); + }); + + testWidgets('renders PuzzleView on a large display', (tester) async { + tester.setLargeDisplaySize(); + + await tester.pumpApp( + PuzzlePage(), + puzzleRepository: puzzleRepository, + ); + expect(find.byType(PuzzleView), findsOneWidget); + }); + + testWidgets('renders PuzzleView on a medium display', (tester) async { + tester.setMediumDisplaySize(); + + await tester.pumpApp( + PuzzlePage(), + puzzleRepository: puzzleRepository, + ); + expect(find.byType(PuzzleView), findsOneWidget); + }); + + testWidgets('renders PuzzleView on a small display', (tester) async { + tester.setSmallDisplaySize(); + + await tester.pumpApp( + PuzzlePage(), + puzzleRepository: puzzleRepository, + ); + expect(find.byType(PuzzleView), findsOneWidget); + }); + + group('PageHeader', () { + late Puzzle puzzle; + late PuzzleBloc puzzleBloc; + late PuzzleState puzzleState; + + setUp(() { + puzzle = MockPuzzle(); + puzzleBloc = MockPuzzleBloc(); + puzzleState = MockPuzzleState(); + + when(() => puzzle.difficulty).thenReturn(Difficulty.medium); + when(() => puzzleState.puzzle).thenReturn(puzzle); + when(() => puzzleBloc.state).thenReturn(puzzleState); + }); + + 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); + }); + + testWidgets('renders difficulty on a small layout', (tester) async { + tester.setSmallDisplaySize(); + await tester.pumpApp(const PageHeader(), puzzleBloc: puzzleBloc); + expect(find.text('Medium'), findsOneWidget); + }); + }); + + group('PuzzleViewLayout', () { + late Sudoku sudoku; + late Puzzle puzzle; + late PuzzleState puzzleState; + late PuzzleBloc puzzleBloc; + late TimerBloc timerBloc; + + setUp(() { + sudoku = MockSudoku(); + puzzle = MockPuzzle(); + puzzleState = MockPuzzleState(); + + puzzleBloc = MockPuzzleBloc(); + timerBloc = MockTimerBloc(); + + when(() => sudoku.blocks).thenReturn([]); + when(() => sudoku.getDimesion()).thenReturn(3); + + when(() => puzzle.sudoku).thenReturn(sudoku); + when(() => puzzle.difficulty).thenReturn(Difficulty.medium); + when(() => puzzle.remainingMistakes).thenReturn(3); + + when(() => puzzleState.puzzle).thenReturn(puzzle); + when(() => puzzleBloc.state).thenReturn(puzzleState); + + 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); + }, + ); + + 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); + }, + ); + + 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); + }, + ); + }); + + 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(), + 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); + }, + ); + }); + }); +} diff --git a/test/sudoku/bloc/sudoku_bloc_test.dart b/test/sudoku/bloc/sudoku_bloc_test.dart deleted file mode 100644 index d6c30de..0000000 --- a/test/sudoku/bloc/sudoku_bloc_test.dart +++ /dev/null @@ -1,261 +0,0 @@ -// ignore_for_file: prefer_const_constructors, avoid_redundant_argument_values - -import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:sudoku/models/models.dart'; -import 'package:sudoku/sudoku/sudoku.dart'; - -void main() { - const sudoku2x2Block0 = Block( - position: Position(x: 0, y: 0), - correctValue: 4, - currentValue: -1, - ); - - const sudoku2x2Block1 = Block( - position: Position(x: 0, y: 1), - correctValue: 1, - currentValue: 1, - isGenerated: true, - ); - - const sudoku2x2Block2 = Block( - position: Position(x: 0, y: 2), - correctValue: 2, - currentValue: -1, - ); - - const sudoku2x2Block3 = Block( - position: Position(x: 0, y: 3), - correctValue: 3, - currentValue: -1, - ); - - const sudoku2x2Block4 = Block( - position: Position(x: 1, y: 0), - correctValue: 2, - currentValue: 2, - isGenerated: true, - ); - - const sudoku2x2Block5 = Block( - position: Position(x: 1, y: 1), - correctValue: 3, - currentValue: 3, - isGenerated: true, - ); - - const sudoku2x2Block6 = Block( - position: Position(x: 1, y: 2), - correctValue: 4, - currentValue: -1, - ); - - const sudoku2x2Block7 = Block( - position: Position(x: 1, y: 3), - correctValue: 1, - currentValue: -1, - ); - - const sudoku2x2Block8 = Block( - position: Position(x: 2, y: 0), - correctValue: 1, - currentValue: -1, - ); - - const sudoku2x2Block9 = Block( - position: Position(x: 2, y: 1), - correctValue: 4, - currentValue: -1, - ); - - const sudoku2x2Block10 = Block( - position: Position(x: 2, y: 2), - correctValue: 3, - currentValue: 3, - isGenerated: true, - ); - - const sudoku2x2Block11 = Block( - position: Position(x: 2, y: 3), - correctValue: 2, - currentValue: 2, - isGenerated: true, - ); - - const sudoku2x2Block12 = Block( - position: Position(x: 3, y: 0), - correctValue: 3, - currentValue: -1, - ); - - const sudoku2x2Block13 = Block( - position: Position(x: 3, y: 1), - correctValue: 2, - currentValue: -1, - ); - - const sudoku2x2Block14 = Block( - position: Position(x: 3, y: 2), - correctValue: 1, - currentValue: 1, - isGenerated: true, - ); - - const sudoku2x2Block15 = Block( - position: Position(x: 3, y: 3), - correctValue: 4, - currentValue: -1, - ); - - const sudoku = Sudoku( - blocks: [ - sudoku2x2Block0, - sudoku2x2Block1, - sudoku2x2Block2, - sudoku2x2Block3, - sudoku2x2Block4, - sudoku2x2Block5, - sudoku2x2Block6, - sudoku2x2Block7, - sudoku2x2Block8, - sudoku2x2Block9, - sudoku2x2Block10, - sudoku2x2Block11, - sudoku2x2Block12, - sudoku2x2Block13, - sudoku2x2Block14, - sudoku2x2Block15, - ], - ); - - const lastLeftSudokuGenerated = [ - [-1, 1, 2, 3], - [2, 3, 4, 1], - [1, 4, 3, 2], - [3, 2, 1, 4], - ]; - - const lastLeftSudokuAnswer = [ - [4, 1, 2, 3], - [2, 3, 4, 1], - [1, 4, 3, 2], - [3, 2, 1, 4], - ]; - - final lastLeftSudoku = Sudoku.fromRawData( - lastLeftSudokuGenerated, - lastLeftSudokuAnswer, - ); - - group('SudokuBlock', () { - SudokuBloc buildBloc() { - return SudokuBloc(sudoku: sudoku); - } - - group('constructor', () { - test('works correctly', () { - expect(buildBloc, returnsNormally); - }); - - test('has an initial state', () { - expect( - buildBloc().state, - equals(SudokuState(sudoku: sudoku)), - ); - }); - }); - - group('SudokuBlockSelected', () { - blocTest( - 'emits state with [cannotBeSelected] [blockSelectionStatus], ' - 'and null [selectedBlock] when block [isGenerated] is true', - build: buildBloc, - act: (bloc) => bloc.add(SudokuBlockSelected(sudoku2x2Block1)), - expect: () => [ - SudokuState( - sudoku: sudoku, - blockSelectionStatus: BlockSelectionStatus.cannotBeSelected, - highlightedBlocks: const {}, - currentSelectedBlock: null, - ), - ], - ); - - blocTest( - 'emits state with selected [blockSelectionStatus], ' - 'and correct [selectedBlock] when block [isGenerated] is false', - build: buildBloc, - act: (bloc) => bloc.add(SudokuBlockSelected(sudoku2x2Block0)), - expect: () => [ - SudokuState( - sudoku: sudoku, - blockSelectionStatus: BlockSelectionStatus.selected, - highlightedBlocks: { - sudoku2x2Block0, - sudoku2x2Block1, - sudoku2x2Block2, - sudoku2x2Block3, - sudoku2x2Block4, - sudoku2x2Block5, - sudoku2x2Block8, - sudoku2x2Block12, - }, - currentSelectedBlock: sudoku2x2Block0, - ), - ], - ); - }); - - group('SudokuInputTapped', () { - final newSudoku = sudoku.updateBlock(sudoku2x2Block0, 7); - final newLastLeftSudoku = lastLeftSudoku.updateBlock(sudoku2x2Block0, 4); - - blocTest( - 'does not emit state if state [currentSelectedBlock] is emoty', - build: buildBloc, - seed: () => SudokuState(sudoku: sudoku, currentSelectedBlock: null), - act: (bloc) => bloc.add(SudokuInputTapped(7)), - expect: () => [], - ); - - blocTest( - 'emits updated sudoku when [isComplete] is false', - build: buildBloc, - seed: () => SudokuState( - sudoku: sudoku, - blockSelectionStatus: BlockSelectionStatus.selected, - currentSelectedBlock: sudoku2x2Block0, - ), - act: (bloc) => bloc.add(SudokuInputTapped(7)), - expect: () => [ - SudokuState( - sudoku: newSudoku, - blockSelectionStatus: BlockSelectionStatus.selected, - currentSelectedBlock: sudoku2x2Block0, - ), - ], - ); - - blocTest( - 'emits updated sudoku, and puzzle status when [isComplete] ' - 'returns true', - build: buildBloc, - seed: () => SudokuState( - sudoku: lastLeftSudoku, - blockSelectionStatus: BlockSelectionStatus.selected, - currentSelectedBlock: sudoku2x2Block0, - ), - act: (bloc) => bloc.add(SudokuInputTapped(4)), - expect: () => [ - SudokuState( - sudoku: newLastLeftSudoku, - puzzleStatus: SudokuPuzzleStatus.complete, - blockSelectionStatus: BlockSelectionStatus.selected, - currentSelectedBlock: sudoku2x2Block0, - ), - ], - ); - }); - }); -} diff --git a/test/sudoku/bloc/sudoku_event_test.dart b/test/sudoku/bloc/sudoku_event_test.dart deleted file mode 100644 index 486b619..0000000 --- a/test/sudoku/bloc/sudoku_event_test.dart +++ /dev/null @@ -1,47 +0,0 @@ -// ignore_for_file: prefer_const_constructors - -import 'package:flutter_test/flutter_test.dart'; -import 'package:sudoku/models/models.dart'; -import 'package:sudoku/sudoku/sudoku.dart'; - -void main() { - const mockBlock = Block( - position: Position(x: 0, y: 2), - correctValue: 3, - currentValue: -1, - ); - - group('SudokuEvent', () { - group('SudokuBlockSelected', () { - test('supports value equality', () { - expect( - SudokuBlockSelected(mockBlock), - equals(SudokuBlockSelected(mockBlock)), - ); - }); - - test('props are correct', () { - expect( - SudokuBlockSelected(mockBlock).props, - equals([mockBlock]), - ); - }); - }); - - group('SudokuInputTapped', () { - test('supports value equality', () { - expect( - SudokuInputTapped(2), - equals(SudokuInputTapped(2)), - ); - }); - - test('props are correct', () { - expect( - SudokuInputTapped(5).props, - equals([5]), - ); - }); - }); - }); -} diff --git a/test/sudoku/bloc/sudoku_state_test.dart b/test/sudoku/bloc/sudoku_state_test.dart deleted file mode 100644 index f4c74fb..0000000 --- a/test/sudoku/bloc/sudoku_state_test.dart +++ /dev/null @@ -1,118 +0,0 @@ -// ignore_for_file: avoid_redundant_argument_values - -import 'package:flutter_test/flutter_test.dart'; -import 'package:sudoku/models/models.dart'; -import 'package:sudoku/sudoku/sudoku.dart'; - -void main() { - const rawData = [ - [4, 1, 2, 3], - [2, 3, 4, 1], - [1, 4, 3, 2], - [3, 2, 1, 4], - ]; - - const dummyBlock1 = Block( - position: Position(x: 1, y: 2), - correctValue: 1, - currentValue: 1, - ); - - const dummyBlock2 = Block( - position: Position(x: 2, y: 1), - correctValue: 7, - currentValue: 7, - ); - - group('SudokuState', () { - SudokuState createSubject({ - Sudoku? sudoku, - SudokuPuzzleStatus? puzzleStatus, - BlockSelectionStatus? blockSelectionStatus, - Set? highlightedBlocks, - Block? currentSelectedBlock, - }) { - return SudokuState( - sudoku: sudoku ?? const Sudoku(blocks: []), - puzzleStatus: puzzleStatus ?? SudokuPuzzleStatus.incomplete, - blockSelectionStatus: - blockSelectionStatus ?? BlockSelectionStatus.nothingSelected, - highlightedBlocks: highlightedBlocks ?? {}, - currentSelectedBlock: currentSelectedBlock, - ); - } - - test('constructor works correctly', () { - expect(createSubject, returnsNormally); - }); - - test('supports value equality', () { - expect(createSubject(), equals(createSubject())); - }); - - test('props are correct', () { - expect( - createSubject().props, - equals([ - const Sudoku(blocks: []), // sudoku - SudokuPuzzleStatus.incomplete, // puzzleStatus - BlockSelectionStatus.nothingSelected, // blockSelectionStatus - {}, //highlightedBlocks - null, // currentSelectedBlock - ]), - ); - }); - - group('copyWith', () { - test('returns same object if no argument is passed', () { - expect( - createSubject().copyWith(), - equals(createSubject()), - ); - }); - - test('returns the old value for each parameter if null is provided', () { - expect( - createSubject().copyWith( - sudoku: null, - puzzleStatus: null, - blockSelectionStatus: null, - highlightedBlocks: null, - currentSelectedBlock: null, - ), - equals(createSubject()), - ); - }); - - test('returns the updated copy of this for every non-null parameter', () { - expect( - createSubject().copyWith( - sudoku: () => Sudoku.fromRawData(rawData, rawData), - puzzleStatus: () => SudokuPuzzleStatus.complete, - blockSelectionStatus: () => BlockSelectionStatus.selected, - highlightedBlocks: () => {dummyBlock1, dummyBlock2}, - currentSelectedBlock: () => dummyBlock1, - ), - equals( - createSubject( - sudoku: Sudoku.fromRawData(rawData, rawData), - puzzleStatus: SudokuPuzzleStatus.complete, - blockSelectionStatus: BlockSelectionStatus.selected, - highlightedBlocks: {dummyBlock1, dummyBlock2}, - currentSelectedBlock: dummyBlock1, - ), - ), - ); - }); - }); - - test('can copyWith null currentSelectedBlock', () { - expect( - createSubject().copyWith( - currentSelectedBlock: () => null, - ), - equals(createSubject(currentSelectedBlock: null)), - ); - }); - }); -} diff --git a/test/sudoku/view/sudoku_page_test.dart b/test/sudoku/view/sudoku_page_test.dart deleted file mode 100644 index 88eb226..0000000 --- a/test/sudoku/view/sudoku_page_test.dart +++ /dev/null @@ -1,141 +0,0 @@ -// ignore_for_file: prefer_const_constructors - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:sudoku/layout/responsive_layout_builder.dart'; -import 'package:sudoku/models/models.dart'; -import 'package:sudoku/sudoku/sudoku.dart'; -import 'package:sudoku/timer/timer.dart'; - -import '../../helpers/helpers.dart'; - -void main() { - group('SudokuPage', () { - late Sudoku sudoku; - - const block = Block( - position: Position(x: 0, y: 0), - correctValue: 7, - currentValue: -1, - ); - - setUp(() { - sudoku = MockSudoku(); - when(() => sudoku.blocks).thenReturn([block]); - when(() => sudoku.getDimesion()).thenReturn(3); - }); - - testWidgets('renders SudokuView', (tester) async { - await tester.pumpApp(SudokuPage(sudoku: sudoku)); - expect(find.byType(SudokuView), findsOneWidget); - }); - }); - - group('SudokuView', () { - late SudokuBloc sudokuBloc; - late TimerBloc timerBloc; - late Sudoku sudoku; - - setUp(() { - sudokuBloc = MockSudokuBloc(); - timerBloc = MockTimerBloc(); - sudoku = MockSudoku(); - - when(() => sudoku.getDimesion()).thenReturn(3); - when(() => sudoku.blocks).thenReturn([]); - when(() => sudokuBloc.state).thenReturn( - SudokuState(sudoku: sudoku), - ); - - when(() => timerBloc.state).thenReturn(TimerState()); - }); - - testWidgets('renders appbar in small layout', (tester) async { - tester.setSmallDisplaySize(); - await tester.pumpApp( - const SudokuView(), - sudokuBloc: sudokuBloc, - timerBloc: timerBloc, - ); - expect(find.byType(AppBar), findsOneWidget); - }); - - testWidgets('renders appbar in medium layout', (tester) async { - tester.setMediumDisplaySize(); - await tester.pumpApp( - const SudokuView(), - sudokuBloc: sudokuBloc, - timerBloc: timerBloc, - ); - expect(find.byType(AppBar), findsOneWidget); - }); - - testWidgets('does not render appbar in large layout', (tester) async { - tester.setLargeDisplaySize(); - await tester.pumpApp( - const SudokuView(), - sudokuBloc: sudokuBloc, - timerBloc: timerBloc, - ); - expect(find.byType(AppBar), findsNothing); - }); - }); - - group('SudokuBoardView', () { - late SudokuBloc sudokuBloc; - late TimerBloc timerBloc; - late Sudoku sudoku; - - setUp(() { - sudokuBloc = MockSudokuBloc(); - timerBloc = MockTimerBloc(); - sudoku = MockSudoku(); - - when(() => sudoku.getDimesion()).thenReturn(3); - when(() => sudoku.blocks).thenReturn([]); - when(() => sudokuBloc.state).thenReturn( - SudokuState(sudoku: sudoku), - ); - - when(() => timerBloc.state).thenReturn( - TimerState(secondsElapsed: 1, isRunning: true), - ); - }); - - testWidgets( - 're-renders only when sudoku from [SudokuState] 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(() => sudokuBloc.stream).thenAnswer( - (_) => Stream.fromIterable([ - SudokuState(sudoku: Sudoku(blocks: const [])), - SudokuState(sudoku: Sudoku(blocks: [block1, block2])), - ]), - ); - - await tester.pumpApp( - const SudokuBoardView( - layoutSize: ResponsiveLayoutSize.large, - ), - sudokuBloc: sudokuBloc, - 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); - }, - ); - }); -} diff --git a/test/sudoku/widgets/sudoku_block_test.dart b/test/sudoku/widgets/sudoku_block_test.dart index 7da9f81..ee6c956 100644 --- a/test/sudoku/widgets/sudoku_block_test.dart +++ b/test/sudoku/widgets/sudoku_block_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:sudoku/models/models.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; import 'package:sudoku/sudoku/sudoku.dart'; import '../../helpers/helpers.dart'; @@ -20,20 +21,24 @@ void main() { const mediumBlockKey = 'sudoku_block_medium_0_0'; const largeBlockKey = 'sudoku_block_large_0_0'; + late Puzzle puzzle; late Sudoku sudoku; - late SudokuBloc bloc; - late SudokuState state; + late PuzzleBloc bloc; + late PuzzleState state; setUp(() { sudoku = MockSudoku(); when(() => sudoku.getDimesion()).thenReturn(3); - state = MockSudokuState(); - when(() => state.sudoku).thenReturn(sudoku); - when(() => state.highlightedBlocks).thenReturn({}); - when(() => state.currentSelectedBlock).thenReturn(block); + puzzle = MockPuzzle(); + when(() => puzzle.sudoku).thenReturn(sudoku); - bloc = MockSudokuBloc(); + state = MockPuzzleState(); + when(() => state.puzzle).thenReturn(puzzle); + when(() => state.selectedBlock).thenReturn(block); + when(() => state.highlightedBlocks).thenReturn([]); + + bloc = MockPuzzleBloc(); when(() => bloc.state).thenReturn(state); }); @@ -42,7 +47,7 @@ void main() { (tester) async { await tester.pumpApp( SudokuBlock(block: block, state: state), - sudokuBloc: bloc, + puzzleBloc: bloc, ); await tester.tap(find.byType(GestureDetector)); @@ -57,7 +62,7 @@ void main() { await tester.pumpApp( SudokuBlock(block: block, state: state), - sudokuBloc: bloc, + puzzleBloc: bloc, ); expect(find.byKey(Key(largeBlockKey)), findsOneWidget); @@ -68,7 +73,7 @@ void main() { await tester.pumpApp( SudokuBlock(block: block, state: state), - sudokuBloc: bloc, + puzzleBloc: bloc, ); expect(find.byKey(Key(mediumBlockKey)), findsOneWidget); @@ -79,7 +84,7 @@ void main() { await tester.pumpApp( SudokuBlock(block: block, state: state), - sudokuBloc: bloc, + puzzleBloc: bloc, ); expect(find.byKey(Key(smallBlockKey)), findsOneWidget); @@ -91,15 +96,15 @@ void main() { final otherBlock = MockBlock(); when(() => otherBlock.position).thenReturn(Position(x: 0, y: 1)); - when(() => state.highlightedBlocks).thenReturn({block}); - when(() => state.currentSelectedBlock).thenReturn(otherBlock); + when(() => state.highlightedBlocks).thenReturn([block]); + when(() => state.selectedBlock).thenReturn(otherBlock); await tester.pumpApp( SudokuBlock( block: block, state: state, ), - sudokuBloc: bloc, + puzzleBloc: bloc, ); expect( diff --git a/test/sudoku/widgets/sudoku_input_test.dart b/test/sudoku/widgets/sudoku_input_test.dart index 6c5e486..ad07817 100644 --- a/test/sudoku/widgets/sudoku_input_test.dart +++ b/test/sudoku/widgets/sudoku_input_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 '../../helpers/helpers.dart'; @@ -13,24 +14,39 @@ void main() { const mediumInputKey = 'sudoku_input_medium'; const smallInputKey = 'sudoku_input_small'; - late SudokuBloc sudokuBloc; + late PuzzleBloc puzzleBloc; setUp(() { - sudokuBloc = MockSudokuBloc(); + puzzleBloc = MockPuzzleBloc(); }); testWidgets( - 'adds [SudokuInputTapped] when tapped on an input block', + 'adds [SudokuInputEntered] when tapped on an input block', (tester) async { await tester.pumpApp( SudokuInput(sudokuDimension: 1), - sudokuBloc: sudokuBloc, + puzzleBloc: puzzleBloc, ); - await tester.tap(find.byType(GestureDetector)); + await tester.tap(find.byType(GestureDetector).first); await tester.pumpAndSettle(); - verify(() => sudokuBloc.add(SudokuInputTapped(1))).called(1); + verify(() => puzzleBloc.add(SudokuInputEntered(1))).called(1); + }, + ); + + testWidgets( + 'adds [SudokuInputErased] when tapped on the last input block', + (tester) async { + await tester.pumpApp( + SudokuInput(sudokuDimension: 1), + puzzleBloc: puzzleBloc, + ); + + await tester.tap(find.byType(GestureDetector).last); + await tester.pumpAndSettle(); + + verify(() => puzzleBloc.add(SudokuInputErased())).called(1); }, );