Skip to content

Commit

Permalink
feat: add ui for sudoku hint button and hint panel
Browse files Browse the repository at this point in the history
  • Loading branch information
thisissandipp committed Aug 1, 2024
1 parent 45ae62d commit 49968d9
Show file tree
Hide file tree
Showing 12 changed files with 454 additions and 7 deletions.
18 changes: 16 additions & 2 deletions lib/puzzle/bloc/puzzle_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,25 @@ class PuzzleBloc extends Bloc<PuzzleEvent, PuzzleState> {

try {
final hint = await _apiClient.generateHint(sudoku: state.puzzle.sudoku);

final selectedBlock = state.puzzle.sudoku.blocks.firstWhere(
(block) => block.position == hint.cell,
);
final highlightedBlocks =
state.puzzle.sudoku.blocksToHighlight(selectedBlock);

// If the hint was generated for a pre-filled block.
if (selectedBlock.isGenerated) {
emit(
state.copyWith(
hintStatus: () => HintStatus.fetchFailed,
),
);
return;
}

final highlightedBlocks = state.puzzle.sudoku.blocksToHighlight(
selectedBlock,
);

emit(
state.copyWith(
puzzle: () => state.puzzle.copyWith(
Expand Down
4 changes: 4 additions & 0 deletions lib/puzzle/bloc/puzzle_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,8 @@ extension HintStatusExtension on HintStatus {
bool get isInteractionEnded {
return this == HintStatus.interactionEnded;
}

bool get successOrFailed {
return this == HintStatus.fetchSuccess || this == HintStatus.fetchFailed;
}
}
18 changes: 17 additions & 1 deletion lib/puzzle/view/puzzle_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ class PuzzleViewLayout extends StatelessWidget {
child: Column(
children: [
PageHeader(),
ResponsiveGap(large: 96),
ResponsiveGap(large: 48),
HintPanel(),
ResponsiveGap(large: 48),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Expand All @@ -157,6 +159,8 @@ class PuzzleViewLayout extends StatelessWidget {
SudokuInputView(),
SizedBox(height: 8),
InputEraseViewForLargeLayout(),
SizedBox(height: 32),
AskHintButton(),
],
),
],
Expand Down Expand Up @@ -212,6 +216,18 @@ class PuzzleViewLayout extends StatelessWidget {
large: 32,
),
const SudokuInputView(),
const ResponsiveGap(
small: 16,
medium: 24,
large: 32,
),
const AskHintButton(),
const ResponsiveGap(
small: 16,
medium: 24,
large: 32,
),
const HintPanel(),
],
),
),
Expand Down
68 changes: 68 additions & 0 deletions lib/puzzle/widgets/ask_hint_button.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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';
import 'package:sudoku/widgets/widgets.dart';

/// {@template ask_hint_button}
/// Displays a button to ask gemini for hint.
///
/// Disables if the remaining hint count decreases to 0 or
/// a hint request is already in progress.
///
/// {@endtemplate}
// TODO(thecodexhub): Shows a loading indication when loading in progress.
class AskHintButton extends StatelessWidget {
/// {@macro ask_hint_button}
const AskHintButton({super.key});

@override
Widget build(BuildContext context) {
final remainingHints = context.select(
(PuzzleBloc bloc) => bloc.state.puzzle.remainingHints,
);

final hintInProgress = context.select(
(PuzzleBloc bloc) => bloc.state.hintStatus.isFetchInProgress,
);

final buttonBeActive = remainingHints > 0 && !hintInProgress;

return ResponsiveLayoutBuilder(
small: (_, child) => child!,
medium: (_, child) => child!,
large: (_, child) => child!,
child: (layoutSize) {
final maxWidth = switch (layoutSize) {
ResponsiveLayoutSize.small => SudokuBoardSize.small,
ResponsiveLayoutSize.medium => SudokuBoardSize.medium,
ResponsiveLayoutSize.large => SudokuInputSize.large * 3,
};

return Column(
children: [
SizedBox(
width: maxWidth,
child: SudokuElevatedButton(
height: 45,
buttonText: 'Ask Gemini for a hint',
onPressed: buttonBeActive
? () => context.read<PuzzleBloc>().add(
const SudokuHintRequested(),
)
: null,
),
),
const SizedBox(height: 8),
Text(
'Number of hints remaining: $remainingHints',
style: SudokuTextStyle.caption,
),
],
);
},
);
}
}
138 changes: 138 additions & 0 deletions lib/puzzle/widgets/hint_panel.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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';
import 'package:sudoku/widgets/widgets.dart';

/// {@template hint_panel}
/// Widget to display hint. Shows an error message if there was an
/// error in fetching or validating the hint.
/// {@endtemplate}
class HintPanel extends StatelessWidget {
/// {@macro hint_panel}
const HintPanel({super.key});

@override
Widget build(BuildContext context) {
final panelBeOpen = context.select(
(PuzzleBloc bloc) => bloc.state.hintStatus.successOrFailed,
);

final hintAvailable = context.select(
(PuzzleBloc bloc) => bloc.state.hint != null,
);

if (!panelBeOpen) {
return const SizedBox();
}

return ResponsiveLayoutBuilder(
small: (_, child) => child!,
medium: (_, child) => child!,
large: (_, child) => child!,
child: (layoutSize) {
final maxWidth = switch (layoutSize) {
ResponsiveLayoutSize.small => SudokuBoardSize.small,
ResponsiveLayoutSize.medium => SudokuBoardSize.medium,
ResponsiveLayoutSize.large => 880.0,
};

return SizedBox(
width: maxWidth,
child: hintAvailable ? const DisplayHint() : const DisplayError(),
);
},
);
}
}

/// {@template display_hint}
/// Widget to display the hint, when fetch was successful.
/// {@endtemplate}
@visibleForTesting
class DisplayHint extends StatelessWidget {
/// {@macro display_hint}
const DisplayHint({super.key});

@override
Widget build(BuildContext context) {
final hint = context.read<PuzzleBloc>().state.hint!;

final defaulTextStyle = SudokuTextStyle.caption;
final titleTextStyle = SudokuTextStyle.bodyText2.copyWith(
fontWeight: SudokuFontWeight.semiBold,
);

return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: SudokuColors.lightPurple.withOpacity(0.27),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Observation:', style: titleTextStyle),
const SizedBox(height: 4),
Text(hint.observation, style: defaulTextStyle),
const SizedBox(height: 8),
Text('Explanation:', style: titleTextStyle),
const SizedBox(height: 4),
Text(hint.explanation, style: defaulTextStyle),
const SizedBox(height: 8),
Text('Solution:', style: titleTextStyle),
const SizedBox(height: 4),
Text(hint.solution, style: defaulTextStyle),
const SizedBox(height: 8),
SudokuTextButton(
buttonText: 'Approve & Close',
onPressed: () => context.read<PuzzleBloc>().add(
const HintInteractioCompleted(),
),
),
],
),
),
);
}
}

/// {@template display_hint}
/// Widget to display the hint, when fetch was successful.
/// {@endtemplate}
@visibleForTesting
class DisplayError extends StatelessWidget {
/// {@macro display_hint}
const DisplayError({super.key});

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);

return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.errorContainer.withOpacity(0.45),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
Text(
'There has been an error while fetching or validating '
'the hint. Please try again!',
textAlign: TextAlign.center,
style: SudokuTextStyle.caption.copyWith(
color: theme.colorScheme.error,
),
),
],
),
),
);
}
}
2 changes: 2 additions & 0 deletions lib/puzzle/widgets/widgets.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export 'ask_hint_button.dart';
export 'congrats_dialog.dart';
export 'game_over_dialog.dart';
export 'hint_panel.dart';
export 'mistakes_count_view.dart';
6 changes: 5 additions & 1 deletion lib/widgets/sudoku_elevated_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ class SudokuElevatedButton extends StatelessWidget {
const SudokuElevatedButton({
required this.buttonText,
required this.onPressed,
this.height = 36,
super.key,
});

/// The height of the elevated button. Defaults to 36.
final double height;

/// Text to be shown in the button.
final String buttonText;

Expand All @@ -24,7 +28,7 @@ class SudokuElevatedButton extends StatelessWidget {
final theme = Theme.of(context);

return SizedBox(
height: 36,
height: height,
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
Expand Down
1 change: 1 addition & 0 deletions test/home/view/home_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ void main() {
when(() => puzzle.sudoku).thenReturn(sudoku3x3);
when(() => puzzle.difficulty).thenReturn(Difficulty.medium);
when(() => puzzle.remainingMistakes).thenReturn(3);
when(() => puzzle.remainingHints).thenReturn(3);

whenListen(
homeBloc,
Expand Down
33 changes: 33 additions & 0 deletions test/puzzle/bloc/puzzle_bloc_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,39 @@ void main() {
},
);

blocTest<PuzzleBloc, PuzzleState>(
'emits a new state with failed status when api was succesful but '
'hint was generated for a block that is [isGenerated]',
build: buildBloc,
setUp: () {
puzzle = Puzzle(sudoku: sudoku3x3, difficulty: Difficulty.medium);
// This is a generated block.
when(() => hint.cell).thenReturn(Position(x: 0, y: 2));
when(() => apiClient.generateHint(sudoku: any(named: 'sudoku')))
.thenAnswer((_) async => hint);
},
seed: () => PuzzleState(puzzle: puzzle),
act: (bloc) => bloc.add(SudokuHintRequested()),
expect: () => [
PuzzleState(
puzzle: puzzle,
puzzleStatus: PuzzleStatus.incomplete,
hintStatus: HintStatus.fetchInProgress,
highlightedBlocks: const [],
selectedBlock: null,
hint: null,
),
PuzzleState(
puzzle: puzzle,
puzzleStatus: PuzzleStatus.incomplete,
hintStatus: HintStatus.fetchFailed,
),
],
verify: (_) {
verify(() => apiClient.generateHint(sudoku: sudoku3x3)).called(1);
},
);

blocTest<PuzzleBloc, PuzzleState>(
'emits a new state with failed hint status when api fails',
build: buildBloc,
Expand Down
9 changes: 6 additions & 3 deletions test/puzzle/view/puzzle_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ void main() {
when(() => puzzle.sudoku).thenReturn(sudoku3x3);
when(() => puzzle.difficulty).thenReturn(Difficulty.medium);
when(() => puzzle.remainingMistakes).thenReturn(3);
when(() => puzzle.remainingHints).thenReturn(3);

when(() => puzzleRepository.fetchPuzzleFromCache()).thenReturn(puzzle);

Expand Down Expand Up @@ -157,9 +158,11 @@ void main() {
when(() => puzzle.sudoku).thenReturn(sudoku);
when(() => puzzle.difficulty).thenReturn(Difficulty.medium);
when(() => puzzle.remainingMistakes).thenReturn(2);
when(() => puzzle.remainingHints).thenReturn(2);

when(() => puzzleState.puzzle).thenReturn(puzzle);
when(() => puzzleState.puzzleStatus).thenReturn(PuzzleStatus.failed);
when(() => puzzleState.hintStatus).thenReturn(HintStatus.initial);
when(() => puzzleBloc.state).thenReturn(puzzleState);

when(() => timerState.secondsElapsed).thenReturn(167);
Expand Down Expand Up @@ -272,11 +275,11 @@ void main() {
when(() => puzzle.sudoku).thenReturn(sudoku);
when(() => puzzle.difficulty).thenReturn(Difficulty.medium);
when(() => puzzle.remainingMistakes).thenReturn(3);
when(() => puzzle.remainingHints).thenReturn(3);

when(() => puzzleState.puzzle).thenReturn(puzzle);
when(() => puzzleState.puzzleStatus).thenReturn(
PuzzleStatus.incomplete,
);
when(() => puzzleState.puzzleStatus).thenReturn(PuzzleStatus.incomplete);
when(() => puzzleState.hintStatus).thenReturn(HintStatus.initial);
when(() => puzzleBloc.state).thenReturn(puzzleState);

when(() => timerBloc.state).thenReturn(
Expand Down
Loading

0 comments on commit 49968d9

Please sign in to comment.