Skip to content

Commit

Permalink
feat: add hint view and logic (#345)
Browse files Browse the repository at this point in the history
* feat: provide hint resource

* feat: ask for hint from ui

* test: asking for a hint

* fix: update method name

* feat: ask for hint when submitting text field

* feat: add hint responses extension

* feat: fetch previous hints

* feat: new hints section

* feat: add hint section to solving view
  • Loading branch information
jsgalarraga authored Apr 21, 2024
1 parent f8fbbf1 commit f62e03b
Show file tree
Hide file tree
Showing 25 changed files with 677 additions and 189 deletions.
5 changes: 2 additions & 3 deletions lib/app/view/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,12 @@ class App extends StatelessWidget {

@override
Widget build(BuildContext context) {
final crosswordResource = apiClient.crosswordResource;

return MultiProvider(
providers: [
Provider.value(value: apiClient.crosswordResource),
Provider.value(value: apiClient.leaderboardResource),
Provider.value(value: apiClient.hintResource),
Provider.value(value: user),
Provider.value(value: crosswordResource),
Provider.value(value: crosswordRepository),
Provider.value(value: boardInfoRepository),
Provider.value(value: leaderboardRepository),
Expand Down
36 changes: 31 additions & 5 deletions lib/hint/bloc/hint_bloc.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import 'package:api_client/api_client.dart';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:game_domain/game_domain.dart';

part 'hint_event.dart';
part 'hint_state.dart';

class HintBloc extends Bloc<HintEvent, HintState> {
HintBloc() : super(const HintState()) {
HintBloc({
required HintResource hintResource,
}) : _hintResource = hintResource,
super(const HintState()) {
on<HintModeEntered>(_onHintModeEntered);
on<HintModeExited>(_onHintModeExited);
on<HintRequested>(_onHintRequested);
on<PreviousHintsRequested>(_onPreviousHintsRequested);
}

final HintResource _hintResource;

void _onHintModeEntered(
HintModeEntered event,
Emitter<HintState> emit,
Expand All @@ -22,7 +30,7 @@ class HintBloc extends Bloc<HintEvent, HintState> {
HintModeExited event,
Emitter<HintState> emit,
) {
emit(const HintState());
emit(state.copyWith(status: HintStatus.initial));
}

Future<void> _onHintRequested(
Expand All @@ -31,9 +39,27 @@ class HintBloc extends Bloc<HintEvent, HintState> {
) async {
emit(state.copyWith(status: HintStatus.thinking));

// Simulate a delay in retrieving the hint.
await Future<void>.delayed(const Duration(seconds: 1), () {});
final hint = await _hintResource.generateHint(
wordId: event.wordId,
question: event.question,
);
final allHints = [...state.hints, hint];

emit(
state.copyWith(
status: HintStatus.answered,
hints: allHints,
),
);
}

emit(state.copyWith(status: HintStatus.answered));
Future<void> _onPreviousHintsRequested(
PreviousHintsRequested event,
Emitter<HintState> emit,
) async {
if (state.hints.isEmpty) {
final hints = await _hintResource.getHints(wordId: event.wordId);
emit(state.copyWith(hints: hints));
}
}
}
19 changes: 16 additions & 3 deletions lib/hint/bloc/hint_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,23 @@ class HintModeExited extends HintEvent {
}

class HintRequested extends HintEvent {
const HintRequested(this.message);
const HintRequested({
required this.wordId,
required this.question,
});

final String message;
final String wordId;
final String question;

@override
List<Object> get props => [message];
List<Object> get props => [wordId, question];
}

class PreviousHintsRequested extends HintEvent {
const PreviousHintsRequested(this.wordId);

final String wordId;

@override
List<Object> get props => [wordId];
}
6 changes: 5 additions & 1 deletion lib/hint/bloc/hint_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,19 @@ enum HintStatus {
class HintState extends Equatable {
const HintState({
this.status = HintStatus.initial,
this.hints = const [],
});

final HintStatus status;
final List<Hint> hints;

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

Expand All @@ -40,5 +44,5 @@ class HintState extends Equatable {
status == HintStatus.invalid;

@override
List<Object> get props => [status];
List<Object> get props => [status, hints];
}
1 change: 1 addition & 0 deletions lib/hint/extensions/extensions.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export 'hint_response_extension.dart';
35 changes: 35 additions & 0 deletions lib/hint/extensions/hint_response_extension.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import 'dart:math';

import 'package:flutter/foundation.dart';
import 'package:game_domain/game_domain.dart';

extension HintResponseExtension on HintResponse {
String get readable {
final random = Random();

switch (this) {
case HintResponse.yes:
return yesResponses[random.nextInt(yesResponses.length)];
case HintResponse.no:
return noResponses[random.nextInt(noResponses.length)];
case HintResponse.notApplicable:
return notApplicableResponses[
random.nextInt(notApplicableResponses.length)];
}
}
}

@visibleForTesting
const yesResponses = [
'Yes!',
];

@visibleForTesting
const noResponses = [
'Nope',
];

@visibleForTesting
const notApplicableResponses = [
'Try with a "Yes or No" question',
];
1 change: 1 addition & 0 deletions lib/hint/hint.dart
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export 'bloc/hint_bloc.dart';
export 'extensions/extensions.dart';
export 'widgets/widgets.dart';
42 changes: 36 additions & 6 deletions lib/hint/widgets/gemini_text_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,44 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:io_crossword/hint/hint.dart';
import 'package:io_crossword/l10n/l10n.dart';
import 'package:io_crossword/word_selection/word_selection.dart';
import 'package:io_crossword_ui/io_crossword_ui.dart';

class GeminiTextField extends StatelessWidget {
class GeminiTextField extends StatefulWidget {
const GeminiTextField({
super.key,
});

@override
State<GeminiTextField> createState() => _GeminiTextFieldState();
}

class _GeminiTextFieldState extends State<GeminiTextField> {
late TextEditingController _controller;
late FocusNode _focusNode;

@override
void initState() {
super.initState();
_focusNode = FocusNode();
_controller = TextEditingController();

WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}

void _onAskForHint(BuildContext context, String question) {
final wordId = context.read<WordSelectionBloc>().state.word?.word.id;

if (wordId == null) return;
if (question.isEmpty) return;

context.read<HintBloc>().add(
HintRequested(wordId: wordId, question: question),
);
}

@override
Widget build(BuildContext context) {
final l10n = context.l10n;
Expand All @@ -18,6 +49,8 @@ class GeminiTextField extends StatelessWidget {
inputDecorationTheme: IoCrosswordTheme.geminiInputDecorationTheme,
),
child: TextField(
focusNode: _focusNode,
controller: _controller,
decoration: InputDecoration(
hintText: l10n.type,
prefixIcon: const Padding(
Expand All @@ -28,16 +61,13 @@ class GeminiTextField extends StatelessWidget {
padding: const EdgeInsets.only(right: 8),
child: GeminiGradient(
child: IconButton(
onPressed: () {
context
.read<HintBloc>()
.add(const HintRequested('is it red?'));
},
onPressed: () => _onAskForHint(context, _controller.text),
icon: const Icon(Icons.send),
),
),
),
),
onSubmitted: (_) => _onAskForHint(context, _controller.text),
),
);
}
Expand Down
13 changes: 3 additions & 10 deletions lib/hint/widgets/hint_text.dart
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:io_crossword/hint/hint.dart';
import 'package:io_crossword/l10n/l10n.dart';
import 'package:io_crossword_ui/io_crossword_ui.dart';

class HintText extends StatelessWidget {
const HintText({super.key});
const HintText({required this.text, super.key});

final String text;

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

final isHintModeActive =
context.select((HintBloc bloc) => bloc.state.isHintModeActive);
final text =
isHintModeActive ? l10n.askYesOrNoQuestion : l10n.askGeminiHint;

return Row(
mainAxisSize: MainAxisSize.min,
Expand Down
118 changes: 118 additions & 0 deletions lib/hint/widgets/hints_section.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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';

class HintsSection extends StatelessWidget {
const HintsSection({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;

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,
hint: hint,
),
),
if (isThinking) ...[
const SizedBox(height: 24),
const Center(child: HintLoadingIndicator()),
],
],
);
}
}

@visibleForTesting
class HintQuestionResponse extends StatelessWidget {
@visibleForTesting
const HintQuestionResponse({
required this.index,
required this.hint,
super.key,
});

final int index;
final Hint hint;

@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final questionNumber = index + 1;

return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Q$questionNumber: ${hint.question}',
style: textTheme.bodySmall?.copyWith(
color: IoCrosswordColors.softGray,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
HintText(text: hint.response.readable),
const SizedBox(height: 8),
],
);
}
}

@visibleForTesting
class HintLoadingIndicator extends StatefulWidget {
@visibleForTesting
const HintLoadingIndicator({super.key});

@override
State<HintLoadingIndicator> createState() => _HintLoadingIndicatorState();
}

class _HintLoadingIndicatorState extends State<HintLoadingIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;

@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 3),
vsync: this,
)..repeat();
}

@override
Widget build(BuildContext context) {
return RotationTransition(
turns: Tween<double>(begin: 0, end: 1).animate(_controller),
child: const GeminiIcon(size: 24),
);
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
Loading

0 comments on commit f62e03b

Please sign in to comment.