From afbb737d3f3469e9de8ac37ef96f9992a40e8b6b Mon Sep 17 00:00:00 2001 From: Jaime Sanchez Date: Mon, 22 Apr 2024 12:10:03 +0200 Subject: [PATCH 1/8] feat: add localizations --- lib/l10n/arb/app_en.arb | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 63af206c1..5883cd358 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -89,6 +89,22 @@ "@askYesOrNoQuestion": { "description": "Ask a yes or no question text" }, + "runOutOfHints": "You've run out of hints", + "@runOutOfHints": { + "description": "Run out of hints label" + }, + "hintsRemaining": "{remaining} of {total} hints remaining", + "@hintsRemaining": { + "description": "Hints remaining label.", + "placeholders": { + "remaining": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, "getHint": "Get Hint", "@getHint": { "description": "Get hint label" @@ -229,10 +245,6 @@ "@findNewWord": { "description": "Find new word label" }, - "points": "Points", - "@points": { - "description": "Points label" - }, "totalScore": "Total score", "@totalScore": { "description": "Total score label" From b62472eabf134ad4aaa2c072d4d64f74df96eee1 Mon Sep 17 00:00:00 2001 From: Jaime Sanchez Date: Mon, 22 Apr 2024 12:12:14 +0200 Subject: [PATCH 2/8] feat: fetch max hints from db --- .../lib/src/hint_repository.dart | 19 ++++++++++++ .../test/src/hint_repository_test.dart | 30 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/api/packages/hint_repository/lib/src/hint_repository.dart b/api/packages/hint_repository/lib/src/hint_repository.dart index fb2352325..d2a2e8272 100644 --- a/api/packages/hint_repository/lib/src/hint_repository.dart +++ b/api/packages/hint_repository/lib/src/hint_repository.dart @@ -15,10 +15,29 @@ class HintRepository { static const _answersCollection = 'answers'; static const _hintsCollection = 'hints'; + static const _boardInfoCollection = 'boardInfo'; + + static const _defaultMaxHints = 10; String _hintsPath(String wordId) => '$_answersCollection/$wordId/$_hintsCollection'; + /// Returns the maximum hints allowed for a word. + Future getMaxHints() async { + try { + final results = await _dbClient.findBy( + _boardInfoCollection, + 'type', + 'max_hints', + ); + + final data = results.first.data; + return (data['value'] as num).toInt(); + } catch (_) { + return _defaultMaxHints; + } + } + /// Generates a new hint for the given word, having the context from previous /// hints. Future generateHint({ diff --git a/api/packages/hint_repository/test/src/hint_repository_test.dart b/api/packages/hint_repository/test/src/hint_repository_test.dart index 8010cac83..1813e2781 100644 --- a/api/packages/hint_repository/test/src/hint_repository_test.dart +++ b/api/packages/hint_repository/test/src/hint_repository_test.dart @@ -28,6 +28,36 @@ void main() { ); }); + group('getMaxHints', () { + test('returns the maximum hints allowed', () async { + when(() => dbClient.findBy('boardInfo', 'type', 'max_hints')) + .thenAnswer( + (_) async => [ + DbEntityRecord( + id: 'maxHints', + data: const { + 'type': 'max_hints', + 'value': 5, + }, + ), + ], + ); + + final maxHints = await hintRepository.getMaxHints(); + + expect(maxHints, 5); + }); + + test('returns the default maximum hints when an error occurs', () async { + when(() => dbClient.findBy('boardInfo', 'type', 'max_hints')) + .thenThrow(Exception()); + + final maxHints = await hintRepository.getMaxHints(); + + expect(maxHints, 10); + }); + }); + group('generateHint', () { test('returns a hint', () async { final hint = await hintRepository.generateHint( From 88d6e4b187cd02c4f9294b33dc844056ea063efa Mon Sep 17 00:00:00 2001 From: Jaime Sanchez Date: Mon, 22 Apr 2024 12:20:01 +0200 Subject: [PATCH 3/8] feat: return max hints from apis --- api/routes/game/hint.dart | 14 +++++++---- api/test/routes/game/hint_test.dart | 13 +++++++++- .../lib/src/resources/hint_resource.dart | 12 ++++++---- .../src/resources/hint_resource_test.dart | 24 +++++++++++++------ 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/api/routes/game/hint.dart b/api/routes/game/hint.dart index 439d9c2cf..b4c033f9c 100644 --- a/api/routes/game/hint.dart +++ b/api/routes/game/hint.dart @@ -5,8 +5,6 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:hint_repository/hint_repository.dart'; import 'package:jwt_middleware/jwt_middleware.dart'; -const _maxAllowedHints = 10; - Future onRequest(RequestContext context) async { if (context.request.method == HttpMethod.post) { return _onPost(context); @@ -43,7 +41,8 @@ Future _onPost(RequestContext context) async { userId: user.id, wordId: wordId, ); - if (previousHints.length >= _maxAllowedHints) { + final maxAllowedHints = await hintRepository.getMaxHints(); + if (previousHints.length >= maxAllowedHints) { return Response( body: 'Max hints reached for word $wordId', statusCode: HttpStatus.forbidden, @@ -62,7 +61,12 @@ Future _onPost(RequestContext context) async { hints: [...previousHints, hint], ); - return Response.json(body: hint.toJson()); + return Response.json( + body: { + 'hint': hint.toJson(), + 'maxHints': maxAllowedHints, + }, + ); } catch (e) { return Response( body: e.toString(), @@ -86,10 +90,12 @@ Future _onGet(RequestContext context) async { userId: user.id, wordId: wordId, ); + final maxAllowedHints = await hintRepository.getMaxHints(); return Response.json( body: { 'hints': hints.map((hint) => hint.toJson()).toList(), + 'maxHints': maxAllowedHints, }, ); } catch (e) { diff --git a/api/test/routes/game/hint_test.dart b/api/test/routes/game/hint_test.dart index 4e3f3cd7a..f90bd99f8 100644 --- a/api/test/routes/game/hint_test.dart +++ b/api/test/routes/game/hint_test.dart @@ -90,6 +90,7 @@ void main() { wordId: 'wordId', ), ).thenAnswer((_) async => []); + when(() => hintRepository.getMaxHints()).thenAnswer((_) async => 10); when( () => hintRepository.generateHint( wordAnswer: 'answer', @@ -112,7 +113,13 @@ void main() { expect(response.statusCode, HttpStatus.ok); expect( await response.json(), - equals({'question': 'question', 'response': 'no'}), + equals({ + 'hint': { + 'question': 'question', + 'response': 'no', + }, + 'maxHints': 10, + }), ); verify( () => hintRepository.saveHints( @@ -147,6 +154,7 @@ void main() { wordId: 'wordId', ), ).thenAnswer((_) async => []); + when(() => hintRepository.getMaxHints()).thenAnswer((_) async => 10); when( () => hintRepository.generateHint( wordAnswer: 'answer', @@ -186,6 +194,7 @@ void main() { wordId: 'wordId', ), ).thenAnswer((_) async => List.filled(10, hint)); + when(() => hintRepository.getMaxHints()).thenAnswer((_) async => 10); final response = await route.onRequest(requestContext); @@ -268,6 +277,7 @@ void main() { wordId: 'wordId', ), ).thenAnswer((_) async => hintList); + when(() => hintRepository.getMaxHints()).thenAnswer((_) async => 10); final response = await route.onRequest(requestContext); @@ -280,6 +290,7 @@ void main() { {'question': 'question2', 'response': 'notApplicable'}, {'question': 'question3', 'response': 'no'}, ], + 'maxHints': 10, }), ); }, diff --git a/packages/api_client/lib/src/resources/hint_resource.dart b/packages/api_client/lib/src/resources/hint_resource.dart index 1392a52f6..759233a51 100644 --- a/packages/api_client/lib/src/resources/hint_resource.dart +++ b/packages/api_client/lib/src/resources/hint_resource.dart @@ -18,7 +18,7 @@ class HintResource { /// Post /game/hint /// /// Generates a [Hint] for the provided word by answering to the question. - Future generateHint({ + Future<(Hint, int)> generateHint({ required String wordId, required String question, }) async { @@ -41,8 +41,9 @@ class HintResource { try { final body = jsonDecode(response.body) as Map; - final hint = Hint.fromJson(body); - return hint; + final hint = Hint.fromJson(body['hint'] as Map); + final maxHints = body['maxHints'] as int; + return (hint, maxHints); } catch (error, stackTrace) { throw ApiClientError( 'POST $path returned invalid response: "${response.body}"', @@ -54,7 +55,7 @@ class HintResource { /// Get /game/hint /// /// Fetches all the hints for the provided word. - Future> getHints({ + Future<(List, int)> getHints({ required String wordId, }) async { const path = '/game/hint'; @@ -78,7 +79,8 @@ class HintResource { final hints = (body['hints'] as List) .map((hint) => Hint.fromJson(hint as Map)) .toList(); - return hints; + final maxHints = body['maxHints'] as int; + return (hints, maxHints); } catch (error, stackTrace) { throw ApiClientError( 'GET $path returned invalid response: "${response.body}"', diff --git a/packages/api_client/test/src/resources/hint_resource_test.dart b/packages/api_client/test/src/resources/hint_resource_test.dart index 84bdaf4eb..085d61f2f 100644 --- a/packages/api_client/test/src/resources/hint_resource_test.dart +++ b/packages/api_client/test/src/resources/hint_resource_test.dart @@ -56,20 +56,26 @@ void main() { ).called(1); }); - test('returns the hint when succeeds', () async { + test('returns the hint and max hints when succeeds', () async { final hint = Hint( question: 'is it a question?', response: HintResponse.yes, ); when(() => response.statusCode).thenReturn(HttpStatus.ok); - when(() => response.body).thenReturn(jsonEncode(hint.toJson())); + when(() => response.body).thenReturn( + jsonEncode({ + 'hint': hint.toJson(), + 'maxHints': 4, + }), + ); final result = await resource.generateHint( wordId: 'wordId', question: 'is it a question?', ); - expect(result, equals(hint)); + expect(result.$1, equals(hint)); + expect(result.$2, equals(4)); }); test('throws ApiClientError when request fails', () async { @@ -142,19 +148,23 @@ void main() { ).called(1); }); - test('returns the list of hints when succeeds', () async { + test('returns the list of hints and max hints when succeeds', () async { final hint = Hint( question: 'question', response: HintResponse.no, ); final hintList = [hint, hint, hint]; - final hintJson = {'hints': hintList.map((e) => e.toJson()).toList()}; + final hintJson = { + 'hints': hintList.map((e) => e.toJson()).toList(), + 'maxHints': 8, + }; when(() => response.statusCode).thenReturn(HttpStatus.ok); when(() => response.body).thenReturn(jsonEncode(hintJson)); - final hints = await resource.getHints(wordId: 'wordId'); + final result = await resource.getHints(wordId: 'wordId'); - expect(hints, equals(hintList)); + expect(result.$1, equals(hintList)); + expect(result.$2, equals(8)); }); test('throws ApiClientError when request fails', () async { From acce021009150a4b364ecc0b83f967cb3a8cdc93 Mon Sep 17 00:00:00 2001 From: Jaime Sanchez Date: Mon, 22 Apr 2024 12:28:51 +0200 Subject: [PATCH 4/8] feat: update hint bloc to handle max hints --- lib/hint/bloc/hint_bloc.dart | 15 ++++- lib/hint/bloc/hint_state.dart | 8 ++- test/hint/bloc/hint_bloc_test.dart | 95 ++++++++++++++++++----------- test/hint/bloc/hint_state_test.dart | 8 +++ 4 files changed, 86 insertions(+), 40 deletions(-) diff --git a/lib/hint/bloc/hint_bloc.dart b/lib/hint/bloc/hint_bloc.dart index 962c86c5a..6322dc7e7 100644 --- a/lib/hint/bloc/hint_bloc.dart +++ b/lib/hint/bloc/hint_bloc.dart @@ -37,9 +37,11 @@ class HintBloc extends Bloc { HintRequested event, Emitter emit, ) async { + if (state.hintsLeft <= 0) return; + emit(state.copyWith(status: HintStatus.thinking)); - final hint = await _hintResource.generateHint( + final (hint, maxHints) = await _hintResource.generateHint( wordId: event.wordId, question: event.question, ); @@ -49,6 +51,7 @@ class HintBloc extends Bloc { state.copyWith( status: HintStatus.answered, hints: allHints, + maxHints: maxHints, ), ); } @@ -58,8 +61,14 @@ class HintBloc extends Bloc { Emitter emit, ) async { if (state.hints.isEmpty) { - final hints = await _hintResource.getHints(wordId: event.wordId); - emit(state.copyWith(hints: hints)); + final (hints, maxHints) = + await _hintResource.getHints(wordId: event.wordId); + emit( + state.copyWith( + hints: hints, + maxHints: maxHints, + ), + ); } } } diff --git a/lib/hint/bloc/hint_state.dart b/lib/hint/bloc/hint_state.dart index 6923967a0..27ad3e7d5 100644 --- a/lib/hint/bloc/hint_state.dart +++ b/lib/hint/bloc/hint_state.dart @@ -23,18 +23,22 @@ class HintState extends Equatable { const HintState({ this.status = HintStatus.initial, this.hints = const [], + this.maxHints = 10, }); final HintStatus status; final List hints; + final int maxHints; HintState copyWith({ HintStatus? status, List? hints, + int? maxHints, }) { return HintState( status: status ?? this.status, hints: hints ?? this.hints, + maxHints: maxHints ?? this.maxHints, ); } @@ -43,6 +47,8 @@ class HintState extends Equatable { status == HintStatus.thinking || status == HintStatus.invalid; + int get hintsLeft => maxHints - hints.length; + @override - List get props => [status, hints]; + List get props => [status, hints, maxHints]; } diff --git a/test/hint/bloc/hint_bloc_test.dart b/test/hint/bloc/hint_bloc_test.dart index 1eb402b9f..b08c27eb4 100644 --- a/test/hint/bloc/hint_bloc_test.dart +++ b/test/hint/bloc/hint_bloc_test.dart @@ -39,45 +39,65 @@ void main() { ], ); - blocTest( - 'emits state with status ${HintStatus.thinking} immediately and ' - '${HintStatus.answered} after when HintRequested is added', - setUp: () { - when( - () => hintResource.generateHint(wordId: 'id', question: 'blue?'), - ).thenAnswer( - (_) async => Hint(question: 'blue?', response: HintResponse.no), - ); - }, - seed: () => HintState( - status: HintStatus.asking, - hints: [ - Hint(question: 'is it orange?', response: HintResponse.no), - ], - ), - build: () => HintBloc(hintResource: hintResource), - act: (bloc) => bloc.add( - HintRequested(wordId: 'id', question: 'blue?'), - ), - expect: () => const [ - HintState( - status: HintStatus.thinking, + group('adding HintRequested', () { + blocTest( + 'emits state with status ${HintStatus.thinking} immediately and ' + '${HintStatus.answered} after', + setUp: () { + when( + () => hintResource.generateHint(wordId: 'id', question: 'blue?'), + ).thenAnswer( + (_) async => + (Hint(question: 'blue?', response: HintResponse.no), 9), + ); + }, + seed: () => HintState( + status: HintStatus.asking, hints: [ Hint(question: 'is it orange?', response: HintResponse.no), ], ), - HintState( - status: HintStatus.answered, + build: () => HintBloc(hintResource: hintResource), + act: (bloc) => bloc.add( + HintRequested(wordId: 'id', question: 'blue?'), + ), + expect: () => const [ + HintState( + status: HintStatus.thinking, + hints: [ + Hint(question: 'is it orange?', response: HintResponse.no), + ], + ), + HintState( + status: HintStatus.answered, + hints: [ + Hint(question: 'is it orange?', response: HintResponse.no), + Hint(question: 'blue?', response: HintResponse.no), + ], + maxHints: 9, + ), + ], + ); + + blocTest( + 'does not emit state if there are no hints left', + seed: () => HintState( + status: HintStatus.asking, hints: [ Hint(question: 'is it orange?', response: HintResponse.no), - Hint(question: 'blue?', response: HintResponse.no), ], + maxHints: 1, ), - ], - ); + build: () => HintBloc(hintResource: hintResource), + act: (bloc) => bloc.add( + HintRequested(wordId: 'id', question: 'blue?'), + ), + expect: () => const [], + ); + }); }); - group('PreviousHintsRequested', () { + group('adding PreviousHintsRequested', () { late HintResource hintResource; setUp(() { @@ -85,13 +105,16 @@ void main() { }); blocTest( - 'emits state with hints when PreviousHintsRequested is added', + 'emits state with hints', setUp: () { when(() => hintResource.getHints(wordId: 'id')).thenAnswer( - (_) async => [ - Hint(question: 'is it orange?', response: HintResponse.no), - Hint(question: 'is it blue?', response: HintResponse.yes), - ], + (_) async => ( + [ + Hint(question: 'is it orange?', response: HintResponse.no), + Hint(question: 'is it blue?', response: HintResponse.yes), + ], + 8 + ), ); }, build: () => HintBloc(hintResource: hintResource), @@ -102,13 +125,13 @@ void main() { Hint(question: 'is it orange?', response: HintResponse.no), Hint(question: 'is it blue?', response: HintResponse.yes), ], + maxHints: 8, ), ], ); blocTest( - 'does not emit state when PreviousHintsRequested is added and hints ' - 'are already present', + 'does not emit state when hints are already present', seed: () => HintState( hints: [ Hint(question: 'is it orange?', response: HintResponse.no), diff --git a/test/hint/bloc/hint_state_test.dart b/test/hint/bloc/hint_state_test.dart index a6a87e53d..af15aef15 100644 --- a/test/hint/bloc/hint_state_test.dart +++ b/test/hint/bloc/hint_state_test.dart @@ -38,6 +38,14 @@ void main() { equals(HintState(status: HintStatus.asking, hints: [hint, hint])), ); }); + + test('returns object with updated maxHints when maxHints is passed', () { + final state = HintState(status: HintStatus.asking); + expect( + state.copyWith(maxHints: 5), + equals(HintState(status: HintStatus.asking, maxHints: 5)), + ); + }); }); }); } From a0606bccbed95ad7af793b686888ba24f7fa3d42 Mon Sep 17 00:00:00 2001 From: Jaime Sanchez Date: Mon, 22 Apr 2024 12:44:01 +0200 Subject: [PATCH 5/8] feat: disable hint button when there are no hints remaining --- lib/hint/widgets/gemini_hint_button.dart | 9 ++-- .../hint/widgets/gemini_hint_button_test.dart | 50 +++++++++++++++---- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/lib/hint/widgets/gemini_hint_button.dart b/lib/hint/widgets/gemini_hint_button.dart index 3cf94d529..1f1f61d37 100644 --- a/lib/hint/widgets/gemini_hint_button.dart +++ b/lib/hint/widgets/gemini_hint_button.dart @@ -11,13 +11,16 @@ class GeminiHintButton extends StatelessWidget { Widget build(BuildContext context) { final l10n = context.l10n; + final isOutOfHints = + context.select((HintBloc bloc) => bloc.state.hintsLeft <= 0); + return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 128), child: OutlinedButton.icon( style: IoCrosswordTheme.geminiOutlinedButtonThemeData.style, - onPressed: () { - context.read().add(const HintModeEntered()); - }, + onPressed: isOutOfHints + ? null + : () => context.read().add(const HintModeEntered()), icon: const GeminiIcon(), label: GeminiGradient( child: Text(l10n.hint), diff --git a/test/hint/widgets/gemini_hint_button_test.dart b/test/hint/widgets/gemini_hint_button_test.dart index 9bdaca18e..1ed7e2580 100644 --- a/test/hint/widgets/gemini_hint_button_test.dart +++ b/test/hint/widgets/gemini_hint_button_test.dart @@ -1,9 +1,11 @@ -// ignore_for_file: prefer_const_constructors +// ignore_for_file: prefer_const_constructors, avoid_redundant_argument_values +// ignore_for_file: prefer_const_literals_to_create_immutables import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:game_domain/game_domain.dart'; import 'package:io_crossword/hint/hint.dart'; import 'package:io_crossword/l10n/l10n.dart'; import 'package:io_crossword_ui/io_crossword_ui.dart'; @@ -25,6 +27,7 @@ void main() { setUp(() { hintBloc = _MockHintBloc(); + when(() => hintBloc.state).thenReturn(const HintState()); }); testWidgets('displays hint text', (tester) async { @@ -39,17 +42,42 @@ void main() { expect(find.byIcon(IoIcons.gemini), findsOneWidget); }); - testWidgets('emits HintModeEntered when tapped', (tester) async { - await tester.pumpApp( - BlocProvider.value( - value: hintBloc, - child: GeminiHintButton(), - ), - ); + testWidgets( + 'emits HintModeEntered when tapped if there are hints left', + (tester) async { + when(() => hintBloc.state).thenReturn( + HintState(hints: [], maxHints: 3), + ); + await tester.pumpApp( + BlocProvider.value( + value: hintBloc, + child: GeminiHintButton(), + ), + ); - await tester.tap(find.byType(GeminiHintButton)); + await tester.tap(find.byType(GeminiHintButton)); - verify(() => hintBloc.add(const HintModeEntered())).called(1); - }); + verify(() => hintBloc.add(const HintModeEntered())).called(1); + }, + ); + + testWidgets( + 'is disabled when there are no hints left', + (tester) async { + final hint = Hint(question: 'is it orange?', response: HintResponse.no); + when(() => hintBloc.state).thenReturn( + HintState(hints: [hint, hint, hint], maxHints: 3), + ); + await tester.pumpApp( + BlocProvider.value( + value: hintBloc, + child: GeminiHintButton(), + ), + ); + await tester.tap(find.byType(GeminiHintButton)); + + verifyNever(() => hintBloc.add(const HintModeEntered())); + }, + ); }); } From 12b1de53ac7c1258d73f4571f20d560ed0576068 Mon Sep 17 00:00:00 2001 From: Jaime Sanchez Date: Mon, 22 Apr 2024 12:52:50 +0200 Subject: [PATCH 6/8] feat: add max hints handling --- lib/hint/widgets/hints_section.dart | 38 ++++++++++---- .../view/word_solving_view.dart | 17 +++++-- test/hint/widgets/hints_section_test.dart | 50 ++++++++++++++++++- .../view/word_selection_page_test.dart | 2 +- 4 files changed, 90 insertions(+), 17 deletions(-) diff --git a/lib/hint/widgets/hints_section.dart b/lib/hint/widgets/hints_section.dart index 505056823..ae34b7fe8 100644 --- a/lib/hint/widgets/hints_section.dart +++ b/lib/hint/widgets/hints_section.dart @@ -6,8 +6,8 @@ import 'package:io_crossword/hint/hint.dart'; import 'package:io_crossword/l10n/l10n.dart'; import 'package:io_crossword_ui/io_crossword_ui.dart'; -class HintsSection extends StatelessWidget { - const HintsSection({super.key}); +class HintsTitle extends StatelessWidget { + const HintsTitle({super.key}); @override Widget build(BuildContext context) { @@ -15,20 +15,35 @@ class HintsSection extends StatelessWidget { final hintState = context.watch().state; final isHintModeActive = hintState.isHintModeActive; - final isThinking = hintState.status == HintStatus.thinking; - final allHints = hintState.hints; + final isOutOfHints = hintState.hintsLeft <= 0; + + var title = l10n.askGeminiHint; + if (isOutOfHints) { + title = l10n.runOutOfHints; + } else if (isHintModeActive) { + if (hintState.hints.isEmpty) { + title = l10n.askYesOrNoQuestion; + } else { + title = l10n.hintsRemaining(hintState.hintsLeft, hintState.maxHints); + } + } + return HintText(text: title); + } +} + +class HintsSection extends StatelessWidget { + const HintsSection({super.key}); + + @override + Widget build(BuildContext context) { + final isThinking = context + .select((HintBloc bloc) => bloc.state.status == HintStatus.thinking); + final allHints = context.select((HintBloc bloc) => bloc.state.hints); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Center( - child: HintText( - text: - isHintModeActive ? l10n.askYesOrNoQuestion : l10n.askGeminiHint, - ), - ), - const SizedBox(height: 32), ...allHints.mapIndexed( (i, hint) => HintQuestionResponse( index: i, @@ -38,6 +53,7 @@ class HintsSection extends StatelessWidget { if (isThinking) ...[ const SizedBox(height: 24), const Center(child: HintLoadingIndicator()), + const SizedBox(height: 8), ], ], ); diff --git a/lib/word_selection/view/word_solving_view.dart b/lib/word_selection/view/word_solving_view.dart index d3a89b04d..2f5c86fe9 100644 --- a/lib/word_selection/view/word_solving_view.dart +++ b/lib/word_selection/view/word_solving_view.dart @@ -45,6 +45,8 @@ class WordSolvingLargeView extends StatelessWidget { textAlign: TextAlign.center, ), const SizedBox(height: 32), + const HintsTitle(), + const SizedBox(height: 32), Flexible( child: BlocSelector( @@ -54,14 +56,17 @@ class WordSolvingLargeView extends StatelessWidget { return const CircularProgressIndicator(); } - return const SingleChildScrollView(child: HintsSection()); + return const SingleChildScrollView( + reverse: true, + child: HintsSection(), + ); }, ), ), ], ), ), - const SizedBox(height: 8), + const SizedBox(height: 16), const BottomPanel(), ], ); @@ -107,6 +112,8 @@ class _WordSolvingSmallViewState extends State { textAlign: TextAlign.center, ), const SizedBox(height: 32), + const HintsTitle(), + const SizedBox(height: 32), Expanded( child: BlocSelector( @@ -116,10 +123,14 @@ class _WordSolvingSmallViewState extends State { return const Center(child: CircularProgressIndicator()); } - return const SingleChildScrollView(child: HintsSection()); + return const SingleChildScrollView( + reverse: true, + child: HintsSection(), + ); }, ), ), + const SizedBox(height: 16), BottomPanel(controller: _controller), ], ); diff --git a/test/hint/widgets/hints_section_test.dart b/test/hint/widgets/hints_section_test.dart index a0bbf4f9d..f66b8856a 100644 --- a/test/hint/widgets/hints_section_test.dart +++ b/test/hint/widgets/hints_section_test.dart @@ -26,13 +26,13 @@ void main() { hintBloc = _MockHintBloc(); }); - group('$HintsSection', () { + group('$HintsTitle', () { late Widget widget; setUp(() { widget = BlocProvider( create: (context) => hintBloc, - child: HintsSection(), + child: HintsTitle(), ); }); @@ -48,6 +48,23 @@ void main() { }, ); + testWidgets( + 'renders "run out of hints" when there are no more hints available', + (tester) async { + final hint = Hint(question: 'Q1', response: HintResponse.yes); + when(() => hintBloc.state).thenReturn( + HintState( + status: HintStatus.asking, + hints: [hint, hint, hint], + maxHints: 3, + ), + ); + await tester.pumpApp(widget); + + expect(find.text(l10n.runOutOfHints), findsOneWidget); + }, + ); + testWidgets( 'renders "ask yes or no question" when the hint mode is active', (tester) async { @@ -60,6 +77,35 @@ void main() { }, ); + testWidgets( + 'renders "1 of 2 hints remaining" when the hint mode is active ' + 'and there are hints available', + (tester) async { + final hint = Hint(question: 'Q1', response: HintResponse.yes); + when(() => hintBloc.state).thenReturn( + HintState( + status: HintStatus.asking, + hints: [hint, hint], + maxHints: 3, + ), + ); + await tester.pumpApp(widget); + + expect(find.text(l10n.hintsRemaining(1, 3)), findsOneWidget); + }, + ); + }); + + group('$HintsSection', () { + late Widget widget; + + setUp(() { + widget = BlocProvider( + create: (context) => hintBloc, + child: HintsSection(), + ); + }); + testWidgets( 'renders as many $HintQuestionResponse widgets as hints are available', (tester) async { diff --git a/test/word_focused/view/word_selection_page_test.dart b/test/word_focused/view/word_selection_page_test.dart index 122232e2f..9fee78015 100644 --- a/test/word_focused/view/word_selection_page_test.dart +++ b/test/word_focused/view/word_selection_page_test.dart @@ -70,7 +70,7 @@ void main() { (tester) async { final hintResource = _MockHintResource(); when(() => hintResource.getHints(wordId: any(named: 'wordId'))) - .thenAnswer((_) async => []); + .thenAnswer((_) async => ([], 4)); when(() => wordSelectionBloc.state).thenReturn( WordSelectionState( status: WordSelectionStatus.preSolving, From 18d1064a82088ae4f42f53b57b1d5a71258859df Mon Sep 17 00:00:00 2001 From: Jaime Sanchez Date: Mon, 22 Apr 2024 12:55:47 +0200 Subject: [PATCH 7/8] test: gemini hint button --- .../hint/widgets/gemini_hint_button_test.dart | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/test/hint/widgets/gemini_hint_button_test.dart b/test/hint/widgets/gemini_hint_button_test.dart index 1ed7e2580..3cf304d1c 100644 --- a/test/hint/widgets/gemini_hint_button_test.dart +++ b/test/hint/widgets/gemini_hint_button_test.dart @@ -20,6 +20,7 @@ void main() { group('$GeminiHintButton', () { late AppLocalizations l10n; late HintBloc hintBloc; + late Widget widget; setUpAll(() async { l10n = await AppLocalizations.delegate.load(Locale('en')); @@ -28,16 +29,20 @@ void main() { setUp(() { hintBloc = _MockHintBloc(); when(() => hintBloc.state).thenReturn(const HintState()); + widget = BlocProvider.value( + value: hintBloc, + child: GeminiHintButton(), + ); }); testWidgets('displays hint text', (tester) async { - await tester.pumpApp(GeminiHintButton()); + await tester.pumpApp(widget); expect(find.text(l10n.hint), findsOneWidget); }); testWidgets('displays gemini icon', (tester) async { - await tester.pumpApp(GeminiHintButton()); + await tester.pumpApp(widget); expect(find.byIcon(IoIcons.gemini), findsOneWidget); }); @@ -48,12 +53,7 @@ void main() { when(() => hintBloc.state).thenReturn( HintState(hints: [], maxHints: 3), ); - await tester.pumpApp( - BlocProvider.value( - value: hintBloc, - child: GeminiHintButton(), - ), - ); + await tester.pumpApp(widget); await tester.tap(find.byType(GeminiHintButton)); @@ -68,12 +68,8 @@ void main() { when(() => hintBloc.state).thenReturn( HintState(hints: [hint, hint, hint], maxHints: 3), ); - await tester.pumpApp( - BlocProvider.value( - value: hintBloc, - child: GeminiHintButton(), - ), - ); + await tester.pumpApp(widget); + await tester.tap(find.byType(GeminiHintButton)); verifyNever(() => hintBloc.add(const HintModeEntered())); From 9200cb54ee8847ed0f214a54c42f85c8222dac25 Mon Sep 17 00:00:00 2001 From: Jaime Sanchez Date: Mon, 22 Apr 2024 13:06:18 +0200 Subject: [PATCH 8/8] fix: hint resource tests --- .../test/src/resources/hint_resource_test.dart | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/api_client/test/src/resources/hint_resource_test.dart b/packages/api_client/test/src/resources/hint_resource_test.dart index 085d61f2f..041dd1ed5 100644 --- a/packages/api_client/test/src/resources/hint_resource_test.dart +++ b/packages/api_client/test/src/resources/hint_resource_test.dart @@ -38,7 +38,12 @@ void main() { response: HintResponse.no, ); when(() => response.statusCode).thenReturn(HttpStatus.ok); - when(() => response.body).thenReturn(jsonEncode(hint.toJson())); + when(() => response.body).thenReturn( + jsonEncode({ + 'hint': hint.toJson(), + 'maxHints': 4, + }), + ); await resource.generateHint( wordId: 'wordId', @@ -134,9 +139,12 @@ void main() { response: HintResponse.no, ); final hintList = [hint, hint, hint]; - final hintJson = {'hints': hintList.map((e) => e.toJson()).toList()}; + final responseJson = { + 'hints': hintList.map((e) => e.toJson()).toList(), + 'maxHints': 8, + }; when(() => response.statusCode).thenReturn(HttpStatus.ok); - when(() => response.body).thenReturn(jsonEncode(hintJson)); + when(() => response.body).thenReturn(jsonEncode(responseJson)); await resource.getHints(wordId: 'wordId');