From 6457a0da36ec2a555a16c1e3139d2a0c2ad7afa5 Mon Sep 17 00:00:00 2001 From: Jaime <52668514+jsgalarraga@users.noreply.github.com> Date: Tue, 23 Apr 2024 13:02:54 +0200 Subject: [PATCH] feat: add hint feature flag (#360) * feat: fetch is hints enabled flag * feat: update hint state with enabled flag * feat: hide hint feature when disabled --- lib/hint/bloc/hint_bloc.dart | 17 ++++++ lib/hint/bloc/hint_event.dart | 7 +++ lib/hint/bloc/hint_state.dart | 6 +- lib/hint/widgets/hints_section.dart | 37 ++++++++----- .../view/word_selection_page.dart | 6 +- .../view/word_solving_view.dart | 40 ++++++++------ .../lib/src/board_info_repository.dart | 26 +++++++++ packages/board_info_repository/pubspec.yaml | 1 + .../test/src/board_info_repository_test.dart | 16 ++++++ test/hint/bloc/hint_bloc_test.dart | 55 +++++++++++++++++-- test/hint/bloc/hint_event_test.dart | 6 ++ test/hint/bloc/hint_state_test.dart | 12 ++++ test/hint/widgets/hints_section_test.dart | 18 +++++- .../view/word_selection_page_test.dart | 8 +++ .../view/word_solving_view_test.dart | 12 ++-- 15 files changed, 221 insertions(+), 46 deletions(-) diff --git a/lib/hint/bloc/hint_bloc.dart b/lib/hint/bloc/hint_bloc.dart index 6322dc7e7..637e9fb22 100644 --- a/lib/hint/bloc/hint_bloc.dart +++ b/lib/hint/bloc/hint_bloc.dart @@ -1,5 +1,6 @@ import 'package:api_client/api_client.dart'; import 'package:bloc/bloc.dart'; +import 'package:board_info_repository/board_info_repository.dart'; import 'package:equatable/equatable.dart'; import 'package:game_domain/game_domain.dart'; @@ -9,8 +10,11 @@ part 'hint_state.dart'; class HintBloc extends Bloc { HintBloc({ required HintResource hintResource, + required BoardInfoRepository boardInfoRepository, }) : _hintResource = hintResource, + _boardInfoRepository = boardInfoRepository, super(const HintState()) { + on(_onHintEnabledRequested); on(_onHintModeEntered); on(_onHintModeExited); on(_onHintRequested); @@ -18,6 +22,19 @@ class HintBloc extends Bloc { } final HintResource _hintResource; + final BoardInfoRepository _boardInfoRepository; + + Future _onHintEnabledRequested( + HintEnabledRequested event, + Emitter emit, + ) async { + await emit.forEach( + _boardInfoRepository.isHintsEnabled(), + onData: (isHintsEnabled) { + return state.copyWith(isHintsEnabled: isHintsEnabled); + }, + ); + } void _onHintModeEntered( HintModeEntered event, diff --git a/lib/hint/bloc/hint_event.dart b/lib/hint/bloc/hint_event.dart index 4be4e3c0b..4e5d9631a 100644 --- a/lib/hint/bloc/hint_event.dart +++ b/lib/hint/bloc/hint_event.dart @@ -4,6 +4,13 @@ sealed class HintEvent extends Equatable { const HintEvent(); } +class HintEnabledRequested extends HintEvent { + const HintEnabledRequested(); + + @override + List get props => []; +} + class HintModeEntered extends HintEvent { const HintModeEntered(); diff --git a/lib/hint/bloc/hint_state.dart b/lib/hint/bloc/hint_state.dart index 27ad3e7d5..f1527310f 100644 --- a/lib/hint/bloc/hint_state.dart +++ b/lib/hint/bloc/hint_state.dart @@ -21,21 +21,25 @@ enum HintStatus { class HintState extends Equatable { const HintState({ + this.isHintsEnabled = false, this.status = HintStatus.initial, this.hints = const [], this.maxHints = 10, }); + final bool isHintsEnabled; final HintStatus status; final List hints; final int maxHints; HintState copyWith({ + bool? isHintsEnabled, HintStatus? status, List? hints, int? maxHints, }) { return HintState( + isHintsEnabled: isHintsEnabled ?? this.isHintsEnabled, status: status ?? this.status, hints: hints ?? this.hints, maxHints: maxHints ?? this.maxHints, @@ -50,5 +54,5 @@ class HintState extends Equatable { int get hintsLeft => maxHints - hints.length; @override - List get props => [status, hints, maxHints]; + List get props => [isHintsEnabled, status, hints, maxHints]; } diff --git a/lib/hint/widgets/hints_section.dart b/lib/hint/widgets/hints_section.dart index ae34b7fe8..292acebb1 100644 --- a/lib/hint/widgets/hints_section.dart +++ b/lib/hint/widgets/hints_section.dart @@ -39,23 +39,30 @@ class HintsSection extends StatelessWidget { 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: [ - ...allHints.mapIndexed( - (i, hint) => HintQuestionResponse( - index: i, - hint: hint, + final isHintsEnabled = + context.select((HintBloc bloc) => bloc.state.isHintsEnabled); + + if (!isHintsEnabled) return const SizedBox.shrink(); + + return SingleChildScrollView( + reverse: true, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ...allHints.mapIndexed( + (i, hint) => HintQuestionResponse( + index: i, + hint: hint, + ), ), - ), - if (isThinking) ...[ - const SizedBox(height: 24), - const Center(child: HintLoadingIndicator()), - const SizedBox(height: 8), + if (isThinking) ...[ + const SizedBox(height: 24), + const Center(child: HintLoadingIndicator()), + const SizedBox(height: 8), + ], ], - ], + ), ); } } diff --git a/lib/word_selection/view/word_selection_page.dart b/lib/word_selection/view/word_selection_page.dart index 7465442ce..61b9d6440 100644 --- a/lib/word_selection/view/word_selection_page.dart +++ b/lib/word_selection/view/word_selection_page.dart @@ -1,4 +1,5 @@ import 'package:api_client/api_client.dart'; +import 'package:board_info_repository/board_info_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:io_crossword/hint/hint.dart'; @@ -20,7 +21,10 @@ class WordSelectionPage extends StatelessWidget { key: Key(wordId), create: (context) => HintBloc( hintResource: context.read(), - )..add(PreviousHintsRequested(wordId)), + boardInfoRepository: context.read(), + ) + ..add(const HintEnabledRequested()) + ..add(PreviousHintsRequested(wordId)), child: const WordSelectionView(), ); } diff --git a/lib/word_selection/view/word_solving_view.dart b/lib/word_selection/view/word_solving_view.dart index 2f5c86fe9..b45c4de4d 100644 --- a/lib/word_selection/view/word_solving_view.dart +++ b/lib/word_selection/view/word_solving_view.dart @@ -30,6 +30,8 @@ class WordSolvingLargeView extends StatelessWidget { final selectedWord = context.select((WordSelectionBloc bloc) => bloc.state.word); if (selectedWord == null) return const SizedBox.shrink(); + final isHintsEnabled = + context.select((HintBloc bloc) => bloc.state.isHintsEnabled); return Column( children: [ @@ -45,8 +47,10 @@ class WordSolvingLargeView extends StatelessWidget { textAlign: TextAlign.center, ), const SizedBox(height: 32), - const HintsTitle(), - const SizedBox(height: 32), + if (isHintsEnabled) ...[ + const HintsTitle(), + const SizedBox(height: 32), + ], Flexible( child: BlocSelector( @@ -56,10 +60,7 @@ class WordSolvingLargeView extends StatelessWidget { return const CircularProgressIndicator(); } - return const SingleChildScrollView( - reverse: true, - child: HintsSection(), - ); + return const HintsSection(); }, ), ), @@ -96,6 +97,8 @@ class _WordSolvingSmallViewState extends State { final selectedWord = context.select((WordSelectionBloc bloc) => bloc.state.word); if (selectedWord == null) return const SizedBox.shrink(); + final isHintsEnabled = + context.select((HintBloc bloc) => bloc.state.isHintsEnabled); return Column( children: [ @@ -112,8 +115,10 @@ class _WordSolvingSmallViewState extends State { textAlign: TextAlign.center, ), const SizedBox(height: 32), - const HintsTitle(), - const SizedBox(height: 32), + if (isHintsEnabled) ...[ + const HintsTitle(), + const SizedBox(height: 32), + ], Expanded( child: BlocSelector( @@ -123,10 +128,7 @@ class _WordSolvingSmallViewState extends State { return const Center(child: CircularProgressIndicator()); } - return const SingleChildScrollView( - reverse: true, - child: HintsSection(), - ); + return const HintsSection(); }, ), ), @@ -148,8 +150,10 @@ class BottomPanel extends StatelessWidget { Widget build(BuildContext context) { final isHintModeActive = context.select((HintBloc bloc) => bloc.state.isHintModeActive); + final isHintsEnabled = + context.select((HintBloc bloc) => bloc.state.isHintsEnabled); - if (isHintModeActive) { + if (isHintModeActive && isHintsEnabled) { return const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -166,10 +170,12 @@ class BottomPanel extends StatelessWidget { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - const Flexible( - child: GeminiHintButton(), - ), - const SizedBox(width: 8), + if (isHintsEnabled) ...[ + const Flexible( + child: GeminiHintButton(), + ), + const SizedBox(width: 8), + ], Flexible( child: SubmitButton(controller: controller), ), diff --git a/packages/board_info_repository/lib/src/board_info_repository.dart b/packages/board_info_repository/lib/src/board_info_repository.dart index f106a99e1..54f88a41d 100644 --- a/packages/board_info_repository/lib/src/board_info_repository.dart +++ b/packages/board_info_repository/lib/src/board_info_repository.dart @@ -1,4 +1,5 @@ import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:rxdart/rxdart.dart'; /// {@template board_info_exception} /// An exception to throw when there is an error fetching the board info. @@ -34,6 +35,8 @@ class BoardInfoRepository { /// The [CollectionReference] for the config. late final CollectionReference> boardInfoCollection; + BehaviorSubject? _hintsEnabled; + /// Returns the total words count available in the crossword. Stream getTotalWordsCount() { try { @@ -85,4 +88,27 @@ class BoardInfoRepository { throw BoardInfoException(error, stackStrace); } } + + /// Returns the hints enabled status. + Stream isHintsEnabled() { + if (_hintsEnabled != null) return _hintsEnabled!.stream; + + _hintsEnabled = BehaviorSubject(); + + boardInfoCollection + .where('type', isEqualTo: 'is_hints_enabled') + .snapshots() + .map((snapshot) { + final docs = snapshot.docs; + // If the flag is not found, we assume it is disabled. + if (docs.isEmpty) return false; + + final isHintsEnabled = docs.first.data()['value'] as bool; + return isHintsEnabled; + }) + .listen(_hintsEnabled!.add) + .onError(_hintsEnabled!.addError); + + return _hintsEnabled!.stream; + } } diff --git a/packages/board_info_repository/pubspec.yaml b/packages/board_info_repository/pubspec.yaml index ac03aebe1..ad5d6fca6 100644 --- a/packages/board_info_repository/pubspec.yaml +++ b/packages/board_info_repository/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: cloud_firestore: ^4.16.1 flutter: sdk: flutter + rxdart: ^0.27.7 dev_dependencies: flutter_test: diff --git a/packages/board_info_repository/test/src/board_info_repository_test.dart b/packages/board_info_repository/test/src/board_info_repository_test.dart index 37516e6fd..efc6d93e8 100644 --- a/packages/board_info_repository/test/src/board_info_repository_test.dart +++ b/packages/board_info_repository/test/src/board_info_repository_test.dart @@ -130,6 +130,22 @@ void main() { ); }); }); + + group('isHintsEnabled', () { + test('returns hints enabled status from firebase', () { + final doc = _MockQueryDocumentSnapshot>(); + final query = _MockQuerySnapshot>(); + when( + () => collection.where('type', isEqualTo: 'is_hints_enabled'), + ).thenReturn(collection); + when(collection.snapshots).thenAnswer((_) => Stream.value(query)); + when(() => query.docs).thenReturn([doc]); + when(doc.data).thenReturn({'value': true}); + + final result = boardInfoRepository.isHintsEnabled(); + expect(result, emits(true)); + }); + }); }); group('BoardInfoException', () { diff --git a/test/hint/bloc/hint_bloc_test.dart b/test/hint/bloc/hint_bloc_test.dart index b08c27eb4..c168a7f04 100644 --- a/test/hint/bloc/hint_bloc_test.dart +++ b/test/hint/bloc/hint_bloc_test.dart @@ -3,6 +3,7 @@ import 'package:api_client/api_client.dart'; import 'package:bloc_test/bloc_test.dart'; +import 'package:board_info_repository/board_info_repository.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:game_domain/game_domain.dart'; import 'package:io_crossword/hint/bloc/hint_bloc.dart'; @@ -10,18 +11,43 @@ import 'package:mocktail/mocktail.dart'; class _MockHintResource extends Mock implements HintResource {} +class _MockBoardInfoRepository extends Mock implements BoardInfoRepository {} + void main() { group('$HintBloc', () { late HintResource hintResource; + late BoardInfoRepository boardInfoRepository; setUp(() { hintResource = _MockHintResource(); + boardInfoRepository = _MockBoardInfoRepository(); }); + blocTest( + 'emits state with updated isHintsEnabled when $HintEnabledRequested ' + 'is added', + setUp: () { + when(() => boardInfoRepository.isHintsEnabled()).thenAnswer( + (_) => Stream.value(true), + ); + }, + build: () => HintBloc( + hintResource: hintResource, + boardInfoRepository: boardInfoRepository, + ), + act: (bloc) => bloc.add(const HintEnabledRequested()), + expect: () => const [ + HintState(isHintsEnabled: true), + ], + ); + blocTest( 'emits state with status ${HintStatus.asking} when HintModeEntered ' 'is added', - build: () => HintBloc(hintResource: hintResource), + build: () => HintBloc( + hintResource: hintResource, + boardInfoRepository: boardInfoRepository, + ), act: (bloc) => bloc.add(const HintModeEntered()), expect: () => const [ HintState(status: HintStatus.asking), @@ -32,7 +58,10 @@ void main() { 'emits state with status ${HintStatus.initial} when HintModeExited ' 'is added', seed: () => HintState(status: HintStatus.asking), - build: () => HintBloc(hintResource: hintResource), + build: () => HintBloc( + hintResource: hintResource, + boardInfoRepository: boardInfoRepository, + ), act: (bloc) => bloc.add(const HintModeExited()), expect: () => const [ HintState(), @@ -57,7 +86,10 @@ void main() { Hint(question: 'is it orange?', response: HintResponse.no), ], ), - build: () => HintBloc(hintResource: hintResource), + build: () => HintBloc( + hintResource: hintResource, + boardInfoRepository: boardInfoRepository, + ), act: (bloc) => bloc.add( HintRequested(wordId: 'id', question: 'blue?'), ), @@ -88,7 +120,10 @@ void main() { ], maxHints: 1, ), - build: () => HintBloc(hintResource: hintResource), + build: () => HintBloc( + hintResource: hintResource, + boardInfoRepository: boardInfoRepository, + ), act: (bloc) => bloc.add( HintRequested(wordId: 'id', question: 'blue?'), ), @@ -99,9 +134,11 @@ void main() { group('adding PreviousHintsRequested', () { late HintResource hintResource; + late BoardInfoRepository boardInfoRepository; setUp(() { hintResource = _MockHintResource(); + boardInfoRepository = _MockBoardInfoRepository(); }); blocTest( @@ -117,7 +154,10 @@ void main() { ), ); }, - build: () => HintBloc(hintResource: hintResource), + build: () => HintBloc( + hintResource: hintResource, + boardInfoRepository: boardInfoRepository, + ), act: (bloc) => bloc.add(PreviousHintsRequested('id')), expect: () => const [ HintState( @@ -138,7 +178,10 @@ void main() { Hint(question: 'is it blue?', response: HintResponse.yes), ], ), - build: () => HintBloc(hintResource: hintResource), + build: () => HintBloc( + hintResource: hintResource, + boardInfoRepository: boardInfoRepository, + ), act: (bloc) => bloc.add( PreviousHintsRequested('id'), ), diff --git a/test/hint/bloc/hint_event_test.dart b/test/hint/bloc/hint_event_test.dart index b09c5ca42..5bfefecdb 100644 --- a/test/hint/bloc/hint_event_test.dart +++ b/test/hint/bloc/hint_event_test.dart @@ -4,6 +4,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:io_crossword/hint/bloc/hint_bloc.dart'; void main() { + group('$HintEnabledRequested', () { + test('supports equality', () { + expect(HintEnabledRequested(), equals(HintEnabledRequested())); + }); + }); + group('$HintModeEntered', () { test('supports equality', () { expect(HintModeEntered(), equals(HintModeEntered())); diff --git a/test/hint/bloc/hint_state_test.dart b/test/hint/bloc/hint_state_test.dart index af15aef15..c8d006b09 100644 --- a/test/hint/bloc/hint_state_test.dart +++ b/test/hint/bloc/hint_state_test.dart @@ -19,6 +19,18 @@ void main() { expect(state.copyWith(), equals(state)); }); + test( + 'returns object with updated isHintsEnabled when isHintsEnabled ' + 'is passed', + () { + final state = HintState(status: HintStatus.asking); + expect( + state.copyWith(isHintsEnabled: true), + equals(HintState(status: HintStatus.asking, isHintsEnabled: true)), + ); + }, + ); + test('returns object with updated status when status is passed', () { final state = HintState(status: HintStatus.asking); expect( diff --git a/test/hint/widgets/hints_section_test.dart b/test/hint/widgets/hints_section_test.dart index f66b8856a..eec97e6ec 100644 --- a/test/hint/widgets/hints_section_test.dart +++ b/test/hint/widgets/hints_section_test.dart @@ -106,6 +106,18 @@ void main() { ); }); + testWidgets( + 'renders a SizedBox when hints are not enabled', + (tester) async { + when(() => hintBloc.state).thenReturn( + HintState(isHintsEnabled: false), + ); + await tester.pumpApp(widget); + + expect(find.byType(SizedBox), findsOneWidget); + }, + ); + testWidgets( 'renders as many $HintQuestionResponse widgets as hints are available', (tester) async { @@ -115,7 +127,9 @@ void main() { Hint(question: 'Q3', response: HintResponse.notApplicable), Hint(question: 'Q4', response: HintResponse.no), ]; - when(() => hintBloc.state).thenReturn(HintState(hints: hints)); + when(() => hintBloc.state).thenReturn( + HintState(hints: hints, isHintsEnabled: true), + ); await tester.pumpApp(widget); expect(find.byType(HintQuestionResponse), findsNWidgets(hints.length)); @@ -126,7 +140,7 @@ void main() { 'renders a hint loading indicator when the hint status is thinking', (tester) async { when(() => hintBloc.state).thenReturn( - HintState(status: HintStatus.thinking), + HintState(status: HintStatus.thinking, isHintsEnabled: true), ); await tester.pumpApp(widget); diff --git a/test/word_focused/view/word_selection_page_test.dart b/test/word_focused/view/word_selection_page_test.dart index 9fee78015..c22a2ac90 100644 --- a/test/word_focused/view/word_selection_page_test.dart +++ b/test/word_focused/view/word_selection_page_test.dart @@ -2,6 +2,7 @@ import 'package:api_client/api_client.dart'; import 'package:bloc_test/bloc_test.dart'; +import 'package:board_info_repository/board_info_repository.dart'; import 'package:flutter/material.dart' hide Axis; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -17,6 +18,8 @@ class _MockWordSelectionBloc class _MockHintResource extends Mock implements HintResource {} +class _MockBoardInfoRepository extends Mock implements BoardInfoRepository {} + class _FakeWord extends Fake implements Word { @override int? get solvedTimestamp => null; @@ -42,10 +45,12 @@ void main() { group('renders', () { late WordSelectionBloc wordSelectionBloc; late SelectedWord selectedWord; + late BoardInfoRepository boardInfoRepository; setUp(() { wordSelectionBloc = _MockWordSelectionBloc(); selectedWord = SelectedWord(section: (0, 0), word: _FakeWord()); + boardInfoRepository = _MockBoardInfoRepository(); }); testWidgets( @@ -77,6 +82,8 @@ void main() { word: selectedWord, ), ); + when(() => boardInfoRepository.isHintsEnabled()) + .thenAnswer((_) => Stream.value(true)); await tester.pumpApp( BlocProvider( @@ -84,6 +91,7 @@ void main() { child: WordSelectionPage(), ), hintResource: hintResource, + boardInfoRepository: boardInfoRepository, ); expect(find.byType(WordSelectionView), findsOneWidget); diff --git a/test/word_focused/view/word_solving_view_test.dart b/test/word_focused/view/word_solving_view_test.dart index 2ebab3950..e7da84b38 100644 --- a/test/word_focused/view/word_solving_view_test.dart +++ b/test/word_focused/view/word_solving_view_test.dart @@ -64,7 +64,9 @@ void main() { ), ); hintBloc = _MockHintBloc(); - when(() => hintBloc.state).thenReturn(HintState()); + when(() => hintBloc.state).thenReturn( + HintState(isHintsEnabled: true), + ); widget = MultiBlocProvider( providers: [ @@ -212,7 +214,9 @@ void main() { word: selectedWord, ), ); - when(() => hintBloc.state).thenReturn(HintState()); + when(() => hintBloc.state).thenReturn( + HintState(isHintsEnabled: true), + ); }); group('renders', () { @@ -333,7 +337,7 @@ void main() { 'a $CloseHintButton and $GeminiTextField when the status is asking', (tester) async { when(() => hintBloc.state).thenReturn( - HintState(status: HintStatus.asking), + HintState(status: HintStatus.asking, isHintsEnabled: true), ); await tester.pumpApp(widget); @@ -347,7 +351,7 @@ void main() { 'a $GeminiHintButton and $SubmitButton when the status is answered', (tester) async { when(() => hintBloc.state).thenReturn( - HintState(status: HintStatus.answered), + HintState(status: HintStatus.answered, isHintsEnabled: true), ); await tester.pumpApp(widget);