Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: handle when max hints is reached #351

Merged
merged 10 commits into from
Apr 22, 2024
19 changes: 19 additions & 0 deletions api/packages/hint_repository/lib/src/hint_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> 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<Hint> generateHint({
Expand Down
30 changes: 30 additions & 0 deletions api/packages/hint_repository/test/src/hint_repository_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 10 additions & 4 deletions api/routes/game/hint.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> onRequest(RequestContext context) async {
if (context.request.method == HttpMethod.post) {
return _onPost(context);
Expand Down Expand Up @@ -43,7 +41,8 @@ Future<Response> _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,
Expand All @@ -62,7 +61,12 @@ Future<Response> _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(),
Expand All @@ -86,10 +90,12 @@ Future<Response> _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) {
Expand Down
13 changes: 12 additions & 1 deletion api/test/routes/game/hint_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ void main() {
wordId: 'wordId',
),
).thenAnswer((_) async => []);
when(() => hintRepository.getMaxHints()).thenAnswer((_) async => 10);
when(
() => hintRepository.generateHint(
wordAnswer: 'answer',
Expand All @@ -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(
Expand Down Expand Up @@ -147,6 +154,7 @@ void main() {
wordId: 'wordId',
),
).thenAnswer((_) async => []);
when(() => hintRepository.getMaxHints()).thenAnswer((_) async => 10);
when(
() => hintRepository.generateHint(
wordAnswer: 'answer',
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -268,6 +277,7 @@ void main() {
wordId: 'wordId',
),
).thenAnswer((_) async => hintList);
when(() => hintRepository.getMaxHints()).thenAnswer((_) async => 10);

final response = await route.onRequest(requestContext);

Expand All @@ -280,6 +290,7 @@ void main() {
{'question': 'question2', 'response': 'notApplicable'},
{'question': 'question3', 'response': 'no'},
],
'maxHints': 10,
}),
);
},
Expand Down
15 changes: 12 additions & 3 deletions lib/hint/bloc/hint_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ class HintBloc extends Bloc<HintEvent, HintState> {
HintRequested event,
Emitter<HintState> 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,
);
Expand All @@ -49,6 +51,7 @@ class HintBloc extends Bloc<HintEvent, HintState> {
state.copyWith(
status: HintStatus.answered,
hints: allHints,
maxHints: maxHints,
),
);
}
Expand All @@ -58,8 +61,14 @@ class HintBloc extends Bloc<HintEvent, HintState> {
Emitter<HintState> 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,
),
);
}
}
}
8 changes: 7 additions & 1 deletion lib/hint/bloc/hint_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hint> hints;
final int maxHints;

HintState copyWith({
HintStatus? status,
List<Hint>? hints,
int? maxHints,
}) {
return HintState(
status: status ?? this.status,
hints: hints ?? this.hints,
maxHints: maxHints ?? this.maxHints,
);
}

Expand All @@ -43,6 +47,8 @@ class HintState extends Equatable {
status == HintStatus.thinking ||
status == HintStatus.invalid;

int get hintsLeft => maxHints - hints.length;

@override
List<Object> get props => [status, hints];
List<Object> get props => [status, hints, maxHints];
}
9 changes: 6 additions & 3 deletions lib/hint/widgets/gemini_hint_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<HintBloc>().add(const HintModeEntered());
},
onPressed: isOutOfHints
? null
: () => context.read<HintBloc>().add(const HintModeEntered()),
icon: const GeminiIcon(),
label: GeminiGradient(
child: Text(l10n.hint),
Expand Down
38 changes: 27 additions & 11 deletions lib/hint/widgets/hints_section.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,44 @@ 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) {
final l10n = context.l10n;

final hintState = context.watch<HintBloc>().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,
Expand All @@ -38,6 +53,7 @@ class HintsSection extends StatelessWidget {
if (isThinking) ...[
const SizedBox(height: 24),
const Center(child: HintLoadingIndicator()),
const SizedBox(height: 8),
],
],
);
Expand Down
20 changes: 16 additions & 4 deletions lib/l10n/arb/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -229,10 +245,6 @@
"@findNewWord": {
"description": "Find new word label"
},
"points": "Points",
"@points": {
"description": "Points label"
},
"totalScore": "Total score",
"@totalScore": {
"description": "Total score label"
Expand Down
Loading
Loading