Merge pull request #5352 from pangeachat/5244-grammar-practice-ui-updates

5244 grammar practice UI updates
This commit is contained in:
ggurdin 2026-01-23 10:36:01 -05:00 committed by GitHub
commit c454dc152c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 343 additions and 63 deletions

View file

@ -16,12 +16,14 @@ class MorphMeaningWidget extends StatefulWidget {
final MorphFeaturesEnum feature;
final String tag;
final TextStyle? style;
final bool blankErrorFeedback;
const MorphMeaningWidget({
super.key,
required this.feature,
required this.tag,
this.style,
this.blankErrorFeedback = false,
});
@override
@ -91,12 +93,13 @@ class MorphMeaningWidgetState extends State<MorphMeaningWidget> {
);
if (result.isError) {
return L10n.of(context).meaningNotFound;
return widget.blankErrorFeedback ? '' : L10n.of(context).meaningNotFound;
}
final morph = result.result!.getFeatureByCode(widget.feature.name);
final data = morph?.getTagByCode(widget.tag);
return data?.l1Description ?? L10n.of(context).meaningNotFound;
return data?.l1Description ??
(widget.blankErrorFeedback ? '' : L10n.of(context).meaningNotFound);
}
void _toggleEditMode(bool value) => setState(() => _editMode = value);

View file

@ -17,6 +17,7 @@ import 'package:fluffychat/pangea/analytics_practice/analytics_practice_view.dar
import 'package:fluffychat/pangea/common/utils/async_state.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart';
@ -26,6 +27,16 @@ import 'package:fluffychat/pangea/toolbar/message_practice/practice_record_contr
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class SelectedMorphChoice {
final MorphFeaturesEnum feature;
final String tag;
const SelectedMorphChoice({
required this.feature,
required this.tag,
});
}
class VocabPracticeChoice {
final String choiceId;
final String choiceText;
@ -85,6 +96,9 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
final ValueNotifier<double> progressNotifier = ValueNotifier<double>(0.0);
final ValueNotifier<bool> enableChoicesNotifier = ValueNotifier<bool>(true);
final ValueNotifier<SelectedMorphChoice?> selectedMorphChoice =
ValueNotifier<SelectedMorphChoice?>(null);
final Map<String, Map<String, String>> _choiceTexts = {};
final Map<String, Map<String, String?>> _choiceEmojis = {};
@ -108,6 +122,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
activityTarget.dispose();
progressNotifier.dispose();
enableChoicesNotifier.dispose();
selectedMorphChoice.dispose();
super.dispose();
}
@ -192,8 +207,8 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
void _clearState() {
activityState.value = const AsyncState.loading();
activityTarget.value = null;
selectedMorphChoice.value = null;
enableChoicesNotifier.value = true;
progressNotifier.value = 0.0;
_queue.clear();
_choiceTexts.clear();
@ -256,6 +271,25 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
await _startSession();
}
Future<void> reloadCurrentActivity() async {
if (activityTarget.value == null) return;
try {
activityState.value = const AsyncState.loading();
selectedMorphChoice.value = null;
final req = activityTarget.value!;
final res = await _fetchActivity(req);
if (!mounted) return;
activityState.value = AsyncState.loaded(res);
_playAudio();
} catch (e) {
if (!mounted) return;
activityState.value = AsyncState.error(e);
}
}
Future<void> _completeSession() async {
_sessionLoader.value!.finishSession();
setState(() {});
@ -284,6 +318,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
await _completeSession();
} else {
activityState.value = const AsyncState.loading();
selectedMorphChoice.value = null;
final nextActivityCompleter = _queue.removeFirst();
activityTarget.value = nextActivityCompleter.request;
@ -410,6 +445,14 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
) async {
if (_currentActivity == null) return;
final activity = _currentActivity!;
// Track the selection for display
if (activity is MorphPracticeActivityModel) {
selectedMorphChoice.value = SelectedMorphChoice(
feature: activity.morphFeature,
tag: choiceContent,
);
}
final isCorrect = activity.multipleChoiceContent.isCorrect(choiceContent);
if (isCorrect) {
enableChoicesNotifier.value = false;

View file

@ -5,6 +5,7 @@ import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
import 'package:fluffychat/pangea/common/network/requests.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
@ -64,7 +65,7 @@ class AnalyticsPracticeSessionRepo {
AnalyticsActivityTarget(
target: PracticeTarget(
tokens: [entry.key],
activityType: types[targets.length],
activityType: ActivityTypeEnum.grammarCategory,
morphFeature: entry.value,
),
),
@ -243,18 +244,39 @@ class AnalyticsPracticeSessionRepo {
}
final choices = igcMatch!.match.choices!.map((c) => c.value).toList();
final choiceTokens = tokens.where(
(token) =>
token.lemma.saveVocab &&
choices.any(
(choice) => choice.contains(token.text.content),
),
);
final choiceTokens = tokens
.where(
(token) =>
token.lemma.saveVocab &&
choices.any(
(choice) => choice.contains(token.text.content),
),
)
.toList();
// Skip if no valid tokens found for this grammar error
if (choiceTokens.isEmpty) continue;
String? translation;
try {
translation = await event.requestRespresentationByL1();
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
'context': 'AnalyticsPracticeSessionRepo._fetchErrors',
'message': 'Failed to fetch translation for analytics practice',
'event_id': event.eventId,
},
);
}
if (translation == null) continue;
targets.add(
AnalyticsActivityTarget(
target: PracticeTarget(
tokens: choiceTokens.toList(),
tokens: choiceTokens,
activityType: ActivityTypeEnum.grammarError,
morphFeature: null,
),
@ -262,6 +284,7 @@ class AnalyticsPracticeSessionRepo {
choreo: choreo,
stepIndex: i,
eventID: event.eventId,
translation: translation,
),
),
);

View file

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
@ -74,8 +76,7 @@ class AnalyticsPracticeView extends StatelessWidget {
),
body: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 24.0,
horizontal: 8.0,
),
child: MaxWidthBody(
withScrolling: false,
@ -123,25 +124,36 @@ class _AnalyticsActivityView extends StatelessWidget {
),
Expanded(
child: Column(
spacing: 16.0,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
flex: 1,
child: ValueListenableBuilder(
valueListenable: controller.activityTarget,
builder: (context, target, __) => target != null
? Column(
ValueListenableBuilder(
valueListenable: controller.activityTarget,
builder: (context, target, __) => target != null
? Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 16.0,
),
child: Column(
spacing: 12.0,
children: [
Text(
target.promptText(context),
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
),
style: FluffyThemes.isColumnMode(context)
? Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
)
: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (controller.widget.type ==
ConstructTypeEnum.vocab)
@ -153,22 +165,56 @@ class _AnalyticsActivityView extends StatelessWidget {
style: const TextStyle(fontSize: 14.0),
),
],
)
: const SizedBox(),
),
),
)
: const SizedBox.shrink(),
),
Expanded(
flex: 2,
child: Center(
Flexible(
fit: FlexFit.loose,
child: SingleChildScrollView(
child: _AnalyticsPracticeCenterContent(
controller: controller,
),
),
),
Expanded(
flex: 6,
child: _ActivityChoicesWidget(controller),
),
//reserve space for grammar category morph meaning to avoid shifting, but only in those questions
AnimatedBuilder(
animation: Listenable.merge([
controller.activityState,
controller.selectedMorphChoice,
]),
builder: (context, _) {
final activityState = controller.activityState.value;
final selectedChoice = controller.selectedMorphChoice.value;
final isGrammarCategory = activityState
is AsyncLoaded<MultipleChoicePracticeActivityModel> &&
activityState.value.activityType ==
ActivityTypeEnum.grammarCategory;
if (!isGrammarCategory) {
return const SizedBox.shrink();
}
return ConstrainedBox(
constraints: const BoxConstraints(
minHeight: 80,
),
child: selectedChoice == null
? const SizedBox.shrink()
: SingleChildScrollView(
child: MorphMeaningWidget(
feature: selectedChoice.feature,
tag: selectedChoice.tag,
blankErrorFeedback: true,
),
),
);
},
),
],
),
),
@ -193,8 +239,23 @@ class _AnalyticsPracticeCenterContent extends StatelessWidget {
ActivityTypeEnum.grammarError => ValueListenableBuilder(
valueListenable: controller.activityState,
builder: (context, state, __) => switch (state) {
AsyncLoaded(value: final activity) => _ErrorBlankWidget(
activity: activity as GrammarErrorPracticeActivityModel,
AsyncLoaded(
value: final GrammarErrorPracticeActivityModel activity
) =>
Column(
mainAxisSize: MainAxisSize.min,
children: [
_ErrorBlankWidget(
activity: activity,
),
const SizedBox(height: 12),
_GrammarErrorTranslationButton(
key: ValueKey(
'${activity.eventID}_${activity.errorOffset}_${activity.errorLength}',
),
translation: activity.translation,
),
],
),
_ => const SizedBox(),
},
@ -332,7 +393,7 @@ class _ActivityChoicesWidget extends StatelessWidget {
ErrorIndicator(message: error.toString()),
const SizedBox(height: 16),
TextButton.icon(
onPressed: controller.reloadSession,
onPressed: controller.reloadCurrentActivity,
icon: const Icon(Icons.refresh),
label: Text(L10n.of(context).tryAgain),
),
@ -347,31 +408,34 @@ class _ActivityChoicesWidget extends StatelessWidget {
final cardHeight = (constrainedHeight / (choices.length + 1))
.clamp(50.0, 80.0);
return Container(
constraints: const BoxConstraints(maxHeight: 400.0),
child: ValueListenableBuilder(
valueListenable: controller.enableChoicesNotifier,
builder: (context, enabled, __) => Column(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
children: choices
.map(
(choice) => _ChoiceCard(
activity: value,
targetId:
controller.choiceTargetId(choice.choiceId),
choiceId: choice.choiceId,
onPressed: () => controller.onSelectChoice(
choice.choiceId,
),
cardHeight: cardHeight,
choiceText: choice.choiceText,
choiceEmoji: choice.choiceEmoji,
enabled: enabled,
),
)
.toList(),
),
return ValueListenableBuilder(
valueListenable: controller.enableChoicesNotifier,
builder: (context, enabled, __) => Column(
children: [
Expanded(
child: Column(
spacing: 4.0,
mainAxisAlignment: MainAxisAlignment.center,
children: choices
.map(
(choice) => _ChoiceCard(
activity: value,
targetId: controller
.choiceTargetId(choice.choiceId),
choiceId: choice.choiceId,
onPressed: () => controller.onSelectChoice(
choice.choiceId,
),
cardHeight: cardHeight,
choiceText: choice.choiceText,
choiceEmoji: choice.choiceEmoji,
enabled: enabled,
),
)
.toList(),
),
),
],
),
);
},
@ -456,6 +520,7 @@ class _ChoiceCard extends StatelessWidget {
tag: choiceText,
onPressed: onPressed,
isCorrect: isCorrect,
height: cardHeight,
enabled: enabled,
);
@ -490,3 +555,85 @@ class _ChoiceCard extends StatelessWidget {
}
}
}
class _GrammarErrorTranslationButton extends StatefulWidget {
final String translation;
const _GrammarErrorTranslationButton({
super.key,
required this.translation,
});
@override
State<_GrammarErrorTranslationButton> createState() =>
_GrammarErrorTranslationButtonState();
}
class _GrammarErrorTranslationButtonState
extends State<_GrammarErrorTranslationButton> {
bool _showTranslation = false;
void _toggleTranslation() {
if (_showTranslation) {
setState(() {
_showTranslation = false;
});
} else {
setState(() {
_showTranslation = true;
});
}
}
@override
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
onTap: _toggleTranslation,
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8.0,
children: [
if (_showTranslation)
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Color.alphaBlend(
Colors.white.withAlpha(180),
ThemeData.dark().colorScheme.primary,
),
borderRadius: BorderRadius.circular(16),
),
child: Text(
widget.translation,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize:
AppConfig.fontSizeFactor * AppConfig.messageFontSize,
),
textAlign: TextAlign.center,
),
),
),
if (!_showTranslation)
ElevatedButton(
onPressed: _toggleTranslation,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(8),
),
child: const Icon(
Icons.lightbulb_outline,
size: 20,
),
),
],
),
),
);
}
}

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_icon.dart';
/// Choice card for meaning activity with emoji, and alt text on flip
class GrammarChoiceCard extends StatelessWidget {
@ -31,6 +32,10 @@ class GrammarChoiceCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final baseTextSize =
(Theme.of(context).textTheme.titleMedium?.fontSize ?? 16) *
(height / 72.0).clamp(1.0, 1.4);
final emojiSize = baseTextSize * 1.2;
final copy = getGrammarCopy(
category: feature.name,
lemma: tag,
@ -45,7 +50,33 @@ class GrammarChoiceCard extends StatelessWidget {
isCorrect: isCorrect,
height: height,
isEnabled: enabled,
child: Text(copy),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: height * .7,
height: height,
child: Center(
child: MorphIcon(
morphFeature: feature,
morphTag: tag,
size: Size(emojiSize, emojiSize),
),
),
),
Expanded(
child: Text(
copy,
overflow: TextOverflow.ellipsis,
maxLines: 2,
textAlign: TextAlign.left,
style: TextStyle(
fontSize: baseTextSize,
),
),
),
],
),
);
}
}

View file

@ -45,6 +45,7 @@ class GrammarErrorPracticeGenerator {
errorOffset: igcMatch.offset,
errorLength: igcMatch.length,
eventID: eventID,
translation: req.grammarErrorInfo!.translation,
),
);
}

View file

@ -45,11 +45,13 @@ class GrammarErrorRequestInfo {
final ChoreoRecordModel choreo;
final int stepIndex;
final String eventID;
final String translation;
const GrammarErrorRequestInfo({
required this.choreo,
required this.stepIndex,
required this.eventID,
required this.translation,
});
Map<String, dynamic> toJson() {
@ -57,6 +59,7 @@ class GrammarErrorRequestInfo {
'choreo': choreo.toJson(),
'step_index': stepIndex,
'event_id': eventID,
'translation': translation,
};
}
@ -65,6 +68,7 @@ class GrammarErrorRequestInfo {
choreo: ChoreoRecordModel.fromJson(json['choreo']),
stepIndex: json['step_index'] as int,
eventID: json['event_id'] as String,
translation: json['translation'] as String,
);
}
}

View file

@ -187,6 +187,21 @@ sealed class PracticeActivityModel {
tokens: tokens,
matchContent: matchContent!,
);
case ActivityTypeEnum.grammarError:
assert(
multipleChoiceContent != null,
"multipleChoiceContent is null in PracticeActivityModel.fromJson for grammarError",
);
return GrammarErrorPracticeActivityModel(
langCode: langCode,
tokens: tokens,
multipleChoiceContent: multipleChoiceContent!,
text: json['text'] as String,
errorOffset: json['error_offset'] as int,
errorLength: json['error_length'] as int,
eventID: json['event_id'] as String,
translation: json['translation'] as String,
);
default:
throw ("Unsupported activity type in PracticeActivityModel.fromJson: $type");
}
@ -358,6 +373,7 @@ class GrammarErrorPracticeActivityModel
final int errorOffset;
final int errorLength;
final String eventID;
final String translation;
GrammarErrorPracticeActivityModel({
required super.tokens,
@ -367,7 +383,19 @@ class GrammarErrorPracticeActivityModel
required this.errorOffset,
required this.errorLength,
required this.eventID,
required this.translation,
});
@override
Map<String, dynamic> toJson() {
final json = super.toJson();
json['text'] = text;
json['error_offset'] = errorOffset;
json['error_length'] = errorLength;
json['event_id'] = eventID;
json['translation'] = translation;
return json;
}
}
class EmojiPracticeActivityModel extends MatchPracticeActivityModel {