fix: simplify word zoom card, make all activity buttons stateless (#1330)

* fix: simplify word zoom card, make all activity buttons stateless

* feat: make it visually clearer which activity type is selected

* fix: give message to user when no token is selected in word zoom card

* feat: don't highlight selected token or speak selected token if message has hidden word activity

* feat: added error widgets to word zoom card

* feat: added x-out badge to word zoom activity buttons, created word zoom activity button widget

* fix: sort morph activity buttons to always have POS as first option, then having unlocked buttons before locked buttons, then alphabetically
This commit is contained in:
ggurdin 2024-12-31 11:54:46 -05:00 committed by GitHub
parent f626d5fe7e
commit 09942aa47e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 439 additions and 409 deletions

View file

@ -4657,5 +4657,6 @@
"pleaseSelectALanguage": "Please select a language",
"myBaseLanguage": "My base language",
"publicProfileTitle": "Allow my profile to be found in search",
"publicProfileDesc": "By enabling this option, I confirm that I am of legal age in my country of residence"
"publicProfileDesc": "By enabling this option, I confirm that I am of legal age in my country of residence",
"clickWordsInstructions": "Click on individual words for more activities."
}

View file

@ -213,6 +213,10 @@ IconData getIconForMorphFeature(String feature) {
return Symbols.abc;
case 'pos':
return Symbols.toys_and_games;
case 'polarity':
return Icons.swap_vert;
case 'definite':
return Icons.check_circle_outline;
default:
debugger(when: kDebugMode);
return Icons.help_outline;

View file

@ -165,26 +165,38 @@ class ActivityRecordResponse {
PracticeActivityModel practiceActivity,
ConstructUseMetaData metadata,
) {
if (practiceActivity.activityType == ActivityTypeEnum.emoji) {
if (practiceActivity.targetTokens != null &&
practiceActivity.targetTokens!.isNotEmpty) {
final token = practiceActivity.targetTokens!.first;
return [
OneConstructUse(
lemma: token.lemma.text,
form: token.text.content,
constructType: ConstructTypeEnum.vocab,
useType: useType(practiceActivity.activityType),
metadata: metadata,
category: token.pos,
),
];
}
if (practiceActivity.targetTokens == null ||
practiceActivity.targetTokens!.isEmpty) {
return [];
}
if (practiceActivity.targetTokens == null) {
return [];
if (practiceActivity.activityType == ActivityTypeEnum.emoji) {
final token = practiceActivity.targetTokens!.first;
return [
OneConstructUse(
lemma: token.lemma.text,
form: token.text.content,
constructType: ConstructTypeEnum.vocab,
useType: useType(practiceActivity.activityType),
metadata: metadata,
category: token.pos,
),
];
}
if (practiceActivity.activityType == ActivityTypeEnum.morphId) {
return practiceActivity.tgtConstructs
.map(
(token) => OneConstructUse(
lemma: token.lemma,
form: practiceActivity.targetTokens!.first.text.content,
constructType: ConstructTypeEnum.morph,
useType: useType(practiceActivity.activityType),
metadata: metadata,
category: token.category,
),
)
.toList();
}
final uses = practiceActivity.targetTokens!

View file

@ -134,7 +134,9 @@ class LemmaDictionaryRepo {
/// From the cache, get a random set of cached definitions that are not for a specific lemma
static List<String> getDistractorDefinitions(
LemmaDefinitionRequest req, int count) {
LemmaDefinitionRequest req,
int count,
) {
_clearExpiredEntries();
final List<String> definitions = [];

View file

@ -19,6 +19,7 @@ import 'package:fluffychat/pangea/repo/practice/morph_activity_generator.dart';
import 'package:fluffychat/pangea/repo/practice/word_meaning_activity_generator.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:matrix/matrix.dart';
@ -119,6 +120,7 @@ class PracticeGenerationController {
Future<MessageActivityResponse> _routePracticeActivity({
required String accessToken,
required MessageActivityRequest req,
required BuildContext context,
}) async {
// some activities we'll get from the server and others we'll generate locally
switch (req.targetType) {
@ -129,7 +131,7 @@ class PracticeGenerationController {
case ActivityTypeEnum.morphId:
return _morph.get(req);
case ActivityTypeEnum.wordMeaning:
return _wordMeaning.get(req);
return _wordMeaning.get(req, context);
case ActivityTypeEnum.wordFocusListening:
case ActivityTypeEnum.hiddenWordListening:
return _fetchFromServer(
@ -142,6 +144,7 @@ class PracticeGenerationController {
Future<PracticeActivityModelResponse> getPracticeActivity(
MessageActivityRequest req,
PangeaMessageEvent event,
BuildContext context,
) async {
final int cacheKey = req.hashCode;
@ -154,6 +157,7 @@ class PracticeGenerationController {
final MessageActivityResponse res = await _routePracticeActivity(
accessToken: _pangeaController.userController.accessToken,
req: req,
context: context,
);
// TODO resolve some wierdness here whereby the activity can be null but then... it's not

View file

@ -4,10 +4,13 @@ import 'package:fluffychat/pangea/models/practice_activities.dart/message_activi
import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/repo/lemma_definition_repo.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class WordMeaningActivityGenerator {
Future<MessageActivityResponse> get(
MessageActivityRequest req,
BuildContext context,
) async {
final ConstructIdentifier lemmaId = ConstructIdentifier(
lemma: req.targetTokens[0].lemma.text,
@ -29,6 +32,11 @@ class WordMeaningActivityGenerator {
final choices =
LemmaDictionaryRepo.getDistractorDefinitions(lemmaDefReq, 3);
if (!choices.contains(res.definition)) {
choices.add(res.definition);
choices.shuffle();
}
return MessageActivityResponse(
activity: PracticeActivityModel(
tgtConstructs: [lemmaId],
@ -36,7 +44,7 @@ class WordMeaningActivityGenerator {
langCode: req.userL2,
activityType: ActivityTypeEnum.wordMeaning,
content: ActivityContent(
question: "?",
question: "${L10n.of(context).definition}?",
choices: choices,
answers: [res.definition],
spanDisplayDetails: null,

View file

@ -202,7 +202,6 @@ class MessageAudioCardState extends State<MessageAudioCard> {
)
: const CardErrorWidget(
error: "Null audio file in message_audio_card",
maxWidth: AppConfig.toolbarMinWidth,
),
),
],

View file

@ -110,11 +110,13 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
void _updateSelectedSpan(PangeaTokenText selectedSpan) {
_selectedSpan = selectedSpan;
widget.chatController.choreographer.tts.tryToSpeak(
selectedSpan.content,
context,
widget._pangeaMessageEvent?.eventId,
);
if (!(messageAnalyticsEntry?.hasHiddenWordActivity ?? false)) {
widget.chatController.choreographer.tts.tryToSpeak(
selectedSpan.content,
context,
widget._pangeaMessageEvent?.eventId,
);
}
// if a token is selected, then the toolbar should be in wordZoom mode
if (toolbarMode != MessageMode.wordZoom) {

View file

@ -67,13 +67,16 @@ class MessageTokenText extends StatelessWidget {
final hideContent =
messageAnalyticsEntry?.isTokenInHiddenWordActivity(token) ?? false;
final hasHiddenContent =
messageAnalyticsEntry?.hasHiddenWordActivity ?? false;
if (globalIndex < startIndex) {
tokenPositions.add(
TokenPosition(
start: globalIndex,
end: startIndex,
hideContent: false,
highlight: _isSelected?.call(token) ?? false,
highlight: (_isSelected?.call(token) ?? false) && !hasHiddenContent,
),
);
}
@ -84,7 +87,9 @@ class MessageTokenText extends StatelessWidget {
end: endIndex,
token: token,
hideContent: hideContent,
highlight: (_isSelected?.call(token) ?? false) && !hideContent,
highlight: (_isSelected?.call(token) ?? false) &&
!hideContent &&
!hasHiddenContent,
),
);
globalIndex = endIndex;

View file

@ -13,6 +13,7 @@ import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_ca
import 'package:fluffychat/pangea/widgets/word_zoom/word_zoom_widget.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix_api_lite/model/message_types.dart';
const double minCardHeight = 70;
@ -73,11 +74,11 @@ class MessageToolbar extends StatelessWidget {
messageEvent: pangeaMessageEvent,
);
case MessageMode.noneSelected:
return const SizedBox();
return Text(L10n.of(context).clickWordsInstructions);
case MessageMode.practiceActivity:
case MessageMode.wordZoom:
if (overlayController.selectedToken == null) {
return const SizedBox();
return Text(L10n.of(context).clickWordsInstructions);
}
return WordZoomWidget(
token: overlayController.selectedToken!,

View file

@ -9,14 +9,16 @@ class CardErrorWidget extends StatelessWidget {
final Object error;
final Choreographer? choreographer;
final int? offset;
final double? maxWidth;
final double maxWidth;
final double padding;
const CardErrorWidget({
super.key,
required this.error,
this.choreographer,
this.offset,
this.maxWidth,
this.maxWidth = 275,
this.padding = 8,
});
@override
@ -24,8 +26,8 @@ class CardErrorWidget extends StatelessWidget {
final ErrorCopy errorCopy = ErrorCopy(context, error);
return Container(
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(maxWidth: 275),
padding: EdgeInsets.all(padding),
constraints: BoxConstraints(maxWidth: maxWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@ -36,7 +38,7 @@ class CardErrorWidget extends StatelessWidget {
cursorOffset: offset,
),
),
const SizedBox(height: 12.0),
const SizedBox(height: 6.0),
Padding(
padding: const EdgeInsets.all(12),
child: Text(

View file

@ -1,36 +1,21 @@
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/word_zoom_activity_button.dart';
import 'package:flutter/material.dart';
class EmojiPracticeButton extends StatefulWidget {
class EmojiPracticeButton extends StatelessWidget {
final PangeaToken token;
final VoidCallback onPressed;
final String? emoji;
final Function(String) setEmoji;
final bool isSelected;
const EmojiPracticeButton({
required this.token,
required this.onPressed,
this.emoji,
required this.setEmoji,
this.isSelected = false,
super.key,
});
@override
EmojiPracticeButtonState createState() => EmojiPracticeButtonState();
}
class EmojiPracticeButtonState extends State<EmojiPracticeButton> {
@override
void didUpdateWidget(covariant EmojiPracticeButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.token != oldWidget.token) {
setState(() {});
}
}
bool get _shouldDoActivity => widget.token.shouldDoActivity(
bool get _shouldDoActivity => token.shouldDoActivity(
a: ActivityTypeEnum.emoji,
feature: null,
tag: null,
@ -38,26 +23,16 @@ class EmojiPracticeButtonState extends State<EmojiPracticeButton> {
@override
Widget build(BuildContext context) {
final emoji = widget.token.getEmoji();
return SizedBox(
height: 40,
width: 40,
child: _shouldDoActivity || emoji != null
? Opacity(
opacity: _shouldDoActivity ? 0.5 : 1,
child: IconButton(
onPressed: () {
widget.onPressed();
if (widget.emoji == null && emoji != null) {
widget.setEmoji(emoji);
}
},
icon: emoji == null
? const Icon(Icons.add_reaction_outlined)
: Text(emoji),
),
)
: const SizedBox.shrink(),
);
final emoji = token.getEmoji();
return _shouldDoActivity || emoji != null
? WordZoomActivityButton(
icon: emoji == null
? const Icon(Icons.add_reaction_outlined)
: Text(emoji),
isSelected: isSelected,
onPressed: onPressed,
opacity: _shouldDoActivity ? 0.5 : 1,
)
: const SizedBox(width: 40);
}
}

View file

@ -147,9 +147,21 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
// If the selected choice is correct, send the record and get the next activity
if (widget.currentActivity.content.isCorrect(value, index)) {
MatrixState.pangeaController.getAnalytics.analyticsStream.stream.first
.then((_) {
widget.practiceCardController.onActivityFinish(correctAnswer: value);
// If the activity is an emoji activity, set the emoji value
if (widget.currentActivity.activityType == ActivityTypeEnum.emoji) {
if (widget.currentActivity.targetTokens?.length != 1) {
debugger(when: kDebugMode);
} else {
widget.currentActivity.targetTokens!.first.setEmoji(value);
}
}
// The next entry in the analytics stream should be from the above putAnalytics.setState.
// So we can wait for the stream to update before calling onActivityFinish.
final streamFuture = MatrixState
.pangeaController.getAnalytics.analyticsStream.stream.first;
streamFuture.then((_) {
widget.practiceCardController.onActivityFinish();
});
}

View file

@ -18,6 +18,7 @@ import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/animations/gain_points.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart';
import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.dart';
import 'package:fluffychat/pangea/widgets/word_zoom/word_zoom_widget.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -65,6 +66,7 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
PracticeGenerationController();
PangeaController get pangeaController => MatrixState.pangeaController;
String? _error;
@override
void initState() {
@ -96,6 +98,7 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
Future<void> _fetchActivity({
ActivityQualityFeedback? activityFeedback,
}) async {
_error = null;
if (!mounted ||
!pangeaController.languageController.languagesSet ||
widget.overlayController.messageAnalyticsEntry == null) {
@ -134,6 +137,7 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
},
);
debugger(when: kDebugMode);
_error = e.toString();
} finally {
_updateFetchingActivity(false);
}
@ -152,14 +156,18 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
final existingActivity =
widget.pangeaMessageEvent.practiceActivities.firstWhereOrNull(
(activity) {
final sameActivity = activity.practiceActivity.targetTokens != null &&
activity.practiceActivity.activityType == type &&
activity.practiceActivity.targetTokens!
.map((t) => t.vocabConstructID.string)
.toSet()
.containsAll(
tokens.map((t) => t.vocabConstructID.string).toSet(),
);
final sameActivity =
activity.practiceActivity.content.choices.toSet().containsAll(
activity.practiceActivity.content.answers.toSet(),
) &&
activity.practiceActivity.targetTokens != null &&
activity.practiceActivity.activityType == type &&
activity.practiceActivity.targetTokens!
.map((t) => t.vocabConstructID.string)
.toSet()
.containsAll(
tokens.map((t) => t.vocabConstructID.string).toSet(),
);
if (type != ActivityTypeEnum.morphId || sameActivity == false) {
return sameActivity;
}
@ -198,6 +206,7 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
await practiceGenerationController.getPracticeActivity(
req,
widget.pangeaMessageEvent,
context,
);
if (activityResponse.activity == null) return null;
@ -213,12 +222,14 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
timeStamp: DateTime.now(),
);
final Duration _savorTheJoyDuration = const Duration(seconds: 1);
Future<void> _savorTheJoy() async {
try {
debugger(when: savoringTheJoy && kDebugMode);
if (mounted) setState(() => savoringTheJoy = true);
await Future.delayed(const Duration(seconds: 1));
await Future.delayed(_savorTheJoyDuration);
if (mounted) setState(() => savoringTheJoy = false);
} catch (e, s) {
debugger(when: kDebugMode);
@ -238,23 +249,23 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
/// Saves the completion record and sends it to the server.
/// Fetches a new activity if there are any left to complete.
/// Exits the practice flow if there are no more activities.
void onActivityFinish({String? correctAnswer}) async {
void onActivityFinish() async {
try {
if (currentCompletionRecord == null || currentActivity == null) {
debugger(when: kDebugMode);
return;
}
widget.wordDetailsController?.onActivityFinish(
savorTheJoyDuration: _savorTheJoyDuration,
);
widget.overlayController.onActivityFinish();
pangeaController.activityRecordController.completeActivity(
widget.pangeaMessageEvent.eventId,
);
await _savorTheJoy();
widget.wordDetailsController?.onActivityFinish(
activityType: currentActivity!.activityType,
correctAnswer: correctAnswer,
);
} catch (e, s) {
_onError();
debugger(when: kDebugMode);
@ -301,9 +312,20 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
@override
Widget build(BuildContext context) {
if (_error != null) {
return CardErrorWidget(
error: _error!,
maxWidth: 500,
);
}
if (!fetchingActivity && currentActivity == null) {
debugPrint("don't think we should be here");
debugger(when: kDebugMode);
return CardErrorWidget(
error: _error!,
maxWidth: 500,
);
}
return Stack(

View file

@ -0,0 +1,66 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class WordZoomActivityButton extends StatelessWidget {
final Widget icon;
final bool isSelected;
final VoidCallback onPressed;
final String? tooltip;
final double? opacity;
const WordZoomActivityButton({
required this.icon,
required this.isSelected,
required this.onPressed,
this.tooltip,
this.opacity,
super.key,
});
@override
Widget build(BuildContext context) {
Widget buttonContent = IconButton(
onPressed: onPressed,
icon: icon,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
style: IconButton.styleFrom(
backgroundColor: isSelected
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.25)
: null,
),
);
if (opacity != null) {
buttonContent = Opacity(
opacity: opacity!,
child: buttonContent,
);
}
if (tooltip != null) {
buttonContent = Tooltip(
message: tooltip!,
child: buttonContent,
);
}
return Badge(
offset: kIsWeb ? null : const Offset(-1, 1),
isLabelVisible: isSelected,
label: SizedBox(
height: 10,
width: 10,
child: IconButton(
onPressed: onPressed,
icon: const Icon(Icons.close, size: 10),
padding: const EdgeInsets.all(0.0),
),
),
backgroundColor:
Theme.of(context).colorScheme.primary.withValues(alpha: 0.5),
padding: const EdgeInsets.all(0),
child: buttonContent,
);
}
}

View file

@ -1,100 +1,57 @@
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/repo/lemma_definition_repo.dart';
import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class ContextualTranslationWidget extends StatefulWidget {
class ContextualTranslationWidget extends StatelessWidget {
final PangeaToken token;
final String fullText;
final String langCode;
final VoidCallback onPressed;
final String? definition;
final Function(String) setDefinition;
const ContextualTranslationWidget({
super.key,
required this.token,
required this.fullText,
required this.langCode,
required this.onPressed,
required this.setDefinition,
this.definition,
});
@override
ContextualTranslationWidgetState createState() =>
ContextualTranslationWidgetState();
}
class ContextualTranslationWidgetState
extends State<ContextualTranslationWidget> {
@override
void initState() {
super.initState();
if (widget.definition == null) {
_fetchDefinition();
}
}
@override
void didUpdateWidget(covariant ContextualTranslationWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.token != widget.token && widget.definition == null) {
_fetchDefinition();
}
}
Future<void> _fetchDefinition() async {
// final FullTextTranslationResponseModel response =
// await FullTextTranslationRepo.translate(
// accessToken: MatrixState.pangeaController.userController.accessToken,
// request: FullTextTranslationRequestModel(
// text: widget.fullText,
// tgtLang:
// MatrixState.pangeaController.languageController.userL1?.langCode ??
// LanguageKeys.defaultLanguage,
// userL2:
// MatrixState.pangeaController.languageController.userL2?.langCode ??
// LanguageKeys.defaultLanguage,
// userL1:
// MatrixState.pangeaController.languageController.userL1?.langCode ??
// LanguageKeys.defaultLanguage,
// offset: widget.token.text.offset,
// length: widget.token.text.length,
// deepL: false,
// ),
// );
// widget.setDefinition(response.bestTranslation);
Future<String> _fetchDefinition() async {
final LemmaDefinitionRequest lemmaDefReq = LemmaDefinitionRequest(
lemma: widget.token.lemma.text,
partOfSpeech: widget.token.pos,
lemma: token.lemma.text,
partOfSpeech: token.pos,
/// This assumes that the user's L2 is the language of the lemma
lemmaLang: widget.langCode,
lemmaLang: langCode,
userL1:
MatrixState.pangeaController.languageController.userL1?.langCode ??
LanguageKeys.defaultLanguage,
);
final res = await LemmaDictionaryRepo.get(lemmaDefReq);
widget.setDefinition(res.definition);
return res.definition;
}
@override
Widget build(BuildContext context) {
return Center(
child: SizedBox(
height: 60,
// child: IconButton(
// iconSize: 30,
// onPressed: widget.onPressed,
// icon: const Icon(Symbols.dictionary),
// ),
child: Text(widget.definition ?? "..."),
),
return FutureBuilder(
future: _fetchDefinition(),
builder: (context, snapshot) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: snapshot.connectionState != ConnectionState.done
? const CircularProgressIndicator()
: snapshot.hasError
? CardErrorWidget(
error: L10n.of(context).oopsSomethingWentWrong,
padding: 0,
maxWidth: 500,
)
: Text(snapshot.data ?? "..."),
),
);
},
);
}
}

View file

@ -1,35 +1,25 @@
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/word_zoom_activity_button.dart';
import 'package:flutter/material.dart';
class LemmaWidget extends StatelessWidget {
final PangeaToken token;
final VoidCallback onPressed;
final String? lemma;
final Function(String) setLemma;
final bool isSelected;
const LemmaWidget({
super.key,
required this.token,
required this.onPressed,
this.lemma,
required this.setLemma,
this.isSelected = false,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 40,
height: 40,
child: IconButton(
onPressed: () {
onPressed();
if (lemma == null) {
setLemma(token.lemma.text);
}
},
icon: Text(token.xpEmoji),
),
return WordZoomActivityButton(
icon: Text(token.xpEmoji),
isSelected: isSelected,
onPressed: onPressed,
);
}
}

View file

@ -1,9 +1,7 @@
import 'package:fluffychat/pangea/constants/morph_categories_and_labels.dart';
import 'package:fluffychat/pangea/enum/analytics/morph_categories_enum.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/repo/practice/morph_activity_generator.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/word_zoom_activity_button.dart';
import 'package:flutter/material.dart';
class ActivityMorph {
@ -22,63 +20,49 @@ class MorphologicalListWidget extends StatelessWidget {
final PangeaToken token;
final String? selectedMorphFeature;
final Function(String?) setMorphFeature;
final int completedActivities;
const MorphologicalListWidget({
super.key,
required this.selectedMorphFeature,
required this.token,
required this.setMorphFeature,
required this.completedActivities,
});
List<ActivityMorph> get _visibleMorphs {
// we always start with the part of speech
final visibleMorphs = [
ActivityMorph(
morphFeature: "pos",
morphTag: token.pos,
revealed: !token.shouldDoPosActivity,
// revealed: !shouldDoActivity || !canGenerateDistractors,
),
];
// each pos has a defined set of morphological features to display and do activities for
final List<String> seq = MorphActivityGenerator().getSequence(
MatrixState.pangeaController.languageController.userL2?.langCode,
token.pos,
);
for (final String feature in seq) {
// don't add any more if the last one is not revealed yet
if (!visibleMorphs.last.revealed) {
break;
}
// check that the feature is in token.morph
if (!token.morph.containsKey(feature)) {
ErrorHandler.logError(
m: "Morphological feature suggested for pos but not found in token",
data: {
"feature": feature,
"token": token,
"lang_code": MatrixState
.pangeaController.languageController.userL2?.langCode,
},
);
continue;
}
visibleMorphs.add(
ActivityMorph(
morphFeature: feature,
morphTag: token.morph[feature],
revealed: !token.shouldDoMorphActivity(feature),
),
final activityMorphs = token.morph.entries.map((entry) {
return ActivityMorph(
morphFeature: entry.key,
morphTag: entry.value,
revealed: !token.shouldDoMorphActivity(entry.key),
);
}
}).toList();
return visibleMorphs;
activityMorphs.sort((a, b) {
if (a.morphFeature.toLowerCase() == "pos") {
return -1;
} else if (b.morphFeature.toLowerCase() == "pos") {
return 1;
}
if (a.revealed && !b.revealed) {
return -1;
} else if (!a.revealed && b.revealed) {
return 1;
}
return a.morphFeature.compareTo(b.morphFeature);
});
final lastRevealedIndex =
activityMorphs.lastIndexWhere((morph) => morph.revealed);
if (lastRevealedIndex == -1) {
return activityMorphs;
} else if (lastRevealedIndex >= (activityMorphs.length - 1)) {
return activityMorphs;
} else {
return activityMorphs.take(lastRevealedIndex + 2).toList();
}
}
@override
@ -119,23 +103,15 @@ class MorphologicalActivityButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
Tooltip(
message: getMorphologicalCategoryCopy(
morphCategory,
context,
),
child: Opacity(
opacity: isSelected ? 1 : 0.5,
child: IconButton(
onPressed: () => onPressed(morphCategory),
icon: Icon(icon),
color: isSelected ? Theme.of(context).colorScheme.primary : null,
),
),
),
],
return WordZoomActivityButton(
icon: Icon(icon),
isSelected: isSelected,
onPressed: () => onPressed(morphCategory),
tooltip: getMorphologicalCategoryCopy(
morphCategory,
context,
),
opacity: (isSelected || !isUnlocked) ? 1 : 0.5,
);
}
}

View file

@ -17,6 +17,23 @@ import 'package:flutter/material.dart';
enum WordZoomSelection {
translation,
emoji,
lemma,
morph,
}
extension on WordZoomSelection {
ActivityTypeEnum get activityType {
switch (this) {
case WordZoomSelection.translation:
return ActivityTypeEnum.wordMeaning;
case WordZoomSelection.emoji:
return ActivityTypeEnum.emoji;
case WordZoomSelection.lemma:
return ActivityTypeEnum.lemmaId;
case WordZoomSelection.morph:
return ActivityTypeEnum.morphId;
}
}
}
class WordZoomWidget extends StatefulWidget {
@ -38,48 +55,29 @@ class WordZoomWidget extends StatefulWidget {
}
class WordZoomWidgetState extends State<WordZoomWidget> {
ActivityTypeEnum _activityType = ActivityTypeEnum.wordMeaning;
/// The currently selected word zoom activity type.
/// If an activity should be shown for this type, shows that activity.
/// If not, shows the info related to that activity type.
/// Defaults to the lemma translation.
WordZoomSelection _selectionType = WordZoomSelection.translation;
// morphological activities
/// If doing a morphological activity, this is the selected morph feature.
String? _selectedMorphFeature;
/// used to trigger a rebuild of the morph activity
/// button when a morph activity is completed
int completedMorphActivities = 0;
/// If true, the activity will be shown regardless of shouldDoActivity.
/// Used to show the practice activity card's savor the joy animation.
/// (Analytics sending triggers the point gain animation, do also
/// causes shouldDoActivity to be false. This is a workaround.)
bool _forceShowActivity = false;
// defintion activities
String? _definition;
// lemma activities
String? _lemma;
// emoji activities
String? _emoji;
// whether activity type can be generated
Map<ActivityTypeEnum, bool> canGenerateActivity = {
ActivityTypeEnum.morphId: true,
ActivityTypeEnum.wordMeaning: true,
ActivityTypeEnum.lemmaId: false,
ActivityTypeEnum.emoji: true,
};
Future<void> _initCanGenerateActivity() async {
widget.token.canGenerateDistractors(ActivityTypeEnum.lemmaId).then((value) {
if (mounted) {
setState(() {
canGenerateActivity[ActivityTypeEnum.lemmaId] = value;
});
}
});
// if learner should do translation activity then setActivityType to wordMeaning
}
// The function to determine if lemma distractors can be generated
// is computationally expensive, so we only do it once
bool canGenerateLemmaActivity = false;
@override
void initState() {
super.initState();
_initCanGenerateActivity();
_setCanGenerateLemmaActivity();
}
@override
@ -87,88 +85,81 @@ class WordZoomWidgetState extends State<WordZoomWidget> {
super.didUpdateWidget(oldWidget);
if (widget.token != oldWidget.token) {
_clean();
_initCanGenerateActivity();
_setCanGenerateLemmaActivity();
}
}
void _clean() {
if (mounted) {
setState(() {
_activityType = ActivityTypeEnum.wordMeaning;
_selectionType = WordZoomSelection.translation;
_selectedMorphFeature = null;
_definition = null;
_lemma = null;
_emoji = null;
_forceShowActivity = false;
});
}
}
void _setSelectedMorphFeature(String? feature) {
_selectedMorphFeature = _selectedMorphFeature == feature ? null : feature;
_setActivityType(
_selectedMorphFeature == null
? ActivityTypeEnum.wordMeaning
: ActivityTypeEnum.morphId,
);
void _setCanGenerateLemmaActivity() {
widget.token.canGenerateDistractors(ActivityTypeEnum.lemmaId).then((value) {
if (mounted) setState(() => canGenerateLemmaActivity = value);
});
}
void _setActivityType(ActivityTypeEnum activityType) {
if (mounted) setState(() => _activityType = activityType);
}
void _setDefinition(String definition) {
if (mounted) setState(() => _definition = definition);
}
void _setLemma(String lemma) {
if (mounted) setState(() => _lemma = lemma);
}
void _setEmoji(String emoji) {
if (mounted) setState(() => _emoji = emoji);
}
void onActivityFinish({
required ActivityTypeEnum activityType,
String? correctAnswer,
}) {
switch (activityType) {
case ActivityTypeEnum.morphId:
if (mounted) setState(() => completedMorphActivities++);
break;
case ActivityTypeEnum.wordMeaning:
if (correctAnswer == null) return;
_setDefinition(correctAnswer);
break;
case ActivityTypeEnum.lemmaId:
if (correctAnswer == null) return;
_setLemma(correctAnswer);
break;
case ActivityTypeEnum.emoji:
if (correctAnswer == null) return;
widget.token
.setEmoji(correctAnswer)
.then((_) => _setEmoji(correctAnswer));
break;
default:
break;
void _setSelectionType(WordZoomSelection type, {String? feature}) {
WordZoomSelection newSelectedType = type;
String? newSelectedFeature;
if (type != WordZoomSelection.morph) {
// if setting selectionType to non-morph activity, either set it if it's not
// already selected, or reset to it the default type
newSelectedType =
_selectionType == type ? WordZoomSelection.translation : type;
} else {
// otherwise (because there could be multiple different morph features), check
// if the feature is already selected, and if so, reset to the default type.
// if not, set the selectionType and feature
newSelectedFeature = _selectedMorphFeature == feature ? null : feature;
newSelectedType = newSelectedFeature == null
? WordZoomSelection.translation
: WordZoomSelection.morph;
}
_selectionType = newSelectedType;
_selectedMorphFeature = newSelectedFeature;
if (mounted) setState(() {});
}
void _setForceShowActivity(bool showActivity) {
if (mounted) setState(() => _forceShowActivity = showActivity);
}
/// This function should be called before overlayController.onActivityFinish to
/// prevent shouldDoActivity being set to false before _forceShowActivity is set to true.
/// This keep the completed actvity visible to the user for a short time.
void onActivityFinish({
Duration savorTheJoyDuration = const Duration(seconds: 1),
}) {
_setForceShowActivity(true);
Future.delayed(savorTheJoyDuration, () {
_setForceShowActivity(false);
});
}
Widget get _wordZoomCenterWidget {
if (widget.token.shouldDoActivity(
a: _activityType,
final showActivity = widget.token.shouldDoActivity(
a: _selectionType.activityType,
feature: _selectedMorphFeature,
tag: _selectedMorphFeature == null
? null
: widget.token.morph[_selectedMorphFeature],
) &&
canGenerateActivity[_activityType]!) {
PracticeActivityCard(
(_selectionType != WordZoomSelection.lemma || canGenerateLemmaActivity);
if (showActivity || _forceShowActivity) {
return PracticeActivityCard(
pangeaMessageEvent: widget.messageEvent,
targetTokensAndActivityType: TargetTokensAndActivityType(
tokens: [widget.token],
activityType: _activityType,
activityType: _selectionType.activityType,
),
overlayController: widget.overlayController,
morphFeature: _selectedMorphFeature,
@ -176,27 +167,26 @@ class WordZoomWidgetState extends State<WordZoomWidget> {
);
}
if (_activityType == ActivityTypeEnum.wordMeaning) {
if (_selectionType == WordZoomSelection.translation) {
return ContextualTranslationWidget(
token: widget.token,
fullText: widget.messageEvent.messageDisplayText,
langCode: widget.messageEvent.messageDisplayLangCode,
onPressed: () => _setActivityType(ActivityTypeEnum.wordMeaning),
definition: _definition,
setDefinition: _setDefinition,
);
}
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [_activityAnswer],
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [_activityAnswer],
),
);
}
Widget get _activityAnswer {
switch (_activityType) {
case ActivityTypeEnum.morphId:
switch (_selectionType) {
case WordZoomSelection.morph:
if (_selectedMorphFeature == null) {
return const Text("There should be a selected morph feature");
}
@ -207,75 +197,77 @@ class WordZoomWidgetState extends State<WordZoomWidget> {
context: context,
);
return Text(copy ?? morphTag);
case ActivityTypeEnum.wordMeaning:
return _definition != null
? Text(_definition!)
: const Text("definition is null");
case ActivityTypeEnum.lemmaId:
return _lemma != null ? Text(_lemma!) : const Text("lemma is null");
case ActivityTypeEnum.emoji:
return _emoji != null ? Text(_emoji!) : const Text("emoji is null");
default:
case WordZoomSelection.lemma:
return Text(widget.token.lemma.text);
case WordZoomSelection.emoji:
return widget.token.getEmoji() != null
? Text(widget.token.getEmoji()!)
: const Text("emoji is null");
case WordZoomSelection.translation:
return const SizedBox();
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: IntrinsicWidth(
child: ConstrainedBox(
constraints:
const BoxConstraints(minHeight: AppConfig.toolbarMinHeight),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ConstrainedBox(
constraints:
const BoxConstraints(minWidth: AppConfig.toolbarMinWidth),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: [
EmojiPracticeButton(
emoji: _emoji,
token: widget.token,
onPressed: () => _setActivityType(
_activityType == ActivityTypeEnum.emoji
? ActivityTypeEnum.wordMeaning
: ActivityTypeEnum.emoji,
),
setEmoji: _setEmoji,
),
WordTextWithAudioButton(
text: widget.token.text.content,
ttsController: widget.tts,
eventID: widget.messageEvent.eventId,
),
LemmaWidget(
token: widget.token,
onPressed: () => _setActivityType(
_activityType == ActivityTypeEnum.lemmaId
? ActivityTypeEnum.wordMeaning
: ActivityTypeEnum.lemmaId,
),
lemma: _lemma,
setLemma: _setLemma,
),
],
),
return ConstrainedBox(
constraints: const BoxConstraints(
minHeight: AppConfig.toolbarMinHeight,
maxHeight: AppConfig.toolbarMaxHeight,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: IntrinsicWidth(
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: AppConfig.toolbarMinHeight,
),
_wordZoomCenterWidget,
MorphologicalListWidget(
token: widget.token,
setMorphFeature: _setSelectedMorphFeature,
selectedMorphFeature: _selectedMorphFeature,
completedActivities: completedMorphActivities,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: AppConfig.toolbarMinWidth,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: [
EmojiPracticeButton(
token: widget.token,
onPressed: () =>
_setSelectionType(WordZoomSelection.emoji),
isSelected: _selectionType == WordZoomSelection.emoji,
),
WordTextWithAudioButton(
text: widget.token.text.content,
ttsController: widget.tts,
eventID: widget.messageEvent.eventId,
),
LemmaWidget(
token: widget.token,
onPressed: () =>
_setSelectionType(WordZoomSelection.lemma),
isSelected: _selectionType == WordZoomSelection.lemma,
),
],
),
),
_wordZoomCenterWidget,
MorphologicalListWidget(
token: widget.token,
setMorphFeature: (feature) => _setSelectionType(
WordZoomSelection.morph,
feature: feature,
),
selectedMorphFeature: _selectedMorphFeature,
),
],
),
],
),
),
),
),