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: add hint view and logic #345

Merged
merged 10 commits into from
Apr 21, 2024
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
Loading