diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 27812c4..d0ef48a 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -11,7 +11,6 @@ class App extends StatelessWidget { return MaterialApp( theme: SudokuTheme.light, darkTheme: SudokuTheme.dark, - themeMode: ThemeMode.light, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const SudokuPage(), diff --git a/lib/sudoku/view/sudoku_page.dart b/lib/sudoku/view/sudoku_page.dart index 3134516..73ce840 100644 --- a/lib/sudoku/view/sudoku_page.dart +++ b/lib/sudoku/view/sudoku_page.dart @@ -4,9 +4,14 @@ 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({super.key}); static const _generated = [ @@ -35,16 +40,29 @@ class SudokuPage extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => SudokuBloc( - sudoku: Sudoku.fromRawData(_generated, _answer), - ), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => SudokuBloc( + sudoku: Sudoku.fromRawData(_generated, _answer), + ), + ), + 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 @@ -61,9 +79,11 @@ class SudokuView extends StatelessWidget { body: SingleChildScrollView( child: Column( children: [ - const ResponsiveGap( - large: 246, + const ResponsiveGap(large: 246), + const Center( + child: SudokuTimer(), ), + const ResponsiveGap(large: 96), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -76,23 +96,17 @@ class SudokuView extends StatelessWidget { ), ), ), - const SizedBox( - width: 60, - ), + const SizedBox(width: 60), const SudokuBoardView( layoutSize: ResponsiveLayoutSize.large, ), - const SizedBox( - width: 96, - ), + const SizedBox(width: 96), SudokuInput( sudokuDimension: sudoku.getDimesion(), ), ], ), - const ResponsiveGap( - large: 246, - ), + const ResponsiveGap(large: 246), ], ), ), @@ -103,25 +117,20 @@ class SudokuView extends StatelessWidget { title: Text(l10n.sudokuAppBarTitle), ), body: SingleChildScrollView( - child: Column( - children: [ - const ResponsiveGap( - small: 24, - medium: 32, - ), - Center( - child: SudokuBoardView(layoutSize: layoutSize), - ), - const ResponsiveGap( - small: 32, - medium: 56, - ), - Center( - child: SudokuInput( + 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(), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/sudoku/widgets/sudoku_board.dart b/lib/sudoku/widgets/sudoku_board.dart index 38085cf..9cfb34f 100644 --- a/lib/sudoku/widgets/sudoku_board.dart +++ b/lib/sudoku/widgets/sudoku_board.dart @@ -1,11 +1,16 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:sudoku/layout/layout.dart'; import 'package:sudoku/sudoku/sudoku.dart'; +import 'package:sudoku/timer/timer.dart'; /// {@template sudoku_board} /// Displays the Sudoku board in a [Stack] containing [blocks]. +/// +/// When the timer is paused, it shows a paused icon, and not +/// the [blocks] and its values. /// {@endtemplate} class SudokuBoard extends StatelessWidget { /// {@macro sudoku_board} @@ -16,6 +21,10 @@ class SudokuBoard extends StatelessWidget { @override Widget build(BuildContext context) { + final isTimerPaused = context.select( + (TimerBloc bloc) => !bloc.state.isRunning, + ); + return ResponsiveLayoutBuilder( small: (_, child) => SizedBox.square( key: const Key('sudoku_board_small'), @@ -46,7 +55,7 @@ class SudokuBoard extends StatelessWidget { final subGridSize = subGridDimension * blockSize; return Stack( children: [ - ...blocks, + if (!isTimerPaused) ...blocks, IgnorePointer( child: SudokuBoardDivider( dimension: boardSize, @@ -64,6 +73,16 @@ class SudokuBoard extends StatelessWidget { ), ), ), + if (isTimerPaused) + Center( + child: FloatingActionButton.extended( + onPressed: () => context.read().add( + const TimerResumed(), + ), + label: const Text('Resume the puzzle'), + icon: const Icon(Icons.play_arrow), + ), + ), ], ); }, diff --git a/lib/sudoku/widgets/sudoku_timer.dart b/lib/sudoku/widgets/sudoku_timer.dart new file mode 100644 index 0000000..9b45744 --- /dev/null +++ b/lib/sudoku/widgets/sudoku_timer.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:sudoku/layout/layout.dart'; +import 'package:sudoku/timer/timer.dart'; +import 'package:sudoku/typography/typography.dart'; + +class SudokuTimer extends StatelessWidget { + const SudokuTimer({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return BlocBuilder( + builder: (context, state) { + final hour = state.secondsElapsed ~/ 3600; + final minute = (state.secondsElapsed - (hour * 3600)) ~/ 60; + final seconds = state.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 ResponsiveLayoutBuilder( + small: (_, child) => child!, + medium: (_, child) => child!, + large: (_, child) => child!, + child: (layoutSize) { + final timerTextStyle = switch (layoutSize) { + ResponsiveLayoutSize.large => SudokuTextStyle.bodyText1, + _ => SudokuTextStyle.bodyText1, + }; + + return GestureDetector( + onTap: () => state.isRunning + ? context.read().add(const TimerStopped()) + : context.read().add(const TimerResumed()), + child: 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: [ + Text( + '$hourString:$minuteString:$secondsString', + style: timerTextStyle, + ), + Icon( + state.isRunning ? Icons.pause : Icons.play_arrow, + size: timerTextStyle.fontSize, + ), + ], + ), + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/sudoku/widgets/widgets.dart b/lib/sudoku/widgets/widgets.dart index c18e2e3..fb8179a 100644 --- a/lib/sudoku/widgets/widgets.dart +++ b/lib/sudoku/widgets/widgets.dart @@ -2,3 +2,4 @@ export 'sudoku_block.dart'; export 'sudoku_board.dart'; export 'sudoku_board_divider.dart'; export 'sudoku_input.dart'; +export 'sudoku_timer.dart'; diff --git a/lib/timer/bloc/timer_bloc.dart b/lib/timer/bloc/timer_bloc.dart index c453147..b657918 100644 --- a/lib/timer/bloc/timer_bloc.dart +++ b/lib/timer/bloc/timer_bloc.dart @@ -14,6 +14,7 @@ class TimerBloc extends Bloc { on(_onTimerStarted); on(_onTimerTicked); on(_onTimerStopped); + on(_onTimerResumed); on(_onTimerReset); } @@ -44,6 +45,11 @@ class TimerBloc extends Bloc { emit(state.copyWith(isRunning: false)); } + void _onTimerResumed(TimerResumed event, Emitter emit) { + _tickerSubscription?.resume(); + emit(state.copyWith(isRunning: true)); + } + void _onTimerReset(TimerReset event, Emitter emit) { _tickerSubscription?.cancel(); emit(state.copyWith(secondsElapsed: 0)); diff --git a/lib/timer/bloc/timer_event.dart b/lib/timer/bloc/timer_event.dart index 752e229..fb8f3cc 100644 --- a/lib/timer/bloc/timer_event.dart +++ b/lib/timer/bloc/timer_event.dart @@ -24,6 +24,10 @@ final class TimerStopped extends TimerEvent { const TimerStopped(); } +final class TimerResumed extends TimerEvent { + const TimerResumed(); +} + final class TimerReset extends TimerEvent { const TimerReset(); } diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 0ed825c..b978989 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -2,6 +2,7 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:sudoku/models/models.dart'; import 'package:sudoku/sudoku/sudoku.dart'; +import 'package:sudoku/timer/timer.dart'; class MockSudoku extends Mock implements Sudoku {} @@ -13,3 +14,6 @@ class MockSudokuState extends Mock implements SudokuState {} class MockBlock extends Mock implements Block {} class MockTicker extends Mock implements Ticker {} + +class MockTimerBloc extends MockBloc + implements TimerBloc {} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index f3b8e6a..49b911f 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -3,14 +3,26 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sudoku/l10n/l10n.dart'; import 'package:sudoku/sudoku/sudoku.dart'; +import 'package:sudoku/timer/timer.dart'; import 'helpers.dart'; extension PumpApp on WidgetTester { - Future pumpApp(Widget widget, {SudokuBloc? sudokuBloc}) { + Future pumpApp( + Widget widget, { + SudokuBloc? sudokuBloc, + TimerBloc? timerBloc, + }) { return pumpWidget( - BlocProvider.value( - value: sudokuBloc ?? MockSudokuBloc(), + MultiBlocProvider( + providers: [ + BlocProvider.value( + value: sudokuBloc ?? MockSudokuBloc(), + ), + BlocProvider.value( + value: timerBloc ?? MockTimerBloc(), + ), + ], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, diff --git a/test/sudoku/view/sudoku_page_test.dart b/test/sudoku/view/sudoku_page_test.dart index 1c50d22..3b92fe9 100644 --- a/test/sudoku/view/sudoku_page_test.dart +++ b/test/sudoku/view/sudoku_page_test.dart @@ -6,6 +6,7 @@ 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'; @@ -19,16 +20,21 @@ void main() { 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 { @@ -36,6 +42,7 @@ void main() { await tester.pumpApp( const SudokuView(), sudokuBloc: sudokuBloc, + timerBloc: timerBloc, ); expect(find.byType(AppBar), findsOneWidget); }); @@ -45,6 +52,7 @@ void main() { await tester.pumpApp( const SudokuView(), sudokuBloc: sudokuBloc, + timerBloc: timerBloc, ); expect(find.byType(AppBar), findsOneWidget); }); @@ -54,6 +62,7 @@ void main() { await tester.pumpApp( const SudokuView(), sudokuBloc: sudokuBloc, + timerBloc: timerBloc, ); expect(find.byType(AppBar), findsNothing); }); @@ -61,16 +70,23 @@ void main() { 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( @@ -99,6 +115,7 @@ void main() { layoutSize: ResponsiveLayoutSize.large, ), sudokuBloc: sudokuBloc, + timerBloc: timerBloc, ); await tester.pumpAndSettle(); diff --git a/test/sudoku/widgets/sudoku_board_test.dart b/test/sudoku/widgets/sudoku_board_test.dart new file mode 100644 index 0000000..6dc1efa --- /dev/null +++ b/test/sudoku/widgets/sudoku_board_test.dart @@ -0,0 +1,94 @@ +// ignore_for_file: prefer_const_constructors, avoid_redundant_argument_values + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:sudoku/sudoku/sudoku.dart'; +import 'package:sudoku/timer/timer.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('SudokuBoard', () { + late TimerBloc timerBloc; + + const largeKey = Key('sudoku_board_large'); + const mediumKey = Key('sudoku_board_medium'); + const smallKey = Key('sudoku_board_small'); + + setUp(() { + timerBloc = MockTimerBloc(); + when(() => timerBloc.state).thenReturn(TimerState()); + }); + + testWidgets('renders on a large layout', (tester) async { + tester.setLargeDisplaySize(); + await tester.pumpApp( + SudokuBoard(blocks: const []), + timerBloc: timerBloc, + ); + expect(find.byKey(largeKey), findsOneWidget); + }); + + testWidgets('renders on a medium layout', (tester) async { + tester.setMediumDisplaySize(); + await tester.pumpApp( + SudokuBoard(blocks: const []), + timerBloc: timerBloc, + ); + expect(find.byKey(mediumKey), findsOneWidget); + }); + + testWidgets('renders on a small layout', (tester) async { + tester.setSmallDisplaySize(); + await tester.pumpApp( + SudokuBoard(blocks: const []), + timerBloc: timerBloc, + ); + expect(find.byKey(smallKey), findsOneWidget); + }); + + testWidgets( + 'does not render [FloatingActionButton] when timer is running', + (tester) async { + when(() => timerBloc.state).thenReturn( + TimerState(secondsElapsed: 5, isRunning: true), + ); + await tester.pumpApp( + SudokuBoard(blocks: const []), + timerBloc: timerBloc, + ); + expect(find.byType(FloatingActionButton), findsNothing); + }, + ); + + testWidgets( + 'renders [FloatingActionButton] when timer is paused', + (tester) async { + when(() => timerBloc.state).thenReturn( + TimerState(secondsElapsed: 5, isRunning: false), + ); + await tester.pumpApp( + SudokuBoard(blocks: const []), + timerBloc: timerBloc, + ); + expect(find.byType(FloatingActionButton), findsOneWidget); + }, + ); + + testWidgets( + 'adds [TimerResumed] when tapped on [FloatingActionButton] widget', + (tester) async { + when(() => timerBloc.state).thenReturn( + TimerState(secondsElapsed: 5, isRunning: false), + ); + await tester.pumpApp( + SudokuBoard(blocks: const []), + timerBloc: timerBloc, + ); + await tester.tap(find.byType(FloatingActionButton)); + verify(() => timerBloc.add(TimerResumed())).called(1); + }, + ); + }); +} diff --git a/test/sudoku/widgets/sudoku_timer_test.dart b/test/sudoku/widgets/sudoku_timer_test.dart new file mode 100644 index 0000000..1af04d4 --- /dev/null +++ b/test/sudoku/widgets/sudoku_timer_test.dart @@ -0,0 +1,72 @@ +// ignore_for_file: prefer_const_constructors, avoid_redundant_argument_values + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:sudoku/sudoku/sudoku.dart'; +import 'package:sudoku/timer/timer.dart'; + +import '../../helpers/helpers.dart'; + +void main() { + group('SudokuTimer', () { + late TimerBloc timerBloc; + + setUp(() { + timerBloc = MockTimerBloc(); + when(() => timerBloc.state).thenReturn( + TimerState(secondsElapsed: 5, isRunning: true), + ); + }); + + testWidgets( + 'adds [TimerStopped] for tapping on the widget when Timer is running', + (tester) async { + await tester.pumpApp(SudokuTimer(), timerBloc: timerBloc); + await tester.tap(find.byType(GestureDetector)); + verify(() => timerBloc.add(TimerStopped())).called(1); + }, + ); + + testWidgets( + 'adds [TimerResumed] for tapping on the widget when Timer is stopped', + (tester) async { + when(() => timerBloc.state).thenReturn( + TimerState(secondsElapsed: 5, isRunning: false), + ); + await tester.pumpApp(SudokuTimer(), timerBloc: timerBloc); + await tester.tap(find.byType(GestureDetector)); + verify(() => timerBloc.add(TimerResumed())).called(1); + }, + ); + + testWidgets( + 'renders the pause icon when Timer is running', + (tester) async { + await tester.pumpApp(SudokuTimer(), timerBloc: timerBloc); + expect( + find.byWidgetPredicate( + (widget) => widget is Icon && widget.icon == Icons.pause, + ), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'renders the play icon when Timer is stopped', + (tester) async { + when(() => timerBloc.state).thenReturn( + TimerState(secondsElapsed: 5, isRunning: false), + ); + await tester.pumpApp(SudokuTimer(), timerBloc: timerBloc); + expect( + find.byWidgetPredicate( + (widget) => widget is Icon && widget.icon == Icons.play_arrow, + ), + findsOneWidget, + ); + }, + ); + }); +} diff --git a/test/timer/bloc/timer_bloc_test.dart b/test/timer/bloc/timer_bloc_test.dart index b014553..cae6a84 100644 --- a/test/timer/bloc/timer_bloc_test.dart +++ b/test/timer/bloc/timer_bloc_test.dart @@ -80,6 +80,53 @@ void main() { }); }); + group('TimerResumed', () { + test('resumes the timer from where it was stopped', () async { + final bloc = TimerBloc(ticker: ticker)..add(TimerStarted()); + + expect( + await bloc.stream.first, + equals(TimerState(isRunning: true, secondsElapsed: 0)), + ); + + streamController + ..add(1) + ..add(2); + + await expectLater( + bloc.stream, + emitsInOrder([ + TimerState(isRunning: true, secondsElapsed: 1), + TimerState(isRunning: true, secondsElapsed: 2), + ]), + ); + + bloc.add(TimerStopped()); + streamController.add(3); + + expect( + await bloc.stream.first, + equals(TimerState(isRunning: false, secondsElapsed: 2)), + ); + + bloc.add(TimerResumed()); + streamController + ..add(3) + ..add(4) + ..add(5); + + await expectLater( + bloc.stream, + emitsInOrder([ + TimerState(isRunning: true, secondsElapsed: 2), + TimerState(isRunning: true, secondsElapsed: 3), + TimerState(isRunning: true, secondsElapsed: 4), + TimerState(isRunning: true, secondsElapsed: 5), + ]), + ); + }); + }); + group('TimerReset', () { blocTest( 'emits new timer state', diff --git a/test/timer/bloc/timer_event_test.dart b/test/timer/bloc/timer_event_test.dart index bb595a2..e0aa4ba 100644 --- a/test/timer/bloc/timer_event_test.dart +++ b/test/timer/bloc/timer_event_test.dart @@ -38,6 +38,16 @@ void main() { }); }); + group('TimerResumed', () { + test('supports value equality', () { + expect(TimerResumed(), equals(TimerResumed())); + }); + + test('props are correct', () { + expect(TimerResumed().props, equals([])); + }); + }); + group('TimerReset', () { test('supports value equality', () { expect(TimerReset(), equals(TimerReset()));