1179 toolbar changes (#1209)

* updated toolbar buttons

* initial work for toolbar updates

* Add WordZoomWidget to display word and lemma information (#1214)

---

For more details, open the [Copilot Workspace session](https://copilot-workspace.githubnext.com/pangeachat/client/tree/1179-toolbar-changes?shareId=XXXX-XXXX-XXXX-XXXX).

* word zoom card prototyped, activity generation in progress

* adding copy for new construct uses

* laying down TODOs

* initial work for word zoom card

* Always add part of speech to token's morph list

* Prevent duplicate choices in lemma activity

* Don't play token audio at start of morph activity

* Only grant +1 points for emoji activity

* Uncomment tryToSpeak function

* Always update activity once complete

* Added queuing / UI logic for morph activity buttons

* code cleanup

* added required data argument to logError calls

* fix overflowing practice activity card and audio player on mobile

---------

Co-authored-by: wcjord <32568597+wcjord@users.noreply.github.com>
This commit is contained in:
ggurdin 2024-12-27 12:42:49 -05:00 committed by GitHub
parent 0d477ad5b4
commit 1317989db0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 2598 additions and 1100 deletions

View file

@ -4615,6 +4615,13 @@
"constructUseCorHWLDesc": "Correct in hidden word activity",
"constructUseIncHWLDesc": "Incorrect in hidden word activity",
"constructUseIgnHWLDesc": "Ignored in hidden word activity",
"constructUseCorLDesc": "Correct in lemma activity",
"constructUseIncLDesc": "Incorrect in lemma activity",
"constructUseIgnLDesc": "Ignored in lemma activity",
"constructUseCorMDesc": "Correct in grammar activity",
"constructUseIncMDesc": "Incorrect in grammar activity",
"constructUseIgnMDesc": "Ignored in grammar activity",
"constructUseEmojiDesc": "Correct in emoji activity",
"constructUseNanDesc": "Not applicable",
"xpIntoLevel": "{currentXP} / {maxXP} XP",
"@xpIntoLevel": {

View file

@ -2,6 +2,7 @@ import 'package:animations/animations.dart';
import 'package:fluffychat/pangea/choreographer/widgets/send_button.dart';
import 'package:fluffychat/pangea/choreographer/widgets/start_igc_button.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/pangea_reaction_picker.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/avatar.dart';
@ -19,13 +20,13 @@ import 'input_bar.dart';
class ChatInputRow extends StatelessWidget {
final ChatController controller;
// #Pangea
final bool isOverlay;
final MessageOverlayController? overlayController;
// Pangea#
const ChatInputRow(
this.controller, {
// #Pangea
this.isOverlay = false,
this.overlayController,
// Pangea#
super.key,
});
@ -68,7 +69,7 @@ class ChatInputRow extends StatelessWidget {
CompositedTransformTarget(
link: controller.choreographer.inputLayerLinkAndKey.link,
child: Row(
key: isOverlay
key: overlayController != null
? null
: controller.choreographer.inputLayerLinkAndKey.key,
// crossAxisAlignment: CrossAxisAlignment.end,
@ -154,7 +155,7 @@ class ChatInputRow extends StatelessWidget {
// Pangea#
: const SizedBox.shrink(),
// #Pangea
PangeaReactionsPicker(controller),
PangeaReactionsPicker(controller, overlayController),
if (controller.selectedEvents.length == 1 &&
!controller.selectedEvents.first
.getDisplayEvent(controller.timeline!)

View file

@ -3,7 +3,6 @@ import 'dart:developer';
import 'dart:io';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart';
import 'package:fluffychat/utils/error_reporter.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart';
@ -27,6 +26,7 @@ class AudioPlayerWidget extends StatefulWidget {
final PangeaAudioFile? matrixFile;
final bool autoplay;
final Function(bool)? setIsPlayingAudio;
final double padding;
// Pangea#
static String? currentId;
@ -48,6 +48,7 @@ class AudioPlayerWidget extends StatefulWidget {
this.sectionStartMS,
this.sectionEndMS,
this.setIsPlayingAudio,
this.padding = 12.0,
// Pangea#
super.key,
});
@ -354,15 +355,18 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
return Padding(
// #Pangea
// padding: const EdgeInsets.all(12.0),
padding: const EdgeInsets.all(5.0),
padding: EdgeInsets.all(widget.padding),
// Pangea#
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ConstrainedBox(
constraints:
const BoxConstraints(maxWidth: FluffyThemes.columnWidth),
// #Pangea
// constraints:
// const BoxConstraints(maxWidth: FluffyThemes.columnWidth),
constraints: const BoxConstraints(maxWidth: 250),
// Pangea#
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
@ -393,7 +397,9 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
),
),
),
const SizedBox(width: 8),
// #Pangea
// const SizedBox(width: 8),
// Pangea#
Expanded(
child: Stack(
children: [
@ -410,9 +416,14 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
height: 32,
alignment: Alignment.center,
child: Container(
margin: const EdgeInsets.symmetric(
horizontal: 1,
// #Pangea
// margin: const EdgeInsets.symmetric(
// horizontal: 1,
// ),
margin: const EdgeInsets.only(
right: 0.5,
),
// Pangea#
decoration: BoxDecoration(
color: i < wavePosition
? widget.color
@ -453,7 +464,6 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
),
// #Pangea
// const SizedBox(width: 8),
const SizedBox(width: 5),
// SizedBox(
// width: 36,
// child:

View file

@ -34,6 +34,8 @@ class ChoicesArray extends StatefulWidget {
/// some uses of this widget want to disable clicking of the choices
final bool isActive;
final String Function(String)? getDisplayCopy;
const ChoicesArray({
super.key,
required this.isLoading,
@ -46,6 +48,7 @@ class ChoicesArray extends StatefulWidget {
this.enableAudio = true,
this.isActive = true,
this.onLongPress,
this.getDisplayCopy,
this.id,
});
@ -103,6 +106,7 @@ class ChoicesArrayState extends State<ChoicesArray> {
disableInteraction: disableInteraction,
isSelected: widget.selectedChoiceIndex == index,
id: widget.id,
getDisplayCopy: widget.getDisplayCopy,
),
)
.toList(),
@ -134,6 +138,7 @@ class ChoiceItem extends StatelessWidget {
required this.enableInteraction,
required this.disableInteraction,
required this.id,
this.getDisplayCopy,
});
final MapEntry<int, Choice> entry;
@ -145,6 +150,7 @@ class ChoiceItem extends StatelessWidget {
final VoidCallback enableInteraction;
final VoidCallback disableInteraction;
final String? id;
final String Function(String)? getDisplayCopy;
@override
Widget build(BuildContext context) {
@ -201,7 +207,9 @@ class ChoiceItem extends StatelessWidget {
? null
: () => onPressed(entry.value.text, entry.key),
child: Text(
entry.value.text,
getDisplayCopy != null
? getDisplayCopy!(entry.value.text)
: entry.value.text,
style: BotStyle.text(context),
),
),

View file

@ -137,4 +137,5 @@ class ModelKey {
static const String latestVersion = "latest_version";
static const String latestBuildNumber = "latest_build_number";
static const String mandatoryUpdate = "mandatory_update";
static const String emoji = "emoji";
}

View file

@ -8,6 +8,7 @@ class PangeaEventTypes {
// static const studentAnalyticsSummary = "pangea.usranalytics";
static const summaryAnalytics = "pangea.summaryAnalytics";
static const construct = "pangea.construct";
static const userChosenEmoji = "p.emoji";
static const translation = "pangea.translation";
static const tokens = "pangea.tokens";

View file

@ -69,7 +69,9 @@ class MessageAnalyticsEntry {
late final bool _includeHiddenWordActivities;
late final List<TargetTokensAndActivityType> _activityQueue;
final List<TargetTokensAndActivityType> _activityQueue = [];
final int _maxQueueLength = 3;
MessageAnalyticsEntry({
required List<PangeaToken> tokens,
@ -77,108 +79,77 @@ class MessageAnalyticsEntry {
}) {
_tokens = tokens;
_includeHiddenWordActivities = includeHiddenWordActivities;
_activityQueue = setActivityQueue();
setActivityQueue();
}
void _pushQueue(TargetTokensAndActivityType entry) {
if (nextActivity?.activityType == ActivityTypeEnum.hiddenWordListening) {
if (entry.activityType == ActivityTypeEnum.hiddenWordListening) {
_activityQueue[0] = entry;
} else {
_activityQueue.insert(1, entry);
}
} else {
_activityQueue.insert(0, entry);
}
if (_activityQueue.length > _maxQueueLength) {
_activityQueue.removeRange(
_maxQueueLength,
_activityQueue.length,
);
}
}
void _popQueue() {
if (hasHiddenWordActivity) {
_activityQueue.removeAt(0);
}
}
void _filterQueue(ActivityTypeEnum activityType) {
_activityQueue.removeWhere((a) => a.activityType == activityType);
}
void _clearQueue() {
_activityQueue.clear();
}
TargetTokensAndActivityType? get nextActivity =>
_activityQueue.isNotEmpty ? _activityQueue.first : null;
/// If there are more than 4 tokens that can be heard, we don't want to do word focus listening
/// Otherwise, we don't have enough distractors
bool get canDoWordFocusListening =>
_tokens.where((t) => t.canBeHeard).length > 4;
bool get hasHiddenWordActivity =>
nextActivity?.activityType.hiddenType ?? false;
int get numActivities => _activityQueue.length;
// /// If there are more than 4 tokens that can be heard, we don't want to do word focus listening
// /// Otherwise, we don't have enough distractors
// bool get canDoWordFocusListening =>
// _tokens.where((t) => t.canBeHeard).length > 4;
/// On initialization, we pick which tokens to do activities on and what types of activities to do
List<TargetTokensAndActivityType> setActivityQueue() {
void setActivityQueue() {
final List<TargetTokensAndActivityType> queue = [];
// for each token in the message
// pick a random activity type from the eligible types
for (final token in _tokens) {
// get all the eligible activity types for the token
// based on the context of the message
final eligibleTypesBasedOnContext = token.eligibleActivityTypes
// we want to filter hidden word types from this part of the process
.where((type) => type != ActivityTypeEnum.hiddenWordListening)
// there have to be at least 4 tokens in the message that can be heard for word focus listening
.where(
(type) =>
canDoWordFocusListening ||
type != ActivityTypeEnum.wordFocusListening,
)
.toList();
// if there are no eligible types, continue to the next token
if (eligibleTypesBasedOnContext.isEmpty) continue;
// chose a random activity type from the eligible types for that token
queue.add(
TargetTokensAndActivityType(
tokens: [token],
activityType: eligibleTypesBasedOnContext[
Random().nextInt(eligibleTypesBasedOnContext.length)],
),
);
}
// sort the queue by the total xp of the tokens, lowest first
queue.sort(
(a, b) => a.tokens
.map((t) => t.vocabConstruct.points)
.reduce((a, b) => a + b)
.compareTo(
b.tokens
.map((t) => t.vocabConstruct.points)
.reduce((a, b) => a + b),
),
);
// if applicable, add a hidden word activity to the front of the queue
final hiddenWordActivity = getHiddenWordActivity(queue.length);
if (hiddenWordActivity != null) {
queue.insert(0, hiddenWordActivity);
_pushQueue(hiddenWordActivity);
}
// limit to 3 activities
final limited = queue.take(3).toList();
// debugPrint("activities for ${PangeaToken.reconstructText(_tokens)}");
// for (final activity in limited) {
// debugPrint("activity: ${activity.activityType}");
// for (final token in activity.tokens) {
// debugPrint("token: ${token.analyticsDebugPrint}");
// }
// }
return limited;
}
/// Adds a word focus listening activity to the front of the queue
/// And limits to 3 activities
void addForWordMeaning(PangeaToken selectedToken) {
final int index = _activityQueue.isNotEmpty &&
_activityQueue.first.activityType ==
ActivityTypeEnum.hiddenWordListening
? 1
: 0;
_activityQueue.insert(
index,
TargetTokensAndActivityType(
tokens: [selectedToken],
activityType: ActivityTypeEnum.wordMeaning,
),
/// And limits to _maxQueueLength activities
void addTokenToActivityQueue(
PangeaToken token, {
ActivityTypeEnum type = ActivityTypeEnum.wordMeaning,
}) {
final entry = TargetTokensAndActivityType(
tokens: [token],
activityType: ActivityTypeEnum.wordMeaning,
);
// remove down to three activities
if (_activityQueue.length > 3) {
_activityQueue.removeRange(3, _activityQueue.length);
}
}
int get numActivities => _activityQueue.length;
void clearActivityQueue() {
_activityQueue.clear();
_pushQueue(entry);
}
/// Returns a hidden word activity if there is a sequence of tokens that have hiddenWordListening in their eligibleActivityTypes
@ -190,7 +161,7 @@ class MessageAnalyticsEntry {
// we will only do hidden word listening 50% of the time
// if there are no other activities to do, we will always do hidden word listening
if (numOtherActivities >= 3 && Random().nextDouble() < 0.5) {
if (numOtherActivities >= _maxQueueLength && Random().nextDouble() < 0.5) {
return null;
}
@ -198,8 +169,11 @@ class MessageAnalyticsEntry {
final List<List<PangeaToken>> sequences = [];
List<PangeaToken> currentSequence = [];
for (final token in _tokens) {
if (token.eligibleActivityTypes
.contains(ActivityTypeEnum.hiddenWordListening)) {
if (token.shouldDoActivity(
a: ActivityTypeEnum.hiddenWordListening,
feature: null,
tag: null,
)) {
currentSequence.add(token);
} else {
if (currentSequence.isNotEmpty) {
@ -226,15 +200,13 @@ class MessageAnalyticsEntry {
);
}
void onActivityComplete(PracticeActivityModel completed) {
_activityQueue.removeWhere(
(a) => a.matchesActivity(completed),
);
void onActivityComplete() {
_popQueue();
}
void revealAllTokens() {
_activityQueue.removeWhere((a) => a.activityType.hiddenType);
}
void exitPracticeFlow() => _clearQueue();
void revealAllTokens() => _filterQueue(ActivityTypeEnum.hiddenWordListening);
bool isTokenInHiddenWordActivity(PangeaToken token) => _activityQueue.any(
(activity) =>

View file

@ -13,7 +13,6 @@ import 'package:fluffychat/pangea/controllers/language_detection_controller.dart
import 'package:fluffychat/pangea/controllers/language_list_controller.dart';
import 'package:fluffychat/pangea/controllers/message_data_controller.dart';
import 'package:fluffychat/pangea/controllers/permissions_controller.dart';
import 'package:fluffychat/pangea/controllers/practice_activity_generation_controller.dart';
import 'package:fluffychat/pangea/controllers/practice_activity_record_controller.dart';
import 'package:fluffychat/pangea/controllers/put_analytics_controller.dart';
import 'package:fluffychat/pangea/controllers/speech_to_text_controller.dart';
@ -57,7 +56,6 @@ class PangeaController {
late SpeechToTextController speechToText;
late LanguageDetectionController languageDetection;
late PracticeActivityRecordController activityRecordController;
late PracticeGenerationController practiceGenerationController;
///store Services
late PStore pStoreService;
@ -114,7 +112,6 @@ class PangeaController {
speechToText = SpeechToTextController(this);
languageDetection = LanguageDetectionController(this);
activityRecordController = PracticeActivityRecordController(this);
practiceGenerationController = PracticeGenerationController(this);
PAuthGaurd.pController = this;
}

View file

@ -5,7 +5,7 @@ import 'dart:typed_data';
import 'package:fluffychat/pangea/config/environment.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/network/urls.dart';
import 'package:http/http.dart';

View file

@ -1,8 +1,17 @@
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
enum ActivityTypeEnum { wordMeaning, wordFocusListening, hiddenWordListening }
enum ActivityTypeEnum {
wordMeaning,
wordFocusListening,
hiddenWordListening,
lemmaId,
emoji,
morphId
}
extension ActivityTypeExtension on ActivityTypeEnum {
String get string {
@ -13,6 +22,12 @@ extension ActivityTypeExtension on ActivityTypeEnum {
return 'word_focus_listening';
case ActivityTypeEnum.hiddenWordListening:
return 'hidden_word_listening';
case ActivityTypeEnum.lemmaId:
return 'lemma_id';
case ActivityTypeEnum.emoji:
return 'emoji';
case ActivityTypeEnum.morphId:
return 'morph_id';
}
}
@ -20,6 +35,9 @@ extension ActivityTypeExtension on ActivityTypeEnum {
switch (this) {
case ActivityTypeEnum.wordMeaning:
case ActivityTypeEnum.wordFocusListening:
case ActivityTypeEnum.lemmaId:
case ActivityTypeEnum.emoji:
case ActivityTypeEnum.morphId:
return false;
case ActivityTypeEnum.hiddenWordListening:
return true;
@ -29,6 +47,9 @@ extension ActivityTypeExtension on ActivityTypeEnum {
bool get includeTTSOnClick {
switch (this) {
case ActivityTypeEnum.wordMeaning:
case ActivityTypeEnum.lemmaId:
case ActivityTypeEnum.emoji:
case ActivityTypeEnum.morphId:
return false;
case ActivityTypeEnum.wordFocusListening:
case ActivityTypeEnum.hiddenWordListening:
@ -53,6 +74,12 @@ extension ActivityTypeExtension on ActivityTypeEnum {
case 'hidden_word_listening':
case 'hiddenWordListening':
return ActivityTypeEnum.hiddenWordListening;
case 'lemma_id':
return ActivityTypeEnum.lemmaId;
case 'emoji':
return ActivityTypeEnum.emoji;
case 'morph_id':
return ActivityTypeEnum.morphId;
default:
throw Exception('Unknown activity type: $split');
}
@ -78,6 +105,37 @@ extension ActivityTypeExtension on ActivityTypeEnum {
ConstructUseTypeEnum.incHWL,
ConstructUseTypeEnum.ignHWL,
];
case ActivityTypeEnum.lemmaId:
return [
ConstructUseTypeEnum.corL,
ConstructUseTypeEnum.incL,
ConstructUseTypeEnum.ignL,
];
case ActivityTypeEnum.emoji:
return [ConstructUseTypeEnum.em];
case ActivityTypeEnum.morphId:
return [
ConstructUseTypeEnum.corM,
ConstructUseTypeEnum.incM,
ConstructUseTypeEnum.ignM,
];
}
}
ConstructUseTypeEnum get correctUse {
switch (this) {
case ActivityTypeEnum.wordMeaning:
return ConstructUseTypeEnum.corPA;
case ActivityTypeEnum.wordFocusListening:
return ConstructUseTypeEnum.corWL;
case ActivityTypeEnum.hiddenWordListening:
return ConstructUseTypeEnum.corHWL;
case ActivityTypeEnum.lemmaId:
return ConstructUseTypeEnum.corL;
case ActivityTypeEnum.emoji:
return ConstructUseTypeEnum.em;
case ActivityTypeEnum.morphId:
return ConstructUseTypeEnum.corM;
}
}
@ -87,7 +145,27 @@ extension ActivityTypeExtension on ActivityTypeEnum {
case ActivityTypeEnum.wordMeaning:
case ActivityTypeEnum.wordFocusListening:
case ActivityTypeEnum.hiddenWordListening:
case ActivityTypeEnum.lemmaId:
case ActivityTypeEnum.emoji:
return (id) => id.type == ConstructTypeEnum.vocab;
case ActivityTypeEnum.morphId:
return (id) => id.type == ConstructTypeEnum.morph;
}
}
IconData get icon {
switch (this) {
case ActivityTypeEnum.wordMeaning:
return Icons.translate;
case ActivityTypeEnum.wordFocusListening:
case ActivityTypeEnum.hiddenWordListening:
return Icons.hearing;
case ActivityTypeEnum.lemmaId:
return Symbols.dictionary;
case ActivityTypeEnum.emoji:
return Icons.emoji_emotions;
case ActivityTypeEnum.morphId:
return Icons.format_shapes;
}
}
}

View file

@ -1,3 +1,7 @@
import 'dart:developer';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
@ -12,52 +16,46 @@ enum ConstructUseTypeEnum {
/// produced in chat by user and igc was not run
unk,
/// selected correctly in IT flow
/// interactive translation activity
corIt,
/// encountered as IT distractor and correctly ignored it
ignIt,
/// encountered as it distractor and selected it
incIt,
/// encountered in igc match and ignored match
/// interactive grammar checking activity
corIGC,
incIGC,
ignIGC,
/// selected correctly in IGC flow
corIGC,
/// encountered as distractor in IGC flow and selected it
incIGC,
/// selected correctly in word meaning in context practice activity
/// word meaning in context practice activity
corPA,
/// encountered as distractor in word meaning in context practice activity and correctly ignored it
/// Currently not used
ignPA,
/// was target construct in word meaning in context practice activity and incorrectly selected
incPA,
/// was target lemma in word-focus listening activity and correctly selected
/// applies to target lemma in word-focus listening activity
corWL,
/// a form of lemma was read-aloud in word-focus listening activity and incorrectly selected
incWL,
/// a form of the lemma was read-aloud in word-focus listening activity and correctly ignored
ignWL,
/// correctly chose a form of the lemma in a hidden word listening activity
/// applies to the form of the lemma in a hidden word listening activity
corHWL,
/// incorrectly chose a form of the lemma in a hidden word listening activity
incHWL,
/// ignored a form of the lemma in a hidden word listening activity
ignHWL,
/// lemma id activity
corL,
incL,
ignL,
/// morph id activity
corM,
incM,
ignM,
/// emoji activity
/// No correct/incorrect/ignored distinction is made
/// User can select any emoji
em,
/// not defined, likely a new construct introduced by choreo and not yet classified by an old version of the client
nan
}
@ -103,52 +101,60 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
return L10n.of(context).constructUseIncHWLDesc;
case ConstructUseTypeEnum.ignHWL:
return L10n.of(context).constructUseIgnHWLDesc;
case ConstructUseTypeEnum.corL:
return L10n.of(context).constructUseCorLDesc;
case ConstructUseTypeEnum.incL:
return L10n.of(context).constructUseIncLDesc;
case ConstructUseTypeEnum.ignL:
return L10n.of(context).constructUseIgnLDesc;
case ConstructUseTypeEnum.corM:
return L10n.of(context).constructUseCorMDesc;
case ConstructUseTypeEnum.incM:
return L10n.of(context).constructUseIncMDesc;
case ConstructUseTypeEnum.ignM:
return L10n.of(context).constructUseIgnMDesc;
case ConstructUseTypeEnum.em:
return L10n.of(context).constructUseEmojiDesc;
case ConstructUseTypeEnum.nan:
return L10n.of(context).constructUseNanDesc;
}
}
ActivityTypeEnum get activityType => ActivityTypeEnum.values.firstWhere(
(e) => e.associatedUseTypes.contains(this),
orElse: () {
debugger(when: kDebugMode);
return ActivityTypeEnum.wordMeaning;
},
);
IconData get icon {
switch (this) {
// all minus for wrong answer
case ConstructUseTypeEnum.incIt:
case ConstructUseTypeEnum.incIGC:
case ConstructUseTypeEnum.incPA:
case ConstructUseTypeEnum.incWL:
case ConstructUseTypeEnum.incHWL:
return Icons.dangerous_outlined;
// correct in word meaning
case ConstructUseTypeEnum.corPA:
return Icons.add_task_outlined;
// correct in audio practice
case ConstructUseTypeEnum.corWL:
case ConstructUseTypeEnum.corHWL:
return Icons.volume_up_outlined;
// correct in translation
case ConstructUseTypeEnum.corIt:
return Icons.translate_outlined;
// written correctly without help
case ConstructUseTypeEnum.wa:
return Icons.thumb_up_outlined;
// correct in grammar correction
case ConstructUseTypeEnum.corIGC:
return Icons.spellcheck_outlined;
// ignored
case ConstructUseTypeEnum.corIt:
case ConstructUseTypeEnum.incIt:
case ConstructUseTypeEnum.ignIt:
case ConstructUseTypeEnum.ignIGC:
case ConstructUseTypeEnum.incIGC:
case ConstructUseTypeEnum.incPA:
case ConstructUseTypeEnum.ignPA:
case ConstructUseTypeEnum.ignWL:
case ConstructUseTypeEnum.incWL:
case ConstructUseTypeEnum.incHWL:
case ConstructUseTypeEnum.ignHWL:
return Icons.block_outlined;
case ConstructUseTypeEnum.ga:
return Icons.edit_outlined;
case ConstructUseTypeEnum.corIGC:
case ConstructUseTypeEnum.corPA:
case ConstructUseTypeEnum.corWL:
case ConstructUseTypeEnum.corHWL:
case ConstructUseTypeEnum.corL:
case ConstructUseTypeEnum.incL:
case ConstructUseTypeEnum.ignL:
case ConstructUseTypeEnum.corM:
case ConstructUseTypeEnum.incM:
case ConstructUseTypeEnum.ignM:
case ConstructUseTypeEnum.em:
return activityType.icon;
case ConstructUseTypeEnum.unk:
case ConstructUseTypeEnum.nan:
@ -173,9 +179,12 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
return 3;
case ConstructUseTypeEnum.corIGC:
case ConstructUseTypeEnum.corL:
case ConstructUseTypeEnum.corM:
return 2;
case ConstructUseTypeEnum.corIt:
case ConstructUseTypeEnum.em:
return 1;
case ConstructUseTypeEnum.ignIt:
@ -183,6 +192,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.ignPA:
case ConstructUseTypeEnum.ignWL:
case ConstructUseTypeEnum.ignHWL:
case ConstructUseTypeEnum.ignL:
case ConstructUseTypeEnum.ignM:
case ConstructUseTypeEnum.unk:
case ConstructUseTypeEnum.nan:
return 0;
@ -192,6 +203,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.incIt:
case ConstructUseTypeEnum.incIGC:
case ConstructUseTypeEnum.incL:
case ConstructUseTypeEnum.incM:
return -2;
case ConstructUseTypeEnum.incPA:

View file

@ -6,9 +6,10 @@ import 'package:matrix/matrix.dart';
enum MessageMode {
practiceActivity,
textToSpeech,
definition,
translation,
speechToText,
wordZoom,
noneSelected,
}
extension MessageModeExtension on MessageMode {
@ -20,13 +21,12 @@ extension MessageModeExtension on MessageMode {
return Symbols.text_to_speech;
case MessageMode.speechToText:
return Symbols.speech_to_text;
//TODO change icon for audio messages
case MessageMode.definition:
return Icons.book;
case MessageMode.practiceActivity:
return Symbols.fitness_center;
default:
return Icons.error; // Icon to indicate an error or unsupported mode
case MessageMode.wordZoom:
return Symbols.dictionary;
case MessageMode.noneSelected:
return Icons.error;
}
}
@ -38,13 +38,12 @@ extension MessageModeExtension on MessageMode {
return L10n.of(context).messageAudio;
case MessageMode.speechToText:
return L10n.of(context).speechToTextTooltip;
case MessageMode.definition:
return L10n.of(context).definitions;
case MessageMode.practiceActivity:
return L10n.of(context).practice;
default:
return L10n.of(context)
.oopsSomethingWentWrong; // Title to indicate an error or unsupported mode
case MessageMode.wordZoom:
return L10n.of(context).vocab;
case MessageMode.noneSelected:
return '';
}
}
@ -56,28 +55,27 @@ extension MessageModeExtension on MessageMode {
return L10n.of(context).audioTooltip;
case MessageMode.speechToText:
return L10n.of(context).speechToTextTooltip;
case MessageMode.definition:
return L10n.of(context).define;
case MessageMode.practiceActivity:
return L10n.of(context).practice;
default:
return L10n.of(context)
.oopsSomethingWentWrong; // Title to indicate an error or unsupported mode
case MessageMode.wordZoom:
return L10n.of(context).vocab;
case MessageMode.noneSelected:
return '';
}
}
bool shouldShowAsToolbarButton(Event event) {
switch (this) {
case MessageMode.translation:
return event.messageType == MessageTypes.Text;
case MessageMode.textToSpeech:
return event.messageType == MessageTypes.Text;
case MessageMode.definition:
return event.messageType == MessageTypes.Text;
case MessageMode.speechToText:
return event.messageType == MessageTypes.Audio;
case MessageMode.practiceActivity:
return true;
case MessageMode.wordZoom:
case MessageMode.noneSelected:
return false;
}
}
@ -88,6 +86,8 @@ extension MessageModeExtension on MessageMode {
) =>
numActivitiesCompleted >= index || totallyDone;
bool get showButton => this != MessageMode.practiceActivity;
Color iconButtonColor(
BuildContext context,
int index,

View file

@ -4,8 +4,10 @@ import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
/// A wrapper around a list of [OneConstructUse]s, used to simplify
/// the process of filtering / sorting / displaying the events.
@ -219,4 +221,91 @@ class ConstructListModel {
}).where((entry) => entry.value.isNotEmpty),
);
}
List<String> morphActivityDistractors(
String morphFeature,
String morphTag,
) {
final List<ConstructUses> morphConstructs = constructList(
type: ConstructTypeEnum.morph,
);
final List<String> possibleDistractors = morphConstructs
.where(
(c) =>
c.category == morphFeature.toLowerCase() &&
c.lemma.toLowerCase() != morphTag.toLowerCase(),
)
.map((c) => c.lemma)
.toList();
possibleDistractors.shuffle();
return possibleDistractors.take(3).toList();
}
Future<List<String>> lemmaActivityDistractors(PangeaToken token) async {
final List<String> lemmas = constructList(type: ConstructTypeEnum.vocab)
.map((c) => c.lemma)
.toSet()
.toList();
// Offload computation to an isolate
final Map<String, int> distances =
await compute(_computeDistancesInIsolate, {
'lemmas': lemmas,
'target': token.lemma.text,
});
// Sort lemmas by distance
final sortedLemmas = distances.keys.toList()
..sort((a, b) => distances[a]!.compareTo(distances[b]!));
// Take the shortest 4
final choices = sortedLemmas.take(4).toList();
if (!choices.contains(token.lemma.text)) {
final random = Random();
choices[random.nextInt(4)] = token.lemma.text;
}
return choices;
}
// isolate helper function
Map<String, int> _computeDistancesInIsolate(Map<String, dynamic> params) {
final List<String> lemmas = params['lemmas'];
final String target = params['target'];
// Calculate Levenshtein distances
final Map<String, int> distances = {};
for (final lemma in lemmas) {
distances[lemma] = levenshteinDistanceSync(target, lemma);
}
return distances;
}
int levenshteinDistanceSync(String s, String t) {
final int m = s.length;
final int n = t.length;
final List<List<int>> dp = List.generate(
m + 1,
(_) => List.generate(n + 1, (_) => 0),
);
for (int i = 0; i <= m; i++) {
for (int j = 0; j <= n; j++) {
if (i == 0) {
dp[i][j] = j;
} else if (j == 0) {
dp[i][j] = i;
} else if (s[i - 1] == t[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = 1 +
[dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]]
.reduce((a, b) => a < b ? a : b);
}
}
}
return dp[m][n];
}
}

View file

@ -1,14 +1,21 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart';
import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/pangea_token_text_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:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import '../constants/model_keys.dart';
import 'lemma.dart';
@ -24,16 +31,16 @@ class PangeaToken {
/// https://universaldependencies.org/u/pos/
final String pos;
/// [morph] ex {} - morphological features of the token
/// [_morph] ex {} - morphological features of the token
/// https://universaldependencies.org/u/feat/
final Map<String, dynamic> morph;
final Map<String, dynamic> _morph;
PangeaToken({
required this.text,
required this.lemma,
required this.pos,
required this.morph,
});
required Map<String, dynamic> morph,
}) : _morph = morph;
@override
bool operator ==(Object other) {
@ -47,6 +54,15 @@ class PangeaToken {
@override
int get hashCode => text.content.hashCode ^ text.offset.hashCode;
Map<String, dynamic> get morph {
if (_morph.keys.map((key) => key.toLowerCase()).contains("pos")) {
return _morph;
}
final morphWithPos = Map<String, dynamic>.from(_morph);
morphWithPos["pos"] = pos;
return morphWithPos;
}
/// reconstructs the text from the tokens
/// [tokens] - the tokens to reconstruct
/// [debugWalkThrough] - if true, will start the debugger
@ -196,62 +212,91 @@ class PangeaToken {
switch (a) {
case ActivityTypeEnum.wordMeaning:
return canBeDefined;
case ActivityTypeEnum.lemmaId:
return lemma.saveVocab;
case ActivityTypeEnum.emoji:
return true;
case ActivityTypeEnum.morphId:
return morph.isNotEmpty;
case ActivityTypeEnum.wordFocusListening:
return false;
case ActivityTypeEnum.hiddenWordListening:
return canBeHeard;
}
}
bool _didActivity(ActivityTypeEnum a) {
bool _didActivity(
ActivityTypeEnum a, [
String? morphFeature,
String? morphTag,
]) {
if ((morphFeature == null || morphTag == null) &&
a == ActivityTypeEnum.morphId) {
debugger(when: kDebugMode);
return true;
}
switch (a) {
case ActivityTypeEnum.wordMeaning:
return vocabConstruct.uses
.map((u) => u.useType)
.any((u) => a.associatedUseTypes.contains(u));
case ActivityTypeEnum.wordFocusListening:
return vocabConstruct.uses
// TODO - double-check that form is going to be available here
// .where((u) =>
// u.form?.toLowerCase() == text.content.toLowerCase(),)
.map((u) => u.useType)
.any((u) => a.associatedUseTypes.contains(u));
case ActivityTypeEnum.hiddenWordListening:
case ActivityTypeEnum.lemmaId:
case ActivityTypeEnum.emoji:
return vocabConstruct.uses
// TODO - double-check that form is going to be available here
// .where((u) =>
// u.form?.toLowerCase() == text.content.toLowerCase(),)
.map((u) => u.useType)
.any((u) => a.associatedUseTypes.contains(u));
case ActivityTypeEnum.morphId:
return morph.entries
.map((e) => morphConstruct(morphFeature!, morphTag!).uses)
.expand((e) => e)
.any(
(u) =>
a.associatedUseTypes.contains(u.useType) &&
u.form == text.content,
);
}
}
bool _didActivitySuccessfully(ActivityTypeEnum a) {
bool _didActivitySuccessfully(
ActivityTypeEnum a, [
String? morphFeature,
String? morphTag,
]) {
if ((morphFeature == null || morphTag == null) &&
a == ActivityTypeEnum.morphId) {
debugger(when: kDebugMode);
return true;
}
switch (a) {
case ActivityTypeEnum.wordMeaning:
return vocabConstruct.uses
.map((u) => u.useType)
.any((u) => u == ConstructUseTypeEnum.corPA);
case ActivityTypeEnum.wordFocusListening:
return vocabConstruct.uses
// TODO - double-check that form is going to be available here
// .where((u) =>
// u.form?.toLowerCase() == text.content.toLowerCase(),)
.map((u) => u.useType)
.any((u) => u == ConstructUseTypeEnum.corWL);
case ActivityTypeEnum.hiddenWordListening:
case ActivityTypeEnum.lemmaId:
case ActivityTypeEnum.emoji:
return vocabConstruct.uses
// TODO - double-check that form is going to be available here
// .where((u) =>
// u.form?.toLowerCase() == text.content.toLowerCase(),)
.map((u) => u.useType)
.any((u) => u == ConstructUseTypeEnum.corHWL);
.any((u) => u == a.correctUse);
// Note that it matters less if they did morphId in general, than if they did it with the particular feature
case ActivityTypeEnum.morphId:
if (morphFeature == null || morphTag == null) {
debugger(when: kDebugMode);
return false;
}
return morphConstruct(morphFeature, morphTag)
.uses
.any((u) => u.useType == a.correctUse && u.form == text.content);
}
}
bool _isActivityProbablyLevelAppropriate(ActivityTypeEnum a) {
bool _isActivityProbablyLevelAppropriate(
ActivityTypeEnum a, [
String? morphFeature,
String? morphTag,
]) {
switch (a) {
case ActivityTypeEnum.wordMeaning:
if (daysSinceLastUseByType(ActivityTypeEnum.wordMeaning) < 1) {
return false;
}
if (isContentWord) {
return vocabConstruct.points < 30;
} else if (canBeDefined) {
@ -262,22 +307,83 @@ class PangeaToken {
case ActivityTypeEnum.wordFocusListening:
return !_didActivitySuccessfully(a) || daysSinceLastUseByType(a) > 30;
case ActivityTypeEnum.hiddenWordListening:
return daysSinceLastUseByType(a) > 2;
return daysSinceLastUseByType(a) > 7;
case ActivityTypeEnum.lemmaId:
return daysSinceLastUseByType(a) > 7;
case ActivityTypeEnum.emoji:
return getEmoji() == null;
case ActivityTypeEnum.morphId:
if (morphFeature == null || morphTag == null) {
debugger(when: kDebugMode);
return false;
}
return daysSinceLastUseMorph(morphFeature, morphTag) > 1 &&
morphConstruct(morphFeature, morphTag).points < 5;
}
}
bool get shouldDoPosActivity => shouldDoMorphActivity("Pos");
bool shouldDoMorphActivity(String feature) {
return shouldDoActivity(
a: ActivityTypeEnum.morphId,
feature: feature,
tag: getMorphTag(feature),
);
}
/// Safely get morph tag for a given feature without regard for case
String? getMorphTag(String feature) {
if (morph.containsKey(feature)) return morph[feature];
if (morph.containsKey(feature.toLowerCase())) {
return morph[feature.toLowerCase()];
}
final lowerCaseEntries = morph.entries.map(
(e) => MapEntry(e.key.toLowerCase(), e.value),
);
return lowerCaseEntries
.firstWhereOrNull(
(e) => e.key == feature.toLowerCase(),
)
?.value;
}
Future<bool> canGenerateDistractors(
ActivityTypeEnum type, {
String? morphFeature,
String? morphTag,
}) async {
final constructListModel =
MatrixState.pangeaController.getAnalytics.constructListModel;
switch (type) {
case ActivityTypeEnum.lemmaId:
final distractors =
await constructListModel.lemmaActivityDistractors(this);
return distractors.isNotEmpty;
case ActivityTypeEnum.morphId:
final distractors = constructListModel.morphActivityDistractors(
morphFeature!,
morphTag!,
);
return distractors.isNotEmpty;
case ActivityTypeEnum.emoji:
case ActivityTypeEnum.wordFocusListening:
case ActivityTypeEnum.wordMeaning:
case ActivityTypeEnum.hiddenWordListening:
return true;
}
}
// maybe for every 5 points of xp for a particular activity, increment the days between uses by 2
bool shouldDoActivity(ActivityTypeEnum a) =>
lemma.saveVocab &&
_isActivityBasicallyEligible(a) &&
_isActivityProbablyLevelAppropriate(a);
/// we try to guess if the user is click on a token specifically or clicking on a message in general
/// if we think the word might be new for the learner, then we'll assume they're clicking on the word
bool get shouldDoWordMeaningOnClick =>
lemma.saveVocab &&
canBeDefined &&
daysSinceLastUseByType(ActivityTypeEnum.wordMeaning) > 1;
bool shouldDoActivity({
required ActivityTypeEnum a,
required String? feature,
required String? tag,
}) {
return lemma.saveVocab &&
_isActivityBasicallyEligible(a) &&
_isActivityProbablyLevelAppropriate(a, feature, tag);
}
List<ActivityTypeEnum> get eligibleActivityTypes {
final List<ActivityTypeEnum> eligibleActivityTypes = [];
@ -287,7 +393,7 @@ class PangeaToken {
}
for (final type in ActivityTypeEnum.values) {
if (shouldDoActivity(type)) {
if (shouldDoActivity(a: type, feature: null, tag: null)) {
eligibleActivityTypes.add(type);
}
}
@ -295,20 +401,37 @@ class PangeaToken {
return eligibleActivityTypes;
}
ConstructUses get vocabConstruct {
final vocab = constructs.firstWhereOrNull(
(element) => element.id.type == ConstructTypeEnum.vocab,
);
if (vocab == null) {
return ConstructUses(
ConstructUses get vocabConstruct =>
MatrixState.pangeaController.getAnalytics.constructListModel
.getConstructUses(
ConstructIdentifier(
lemma: lemma.text,
type: ConstructTypeEnum.morph,
category: pos,
),
) ??
ConstructUses(
lemma: lemma.text,
constructType: ConstructTypeEnum.vocab,
constructType: ConstructTypeEnum.morph,
category: pos,
uses: [],
);
}
return vocab;
}
ConstructUses morphConstruct(String morphFeature, String morphTag) =>
MatrixState.pangeaController.getAnalytics.constructListModel
.getConstructUses(
ConstructIdentifier(
lemma: morphTag,
type: ConstructTypeEnum.morph,
category: morphFeature,
),
) ??
ConstructUses(
lemma: morphTag,
constructType: ConstructTypeEnum.morph,
category: morphFeature,
uses: [],
);
int get xp {
return constructs.fold<int>(
@ -337,6 +460,12 @@ class PangeaToken {
return DateTime.now().difference(lastUsed).inDays;
}
int daysSinceLastUseMorph(String morphFeature, String morphTag) {
final lastUsed = morphConstruct(morphFeature, morphTag).lastUsed;
if (lastUsed == null) return 1000;
return DateTime.now().difference(lastUsed).inDays;
}
List<ConstructIdentifier> get _constructIDs {
final List<ConstructIdentifier> ids = [];
ids.add(
@ -374,46 +503,91 @@ class PangeaToken {
'target_types': eligibleActivityTypes.map((e) => e.string).toList(),
};
}
}
class PangeaTokenText {
int offset;
String content;
int length;
Future<List<String>> getEmojiChoices() => LemmaDictionaryRepo.get(
LemmaDefinitionRequest(
lemma: lemma.text,
partOfSpeech: pos,
lemmaLang: MatrixState
.pangeaController.languageController.userL2?.langCode ??
LanguageKeys.unknownLanguage,
userL1: MatrixState
.pangeaController.languageController.userL1?.langCode ??
LanguageKeys.defaultLanguage,
),
).then((onValue) => onValue.emoji);
PangeaTokenText({
required this.offset,
required this.content,
required this.length,
});
ConstructIdentifier get vocabConstructID => ConstructIdentifier(
lemma: lemma.text,
type: ConstructTypeEnum.vocab,
category: pos,
);
factory PangeaTokenText.fromJson(Map<String, dynamic> json) {
debugger(when: kDebugMode && json[_offsetKey] == null);
return PangeaTokenText(
offset: json[_offsetKey],
content: json[_contentKey],
length: json[_lengthKey] ?? (json[_contentKey] as String).length,
);
}
Room? get analyticsRoom {
final String? l2 =
MatrixState.pangeaController.languageController.userL2?.langCode;
static const String _offsetKey = "offset";
static const String _contentKey = "content";
static const String _lengthKey = "length";
Map<String, dynamic> toJson() =>
{_offsetKey: offset, _contentKey: content, _lengthKey: length};
//override equals and hashcode
@override
bool operator ==(Object other) {
if (other is PangeaTokenText) {
return other.offset == offset &&
other.content == content &&
other.length == length;
if (l2 == null) {
debugger(when: kDebugMode);
return null;
}
return false;
final Room? analyticsRoom =
MatrixState.pangeaController.matrixState.client.analyticsRoomLocal(l2);
if (analyticsRoom == null) {
debugger(when: kDebugMode);
}
return analyticsRoom;
}
@override
int get hashCode => offset.hashCode ^ content.hashCode ^ length.hashCode;
/// [setEmoji] sets the emoji for the lemma
/// NOTE: assumes that the language of the lemma is the same as the user's current l2
Future<void> setEmoji(String emoji) async {
if (analyticsRoom == null) return;
try {
final client = MatrixState.pangeaController.matrixState.client;
final syncFuture = client.onRoomState.stream.firstWhere((event) {
return event.roomId == analyticsRoom!.id &&
event.state.type == PangeaEventTypes.userChosenEmoji;
});
client.setRoomStateWithKey(
analyticsRoom!.id,
PangeaEventTypes.userChosenEmoji,
vocabConstructID.string,
{ModelKey.emoji: emoji},
);
await syncFuture;
} catch (err, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: err,
data: {
"construct": vocabConstructID.string,
"emoji": emoji,
},
s: s,
);
}
}
/// [getEmoji] gets the emoji for the lemma
/// NOTE: assumes that the language of the lemma is the same as the user's current l2
String? getEmoji() {
return analyticsRoom
?.getState(PangeaEventTypes.userChosenEmoji, vocabConstructID.string)
?.content
.tryGet<String>(ModelKey.emoji);
}
String get xpEmoji {
if (xp < 5) {
return "🌱";
} else if (xp < 10) {
return "🌿";
} else {
return "🌺";
}
}
}

View file

@ -0,0 +1,45 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
class PangeaTokenText {
int offset;
String content;
int length;
PangeaTokenText({
required this.offset,
required this.content,
required this.length,
});
factory PangeaTokenText.fromJson(Map<String, dynamic> json) {
debugger(when: kDebugMode && json[_offsetKey] == null);
return PangeaTokenText(
offset: json[_offsetKey],
content: json[_contentKey],
length: json[_lengthKey] ?? (json[_contentKey] as String).length,
);
}
static const String _offsetKey = "offset";
static const String _contentKey = "content";
static const String _lengthKey = "length";
Map<String, dynamic> toJson() =>
{_offsetKey: offset, _contentKey: content, _lengthKey: length};
//override equals and hashcode
@override
bool operator ==(Object other) {
if (other is PangeaTokenText) {
return other.offset == offset &&
other.content == content &&
other.length == length;
}
return false;
}
@override
int get hashCode => offset.hashCode ^ content.hashCode ^ length.hashCode;
}

View file

@ -54,6 +54,7 @@ class MessageActivityRequest {
final List<PangeaToken> targetTokens;
final ActivityTypeEnum targetType;
final String? targetMorphFeature;
final ActivityQualityFeedback? activityQualityFeedback;
@ -65,6 +66,7 @@ class MessageActivityRequest {
required this.activityQualityFeedback,
required this.targetTokens,
required this.targetType,
required this.targetMorphFeature,
}) {
if (targetTokens.isEmpty) {
throw Exception('Target tokens must not be empty');
@ -87,6 +89,7 @@ class MessageActivityRequest {
'activity_quality_feedback': activityQualityFeedback?.toJson(),
'target_tokens': targetTokens.map((e) => e.toJson()).toList(),
'target_type': targetType.string,
'target_morph_feature': targetMorphFeature,
};
}
@ -99,7 +102,8 @@ class MessageActivityRequest {
other.targetType == targetType &&
other.activityQualityFeedback?.feedbackText ==
activityQualityFeedback?.feedbackText &&
const ListEquality().equals(other.targetTokens, targetTokens);
const ListEquality().equals(other.targetTokens, targetTokens) &&
other.targetMorphFeature == targetMorphFeature;
}
@override
@ -107,7 +111,8 @@ class MessageActivityRequest {
return messageText.hashCode ^
targetType.hashCode ^
activityQualityFeedback.hashCode ^
targetTokens.hashCode;
targetTokens.hashCode ^
targetMorphFeature.hashCode;
}
}

View file

@ -1,5 +1,6 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:flutter/foundation.dart';
@ -7,14 +8,16 @@ import 'package:flutter/material.dart';
class ActivityContent {
final String question;
/// choices, including the correct answer
final List<String> choices;
final String answer;
final List<String> answers;
final RelevantSpanDisplayDetails? spanDisplayDetails;
ActivityContent({
required this.question,
required this.choices,
required this.answer,
required this.answers,
required this.spanDisplayDetails,
});
@ -25,27 +28,45 @@ class ActivityContent {
if (value != choices[index]) {
debugger(when: kDebugMode);
}
return value == answer || index == correctAnswerIndex;
return answers.contains(value) || correctAnswerIndices.contains(index);
}
bool get isValidQuestion => choices.contains(answer);
bool get isValidQuestion => choices.toSet().containsAll(answers);
int get correctAnswerIndex => choices.indexOf(answer);
List<int> get correctAnswerIndices {
final List<int> indices = [];
for (var i = 0; i < choices.length; i++) {
if (answers.contains(choices[i])) {
indices.add(i);
}
}
return indices;
}
int choiceIndex(String choice) => choices.indexOf(choice);
Color choiceColor(int index) =>
index == correctAnswerIndex ? AppConfig.success : AppConfig.warning;
Color choiceColor(int index) => correctAnswerIndices.contains(index)
? AppConfig.success
: AppConfig.warning;
factory ActivityContent.fromJson(Map<String, dynamic> json) {
final spanDisplay = json['span_display_details'] != null &&
json['span_display_details'] is Map
? RelevantSpanDisplayDetails.fromJson(json['span_display_details'])
: null;
final answerEntry = json['answer'] ?? json['correct_answer'] ?? "";
List<String> answers = [];
if (answerEntry is String) {
answers = [answerEntry];
} else if (answerEntry is List) {
answers = answerEntry.map((e) => e as String).toList();
}
return ActivityContent(
question: json['question'] as String,
choices: (json['choices'] as List).map((e) => e as String).toList(),
answer: json['answer'] ?? json['correct_answer'] as String,
answers: answers,
spanDisplayDetails: spanDisplay,
);
}
@ -54,7 +75,7 @@ class ActivityContent {
return {
'question': question,
'choices': choices,
'answer': answer,
'answer': answers,
'span_display_details': spanDisplayDetails?.toJson(),
};
}
@ -67,11 +88,11 @@ class ActivityContent {
return other is ActivityContent &&
other.question == question &&
other.choices == choices &&
other.answer == answer;
const ListEquality().equals(other.answers.sorted(), answers.sorted());
}
@override
int get hashCode {
return question.hashCode ^ choices.hashCode ^ answer.hashCode;
return question.hashCode ^ choices.hashCode ^ Object.hashAll(answers);
}
}

View file

@ -230,7 +230,8 @@ class PracticeActivityModel {
bool get shouldPlayTargetTokens =>
targetTokens != null &&
activityType != ActivityTypeEnum.hiddenWordListening;
activityType != ActivityTypeEnum.hiddenWordListening &&
activityType != ActivityTypeEnum.morphId;
factory PracticeActivityModel.fromJson(Map<String, dynamic> json) {
// moving from multiple_choice to content as the key

View file

@ -143,6 +143,16 @@ class ActivityRecordResponse {
return score > 0
? ConstructUseTypeEnum.corWL
: ConstructUseTypeEnum.incWL;
case ActivityTypeEnum.emoji:
return ConstructUseTypeEnum.em;
case ActivityTypeEnum.lemmaId:
return score > 0
? ConstructUseTypeEnum.corL
: ConstructUseTypeEnum.incL;
case ActivityTypeEnum.morphId:
return score > 0
? ConstructUseTypeEnum.corM
: ConstructUseTypeEnum.incM;
case ActivityTypeEnum.hiddenWordListening:
return score > 0
? ConstructUseTypeEnum.corHWL
@ -155,9 +165,28 @@ 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,
),
];
}
return [];
}
if (practiceActivity.targetTokens == null) {
return [];
}
final uses = practiceActivity.targetTokens!
.map(
(token) => OneConstructUse(

View file

@ -20,6 +20,9 @@ class PApiUrls {
static String accountEndpoint =
"${Environment.choreoApi}${PApiUrls.accountPrefix}";
/// ---------------------- Util --------------------------------------
static String appVersion = "${PApiUrls.choreoEndpoint}/version";
/// ---------------------- Languages --------------------------------------
static String getLanguages = "${PApiUrls.choreoEndpoint}/languages";
@ -61,6 +64,8 @@ class PApiUrls {
static String messageActivityGeneration =
"${PApiUrls.choreoEndpoint}/practice";
static String lemmaDictionary = "${PApiUrls.choreoEndpoint}/lemma_definition";
///-------------------------------- revenue cat --------------------------
static String rcApiV1 = "https://api.revenuecat.com/v1";
@ -70,6 +75,4 @@ class PApiUrls {
"${PApiUrls.subscriptionEndpoint}/all_products";
static String rcSubscription = "$rcApiV1/subscribers";
static String appVersion = "${PApiUrls.choreoEndpoint}/version";
}

View file

@ -1,12 +1,12 @@
import 'dart:convert';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:http/http.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../config/environment.dart';
import '../models/pangea_token_model.dart';
import '../network/requests.dart';
import '../network/urls.dart';

View file

@ -6,6 +6,7 @@ import 'package:fluffychat/pangea/models/language_detection_model.dart';
import 'package:fluffychat/pangea/models/lemma.dart';
import 'package:fluffychat/pangea/models/pangea_match_model.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/repo/span_data_repo.dart';
import 'package:http/http.dart';

View file

@ -26,6 +26,7 @@ class GenerateImageeResponse {
}
factory GenerateImageeResponse.error() {
// TODO: Implement better error handling
return GenerateImageeResponse(
imageUrl: 'https://i.imgur.com/2L2JYqk.png',
prompt: 'Error',

View file

@ -0,0 +1,147 @@
import 'dart:convert';
import 'package:fluffychat/pangea/network/urls.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:http/http.dart';
import '../config/environment.dart';
import '../network/requests.dart';
class LemmaDefinitionRequest {
final String lemma;
final String partOfSpeech;
final String lemmaLang;
final String userL1;
LemmaDefinitionRequest({
required this.lemma,
required this.partOfSpeech,
required this.lemmaLang,
required this.userL1,
});
factory LemmaDefinitionRequest.fromJson(Map<String, dynamic> json) {
return LemmaDefinitionRequest(
lemma: json['lemma'] as String,
partOfSpeech: json['part_of_speech'] as String,
lemmaLang: json['lemma_lang'] as String,
userL1: json['user_l1'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'lemma': lemma,
'part_of_speech': partOfSpeech,
'lemma_lang': lemmaLang,
'user_l1': userL1,
};
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is LemmaDefinitionRequest &&
runtimeType == other.runtimeType &&
lemma == other.lemma &&
partOfSpeech == other.partOfSpeech &&
lemmaLang == other.lemmaLang &&
userL1 == other.userL1;
@override
int get hashCode =>
lemma.hashCode ^
partOfSpeech.hashCode ^
lemmaLang.hashCode ^
userL1.hashCode;
}
class LemmaDefinitionResponse {
final List<String> emoji;
final String definition;
LemmaDefinitionResponse({
required this.emoji,
required this.definition,
});
factory LemmaDefinitionResponse.fromJson(Map<String, dynamic> json) {
return LemmaDefinitionResponse(
emoji: (json['emoji'] as List<dynamic>).map((e) => e as String).toList(),
definition: json['definition'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'emoji': emoji,
'definition': definition,
};
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is LemmaDefinitionResponse &&
runtimeType == other.runtimeType &&
emoji.length == other.emoji.length &&
emoji.every((element) => other.emoji.contains(element)) &&
definition == other.definition;
@override
int get hashCode =>
emoji.fold(0, (prev, element) => prev ^ element.hashCode) ^
definition.hashCode;
}
class LemmaDictionaryRepo {
// In-memory cache with timestamps
static final Map<LemmaDefinitionRequest, LemmaDefinitionResponse> _cache = {};
static final Map<LemmaDefinitionRequest, DateTime> _cacheTimestamps = {};
static const Duration _cacheDuration = Duration(days: 2);
static Future<LemmaDefinitionResponse> get(
LemmaDefinitionRequest request,
) async {
_clearExpiredEntries();
// Check the cache first
if (_cache.containsKey(request)) {
return _cache[request]!;
}
final Requests req = Requests(
choreoApiKey: Environment.choreoApiKey,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final requestBody = request.toJson();
final Response res = await req.post(
url: PApiUrls.lemmaDictionary,
body: requestBody,
);
final decodedBody = jsonDecode(utf8.decode(res.bodyBytes));
final response = LemmaDefinitionResponse.fromJson(decodedBody);
// Store the response and timestamp in the cache
_cache[request] = response;
_cacheTimestamps[request] = DateTime.now();
return response;
}
static void _clearExpiredEntries() {
final now = DateTime.now();
final expiredKeys = _cacheTimestamps.entries
.where((entry) => now.difference(entry.value) > _cacheDuration)
.map((entry) => entry.key)
.toList();
for (final key in expiredKeys) {
_cache.remove(key);
_cacheTimestamps.remove(key);
}
}
}

View file

@ -0,0 +1,36 @@
import 'dart:developer';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
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:flutter/foundation.dart';
class EmojiActivityGenerator {
Future<MessageActivityResponse> get(
MessageActivityRequest req,
) async {
debugger(when: kDebugMode && req.targetTokens.length != 1);
final PangeaToken token = req.targetTokens.first;
final List<String> emojis = await token.getEmojiChoices();
// TODO - modify MultipleChoiceActivity flow to allow no correct answer
return MessageActivityResponse(
activity: PracticeActivityModel(
activityType: ActivityTypeEnum.emoji,
targetTokens: [token],
tgtConstructs: [token.vocabConstructID],
langCode: req.userL2,
content: ActivityContent(
question: "",
choices: emojis,
answers: emojis,
spanDisplayDetails: null,
),
),
);
}
}

View file

@ -0,0 +1,37 @@
import 'dart:developer';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
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/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
class LemmaActivityGenerator {
Future<MessageActivityResponse> get(
MessageActivityRequest req,
) async {
debugger(when: kDebugMode && req.targetTokens.length != 1);
final token = req.targetTokens.first;
final List<String> choices = await MatrixState
.pangeaController.getAnalytics.constructListModel
.lemmaActivityDistractors(token);
// TODO - modify MultipleChoiceActivity flow to allow no correct answer
return MessageActivityResponse(
activity: PracticeActivityModel(
activityType: ActivityTypeEnum.lemmaId,
targetTokens: [token],
tgtConstructs: [token.vocabConstructID],
langCode: req.userL2,
content: ActivityContent(
question: "",
choices: choices,
answers: [token.lemma.text],
spanDisplayDetails: null,
),
),
);
}
}

View file

@ -0,0 +1,100 @@
import 'dart:developer';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
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/utils/error_handler.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
typedef MorphActivitySequence = Map<String, POSActivitySequence>;
typedef POSActivitySequence = List<String>;
class MorphActivityGenerator {
// TODO we want to define this on the server and have the client pull it down
final Map<String, MorphActivitySequence> sequence = {
"en": {
"ADJ": ["AdvType", "Aspect"],
"ADP": [],
"ADV": [],
"AUX": ["Tense", "Number"],
"CCONJ": [],
"DET": [],
"NOUN": ["Number"],
"NUM": [],
"PRON": ["Number", "Person"],
"SCONJ": [],
"PUNCT": [],
"VERB": ["Tense", "Aspect"],
},
};
/// Get the sequence of activities for a given part of speech
/// The sequence is a list of morphological features that should be practiced
/// in order for the given part of speech
Future<POSActivitySequence> getSequence(String langCode, String pos) async {
if (!sequence.containsKey(langCode)) {
langCode = "en";
}
final MorphActivitySequence morphActivitySequence = sequence[langCode]!;
if (!morphActivitySequence.containsKey(pos)) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "No sequence defined",
data: {"langCode": langCode, "pos": pos},
);
return [];
}
return morphActivitySequence[pos]!;
}
/// Generate a morphological activity for a given token and morphological feature
Future<MessageActivityResponse> get(
MessageActivityRequest req,
) async {
debugger(when: kDebugMode && req.targetTokens.length != 1);
debugger(when: kDebugMode && req.targetMorphFeature == null);
final PangeaToken token = req.targetTokens.first;
final String morphFeature = req.targetMorphFeature!;
final String? morphTag = token.getMorphTag(morphFeature);
if (morphTag == null) {
debugger(when: kDebugMode);
throw "No morph tag found for morph feature";
}
final List<String> distractors = MatrixState
.pangeaController.getAnalytics.constructListModel
.morphActivityDistractors(morphFeature, morphTag);
return MessageActivityResponse(
activity: PracticeActivityModel(
tgtConstructs: [
ConstructIdentifier(
lemma: morphTag,
type: ConstructTypeEnum.morph,
category: morphFeature,
),
],
targetTokens: req.targetTokens,
langCode: req.userL2,
activityType: ActivityTypeEnum.morphId,
content: ActivityContent(
question: "",
choices: distractors + [morphTag],
answers: [morphTag],
spanDisplayDetails: null,
),
),
);
}
}

View file

@ -5,6 +5,7 @@ import 'dart:developer';
import 'package:fluffychat/pangea/config/environment.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
@ -12,14 +13,19 @@ import 'package:fluffychat/pangea/models/practice_activities.dart/message_activi
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/network/requests.dart';
import 'package:fluffychat/pangea/network/urls.dart';
import 'package:fluffychat/pangea/repo/practice/emoji_activity_generator.dart';
import 'package:fluffychat/pangea/repo/practice/lemma_activity_generator.dart';
import 'package:fluffychat/pangea/repo/practice/morph_activity_generator.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart';
import 'package:matrix/matrix.dart';
/// Represents an item in the completion cache.
class _RequestCacheItem {
MessageActivityRequest req;
PracticeActivityModelResponse? practiceActivity;
final MessageActivityRequest req;
final PracticeActivityModelResponse practiceActivity;
final DateTime createdAt = DateTime.now();
_RequestCacheItem({
required this.req,
@ -34,18 +40,29 @@ class PracticeGenerationController {
late PangeaController _pangeaController;
PracticeGenerationController(PangeaController pangeaController) {
_pangeaController = pangeaController;
final MorphActivityGenerator _morph = MorphActivityGenerator();
final EmojiActivityGenerator _emoji = EmojiActivityGenerator();
final LemmaActivityGenerator _lemma = LemmaActivityGenerator();
PracticeGenerationController() {
_pangeaController = MatrixState.pangeaController;
_initializeCacheClearing();
}
void _initializeCacheClearing() {
const duration = Duration(minutes: 2);
const duration = Duration(minutes: 10);
_cacheClearTimer = Timer.periodic(duration, (Timer t) => _clearCache());
}
void _clearCache() {
_cache.clear();
final now = DateTime.now();
final keys = _cache.keys.toList();
for (final key in keys) {
final item = _cache[key]!;
if (now.difference(item.createdAt) > const Duration(minutes: 10)) {
_cache.remove(key);
}
}
}
void dispose() {
@ -72,7 +89,7 @@ class PracticeGenerationController {
);
}
Future<MessageActivityResponse> _fetch({
Future<MessageActivityResponse> _fetchFromServer({
required String accessToken,
required MessageActivityRequest requestModel,
}) async {
@ -97,9 +114,31 @@ class PracticeGenerationController {
}
}
//TODO - allow return of activity content before sending the event
// this requires some downstream changes to the way the event is handled
Future<PracticeActivityModelResponse?> getPracticeActivity(
Future<MessageActivityResponse> _routePracticeActivity({
required String accessToken,
required MessageActivityRequest req,
}) async {
// some activities we'll get from the server and others we'll generate locally
switch (req.targetType) {
case ActivityTypeEnum.emoji:
return _emoji.get(req);
case ActivityTypeEnum.lemmaId:
return _lemma.get(req);
case ActivityTypeEnum.morphId:
return _morph.get(req);
case ActivityTypeEnum.wordFocusListening:
// TODO bring clientside because more efficient
case ActivityTypeEnum.wordMeaning:
// TODO get correct answer with translation and distractors with distractor service
case ActivityTypeEnum.hiddenWordListening:
return _fetchFromServer(
accessToken: accessToken,
requestModel: req,
);
}
}
Future<PracticeActivityModelResponse> getPracticeActivity(
MessageActivityRequest req,
PangeaMessageEvent event,
) async {
@ -111,11 +150,13 @@ class PracticeGenerationController {
return _cache[cacheKey]!.practiceActivity;
}
final MessageActivityResponse res = await _fetch(
final MessageActivityResponse res = await _routePracticeActivity(
accessToken: _pangeaController.userController.accessToken,
requestModel: req,
req: req,
);
// TODO resolve some wierdness here whereby the activity can be null but then... it's not
final eventCompleter = Completer<PracticeActivityEvent?>();
debugPrint('Activity generated: ${res.activity.toJson()}');

View file

@ -1,9 +1,6 @@
// ignore_for_file: constant_identifier_names
import 'dart:developer';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
@ -454,7 +451,7 @@ String? getGrammarCopy({
);
return L10n.of(context).grammarCopyUnknown;
default:
debugger(when: kDebugMode);
// debugger(when: kDebugMode);
ErrorHandler.logError(
e: 'Need to add copy to intl_en.arb',
data: {

View file

@ -6,7 +6,7 @@ import 'package:fluffychat/pages/chat/events/audio_player.dart';
import 'package:fluffychat/pangea/controllers/text_to_speech_controller.dart';
import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart';
@ -185,7 +185,6 @@ class MessageAudioCardState extends State<MessageAudioCard> {
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(8),
alignment: Alignment.center,
child: _isLoading
? const ToolbarContentLoadingIndicator()
@ -199,6 +198,7 @@ class MessageAudioCardState extends State<MessageAudioCard> {
setIsPlayingAudio: widget.setIsPlayingAudio,
fontSize:
AppConfig.messageFontSize * AppConfig.fontSizeFactor,
padding: 0,
)
: const CardErrorWidget(
error: "Null audio file in message_audio_card",

View file

@ -1,17 +1,17 @@
import 'dart:async';
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/events/message_reactions.dart';
import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart';
import 'package:fluffychat/pangea/enum/activity_display_instructions_enum.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/enum/message_mode_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar_buttons.dart';
@ -20,7 +20,6 @@ import 'package:fluffychat/pangea/widgets/chat/overlay_header.dart';
import 'package:fluffychat/pangea/widgets/chat/overlay_message.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:matrix/matrix.dart';
@ -58,7 +57,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
StreamSubscription? _reactionSubscription;
Animation<double>? _overlayPositionAnimation;
MessageMode toolbarMode = MessageMode.translation;
MessageMode toolbarMode = MessageMode.noneSelected;
PangeaTokenText? _selectedSpan;
List<PangeaToken>? tokens;
@ -74,33 +73,30 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
int get activitiesLeftToComplete => messageAnalyticsEntry?.numActivities ?? 0;
bool get isPracticeComplete => activitiesLeftToComplete <= 0;
bool get isPracticeComplete =>
activitiesLeftToComplete <= 0 || !messageInUserL2;
/// Decides whether an _initialSelectedToken should be used
/// for a first practice activity on the word meaning
PangeaToken? get _selectedTargetTokenForWordMeaning {
void _initializeSelectedToken() {
// if there is no initial selected token, then we don't need to do anything
if (widget._initialSelectedToken == null || messageAnalyticsEntry == null) {
return null;
return;
}
debugPrint(
"selected token ${widget._initialSelectedToken?.analyticsDebugPrint}",
);
debugPrint(
"${widget._initialSelectedToken?.vocabConstruct.uses.map((u) => "${u.useType} ${u.timeStamp}").join(", ")}",
);
// should not already be involved in a hidden word activity
final isInHiddenWordActivity =
messageAnalyticsEntry!.isTokenInHiddenWordActivity(
widget._initialSelectedToken!,
);
// whether the activity should generally be involved in an activity
// final shouldDoActivity = widget._initialSelectedToken!
// .shouldDoActivity(ActivityTypeEnum.wordMeaning);
return !isInHiddenWordActivity ? widget._initialSelectedToken : null;
// whether the activity should generally be involved in an activity
final selected =
!isInHiddenWordActivity ? widget._initialSelectedToken : null;
if (selected != null) {
_updateSelectedSpan(selected.text);
}
}
@override
@ -111,6 +107,23 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
_setupSubscriptions();
}
void _updateSelectedSpan(PangeaTokenText selectedSpan) {
_selectedSpan = selectedSpan;
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) {
debugPrint("_updateSelectedSpan: setting toolbarMode to wordZoom");
updateToolbarMode(MessageMode.wordZoom);
}
setState(() {});
}
void _setupSubscriptions() {
_animationController = AnimationController(
vsync: this,
@ -165,11 +178,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
},
);
} finally {
if (_selectedTargetTokenForWordMeaning != null) {
messageAnalyticsEntry?.addForWordMeaning(
_selectedTargetTokenForWordMeaning!,
);
}
_initializeSelectedToken();
_setInitialToolbarMode();
initialized = true;
if (mounted) setState(() {});
@ -186,22 +195,24 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
return setState(() {});
}
// 1) we're only going to do activities if we have tokens for the message
// 2) if the user selects a span on initialization, then we want to give
// them a practice activity on that word
// 3) if the user has activities left to complete, then we want to give them
if (tokens != null && activitiesLeftToComplete > 0 && messageInUserL2) {
// 1) if we have a hidden word activity, then we should start with that
if (messageAnalyticsEntry?.nextActivity?.activityType ==
ActivityTypeEnum.hiddenWordListening) {
return setState(() => toolbarMode = MessageMode.practiceActivity);
}
if (selectedToken != null) {
return setState(() => toolbarMode = MessageMode.wordZoom);
}
// Note: this setting is now hidden so this will always be false
// leaving this here in case we want to bring it back
if (MatrixState.pangeaController.userController.profile.userSettings
.autoPlayMessages) {
return setState(() => toolbarMode = MessageMode.textToSpeech);
}
// if (MatrixState.pangeaController.userController.profile.userSettings
// .autoPlayMessages) {
// return setState(() => toolbarMode = MessageMode.textToSpeech);
// }
setState(() => toolbarMode = MessageMode.translation);
// defaults to noneSelected
}
/// We need to check if the setState call is safe to call immediately
@ -242,30 +253,30 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
/// When an activity is completed, we need to update the state
/// and check if the toolbar should be unlocked
void onActivityFinish() {
messageAnalyticsEntry!.onActivityComplete();
if (!mounted) return;
_clearSelection();
setState(() {});
}
/// In some cases, we need to exit the practice flow and let the user
/// interact with the toolbar without completing activities
void exitPracticeFlow() {
messageAnalyticsEntry?.clearActivityQueue();
_clearSelection();
messageAnalyticsEntry?.exitPracticeFlow();
setState(() {});
}
void updateToolbarMode(MessageMode mode) {
setState(() {
// only practiceActivity and wordZoom make sense with selectedSpan
if (![MessageMode.practiceActivity, MessageMode.wordZoom]
.contains(mode)) {
debugPrint("updateToolbarMode: $mode - clearing selectedSpan");
_selectedSpan = null;
}
toolbarMode = mode;
});
}
void _clearSelection() {
_selectedSpan = null;
setState(() {});
}
/// The text that the toolbar should target
/// If there is no selectedSpan, then the whole message is the target
/// If there is a selectedSpan, then the target is the selected text
@ -284,69 +295,42 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
void onClickOverlayMessageToken(
PangeaToken token,
) {
if ([
MessageMode.practiceActivity,
// MessageMode.textToSpeech
].contains(toolbarMode) ||
isPlayingAudio) {
if (toolbarMode == MessageMode.practiceActivity &&
messageAnalyticsEntry?.nextActivity?.activityType ==
ActivityTypeEnum.hiddenWordListening) {
return;
}
// if there's no selected span, then select the token
if (_selectedSpan == null) {
_selectedSpan = token.text;
} else {
// if there is a selected span, then deselect the token if it's the same
if (isTokenSelected(token)) {
_selectedSpan = null;
} else {
// if there is a selected span but it is not the same, then select the token
_selectedSpan = token.text;
}
}
if (_selectedSpan != null) {
widget.chatController.choreographer.tts.tryToSpeak(
token.text.content,
context,
pangeaMessageEvent!.eventId,
);
}
setState(() {});
}
void setSelectedSpan(PracticeActivityModel activity) {
if (pangeaMessageEvent == null) return;
final RelevantSpanDisplayDetails? span =
activity.content.spanDisplayDetails;
if (span == null) {
debugger(when: kDebugMode);
return;
}
if (span.displayInstructions != ActivityDisplayInstructionsEnum.nothing) {
_selectedSpan = PangeaTokenText(
offset: span.offset,
length: span.length,
content: widget._pangeaMessageEvent!.messageDisplayText
.substring(span.offset, span.offset + span.length),
);
} else {
_selectedSpan = null;
}
// PangeaTokenText? newSelectedSpan;
// if (_selectedSpan == null) {
// newSelectedSpan = token.text;
// } else {
// // if there is a selected span, then deselect the token if it's the same
// if (isTokenSelected(token)) {
// newSelectedSpan = null;
// } else {
// // if there is a selected span but it is not the same, then select the token
// newSelectedSpan = token.text;
// }
// }
// if (newSelectedSpan != null) {
// updateToolbarMode(MessageMode.practiceActivity);
// }
_updateSelectedSpan(token.text);
setState(() {});
}
/// Whether the given token is currently selected
bool isTokenSelected(PangeaToken token) {
return _selectedSpan?.offset == token.text.offset &&
final isSelected = _selectedSpan?.offset == token.text.offset &&
_selectedSpan?.length == token.text.length;
return isSelected;
}
PangeaToken? get selectedToken => tokens?.firstWhereOrNull(isTokenSelected);
/// Whether the overlay is currently displaying a selection
bool get isSelection => _selectedSpan != null;
@ -394,7 +378,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
_belowMessageHeight;
final bool hasHeaderOverflow =
_messageOffset!.dy < (AppConfig.toolbarMaxHeight + _headerHeight);
_messageOffset!.dy < (AppConfig.toolbarMaxHeight + _headerHeight + 10);
final bool hasFooterOverflow = (_footerHeight + 5) > currentBottomOffset;
if (!hasHeaderOverflow && !hasFooterOverflow) return;
@ -418,7 +402,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
final remainingSpace = _screenHeight! - totalTopOffset;
if (remainingSpace < _headerHeight) {
// the overlay could run over the header, so it needs to be shifted down
animationEndOffset -= (_headerHeight - remainingSpace);
animationEndOffset -= (_headerHeight - remainingSpace + 10);
}
scrollOffset = animationEndOffset - currentBottomOffset;
} else if (hasFooterOverflow) {
@ -591,7 +575,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
if (pangeaMessageEvent != null)
MessageToolbar(
pangeaMessageEvent: pangeaMessageEvent!,
overLayController: this,
overlayController: this,
),
const SizedBox(height: 8),
SizedBox(
@ -624,7 +608,6 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
ToolbarButtons(
event: widget._event,
overlayController: this,
width: 250,
),
],
),
@ -695,7 +678,10 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
OverlayFooter(controller: widget.chatController),
OverlayFooter(
controller: widget.chatController,
overlayController: this,
),
SizedBox(height: _mediaQuery?.padding.bottom ?? 0),
],
),

View file

@ -1,10 +1,7 @@
import 'dart:developer';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/enum/message_mode_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/message_speech_to_text_card.dart';
@ -12,143 +9,91 @@ import 'package:fluffychat/pangea/widgets/chat/message_translation_card.dart';
import 'package:fluffychat/pangea/widgets/chat/message_unsubscribed_card.dart';
import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart';
import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart';
import 'package:fluffychat/pangea/widgets/igc/word_data_card.dart';
import 'package:fluffychat/pangea/widgets/message_display_card.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart';
import 'package:fluffychat/pangea/widgets/word_zoom/word_zoom_widget.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.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;
class MessageToolbar extends StatelessWidget {
final PangeaMessageEvent pangeaMessageEvent;
final MessageOverlayController overLayController;
final MessageOverlayController overlayController;
const MessageToolbar({
super.key,
required this.pangeaMessageEvent,
required this.overLayController,
required this.overlayController,
});
TtsController get ttsController =>
overLayController.widget.chatController.choreographer.tts;
overlayController.widget.chatController.choreographer.tts;
Widget toolbarContent(BuildContext context) {
Widget? toolbarContent(BuildContext context) {
final bool subscribed =
MatrixState.pangeaController.subscriptionController.isSubscribed;
if (!subscribed) {
return MessageUnsubscribedCard(
controller: overLayController,
controller: overlayController,
);
}
if (!overLayController.initialized) {
if (overlayController.messageAnalyticsEntry?.hasHiddenWordActivity ??
false) {
return PracticeActivityCard(
pangeaMessageEvent: pangeaMessageEvent,
overlayController: overlayController,
targetTokensAndActivityType:
overlayController.messageAnalyticsEntry!.nextActivity!,
);
}
if (!overlayController.initialized) {
return const ToolbarContentLoadingIndicator();
}
switch (overLayController.toolbarMode) {
switch (overlayController.toolbarMode) {
case MessageMode.translation:
return MessageTranslationCard(
messageEvent: pangeaMessageEvent,
selection: overLayController.selectedSpan,
selection: overlayController.selectedSpan,
);
case MessageMode.textToSpeech:
return MessageAudioCard(
messageEvent: pangeaMessageEvent,
overlayController: overLayController,
selection: overLayController.selectedSpan,
overlayController: overlayController,
selection: overlayController.selectedSpan,
tts: ttsController,
setIsPlayingAudio: overLayController.setIsPlayingAudio,
setIsPlayingAudio: overlayController.setIsPlayingAudio,
);
case MessageMode.speechToText:
return MessageSpeechToTextCard(
messageEvent: pangeaMessageEvent,
);
case MessageMode.definition:
if (!overLayController.isSelection) {
return FutureBuilder(
//TODO - convert this to synchronous if possible
future: Future.value(
pangeaMessageEvent.messageDisplayRepresentation?.tokens,
),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const ToolbarContentLoadingIndicator();
} else if (snapshot.hasError ||
snapshot.data == null ||
snapshot.data!.isEmpty) {
return const Padding(
padding: EdgeInsets.all(8),
child: CardErrorWidget(
error: "No tokens available",
maxWidth: AppConfig.toolbarMinWidth,
),
);
} else {
return MessageDisplayCard(
displayText: L10n.of(context).selectToDefine,
);
}
},
);
} else {
try {
final selectedText = overLayController.targetText;
return WordDataCard(
word: selectedText,
wordLang: pangeaMessageEvent.messageDisplayLangCode,
fullText: pangeaMessageEvent.messageDisplayText,
fullTextLang: pangeaMessageEvent.messageDisplayLangCode,
hasInfo: true,
room: overLayController.widget.chatController.room,
);
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: "Error in WordDataCard",
s: s,
data: {
"word": overLayController.targetText,
"fullText": pangeaMessageEvent.messageDisplayText,
},
);
return const SizedBox();
}
}
case MessageMode.practiceActivity:
// If not in the target language show specific messsage
if (!overLayController.messageInUserL2) {
return MessageDisplayCard(
displayText: L10n.of(context)
.messageNotInTargetLang, // Pass the display text,
);
}
return PracticeActivityCard(
pangeaMessageEvent: pangeaMessageEvent,
overlayController: overLayController,
);
default:
debugger(when: kDebugMode);
ErrorHandler.logError(
e: "Invalid toolbar mode",
s: StackTrace.current,
data: {"newMode": overLayController.toolbarMode},
);
case MessageMode.noneSelected:
return const SizedBox();
case MessageMode.practiceActivity:
case MessageMode.wordZoom:
if (overlayController.selectedToken == null) {
return const SizedBox();
}
return WordZoomWidget(
token: overlayController.selectedToken!,
messageEvent: overlayController.pangeaMessageEvent!,
tts: ttsController,
overlayController: overlayController,
);
}
}
@override
Widget build(BuildContext context) {
if (![MessageTypes.Text, MessageTypes.Audio].contains(
pangeaMessageEvent.event.messageType,
)) {
if (overlayController.toolbarMode == MessageMode.noneSelected ||
![MessageTypes.Text, MessageTypes.Audio].contains(
pangeaMessageEvent.event.messageType,
)) {
return const SizedBox();
}

View file

@ -4,10 +4,8 @@ import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/enum/message_mode_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/pressable_button.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -16,36 +14,27 @@ import 'package:matrix/matrix.dart';
class ToolbarButtons extends StatelessWidget {
final Event event;
final MessageOverlayController overlayController;
final double width;
const ToolbarButtons({
required this.event,
required this.overlayController,
required this.width,
super.key,
});
PangeaMessageEvent? get pangeaMessageEvent =>
overlayController.pangeaMessageEvent;
int? get activitiesCompleted =>
overlayController.pangeaMessageEvent?.numberOfActivitiesCompleted;
List<MessageMode> get modes => MessageMode.values
.where((mode) => mode.shouldShowAsToolbarButton(event))
.toList();
bool get messageInUserL2 =>
pangeaMessageEvent?.messageDisplayLangCode ==
MatrixState.pangeaController.languageController.userL2?.langCode;
static const double iconWidth = 36.0;
static const buttonSize = 40.0;
static const double buttonSize = 40.0;
static const double width = 250.0;
@override
Widget build(BuildContext context) {
final totallyDone =
overlayController.isPracticeComplete || !messageInUserL2;
final double barWidth = width - iconWidth;
if (!overlayController.showToolbarButtons || pangeaMessageEvent == null) {
if (!overlayController.showToolbarButtons) {
return const SizedBox();
}
@ -62,6 +51,7 @@ class ToolbarButtons extends StatelessWidget {
height: 12,
decoration: BoxDecoration(
color: MessageModeExtension.barAndLockedButtonColor(context),
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
),
margin: const EdgeInsets.symmetric(horizontal: iconWidth / 2),
),
@ -69,13 +59,12 @@ class ToolbarButtons extends StatelessWidget {
duration: FluffyThemes.animationDuration,
height: 12,
width: overlayController.isPracticeComplete
? barWidth
: min(
barWidth,
(barWidth / 3) *
pangeaMessageEvent!.numberOfActivitiesCompleted,
),
color: AppConfig.success,
? width
: min(width, (width / 2) * activitiesCompleted!),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
color: AppConfig.success,
),
margin: const EdgeInsets.symmetric(horizontal: iconWidth / 2),
),
],
@ -86,52 +75,25 @@ class ToolbarButtons extends StatelessWidget {
children: modes.mapIndexed((index, mode) {
final enabled = mode.isUnlocked(
index,
pangeaMessageEvent!.numberOfActivitiesCompleted,
totallyDone,
activitiesCompleted!,
overlayController.isPracticeComplete,
);
final color = mode.iconButtonColor(
context,
index,
overlayController.toolbarMode,
pangeaMessageEvent!.numberOfActivitiesCompleted,
totallyDone,
activitiesCompleted!,
overlayController.isPracticeComplete,
);
return Tooltip(
message: mode.tooltip(context),
child: Stack(
alignment: Alignment.center,
children: [
PressableButton(
borderRadius: BorderRadius.circular(20),
depressed:
!enabled || mode == overlayController.toolbarMode,
return mode.showButton
? ToolbarButton(
mode: mode,
overlayController: overlayController,
enabled: enabled,
buttonSize: buttonSize,
color: color,
onPressed: enabled
? () => overlayController.updateToolbarMode(mode)
: null,
clickPlayer: overlayController
.widget.chatController.choreographer.clickPlayer,
child: AnimatedContainer(
duration: FluffyThemes.animationDuration,
height: buttonSize,
width: buttonSize,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(
mode.icon,
size: 20,
color: mode == overlayController.toolbarMode
? Colors.white
: null,
),
),
),
if (!enabled) const DisabledAnimation(),
],
),
);
)
: const SizedBox(width: buttonSize);
}).toList(),
),
],
@ -216,3 +178,56 @@ class DisabledAnimationState extends State<DisabledAnimation>
);
}
}
class ToolbarButton extends StatelessWidget {
final MessageMode mode;
final MessageOverlayController overlayController;
final bool enabled;
final double buttonSize;
final Color color;
const ToolbarButton({
required this.mode,
required this.overlayController,
required this.enabled,
required this.buttonSize,
required this.color,
super.key,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: mode.tooltip(context),
child: Stack(
alignment: Alignment.center,
children: [
PressableButton(
borderRadius: BorderRadius.circular(20),
depressed: !enabled || mode == overlayController.toolbarMode,
color: color,
onPressed: enabled
? () => overlayController.updateToolbarMode(mode)
: null,
child: AnimatedContainer(
duration: FluffyThemes.animationDuration,
height: buttonSize,
width: buttonSize,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(
mode.icon,
size: 20,
color:
mode == overlayController.toolbarMode ? Colors.white : null,
),
),
),
if (!enabled) const DisabledAnimation(),
],
),
);
}
}

View file

@ -1,7 +1,7 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/enum/instructions_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/models/representation_content_model.dart';
import 'package:fluffychat/pangea/repo/full_text_translation_repo.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';

View file

@ -2,13 +2,16 @@ import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/chat_input_row.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:flutter/material.dart';
class OverlayFooter extends StatelessWidget {
final ChatController controller;
final MessageOverlayController overlayController;
const OverlayFooter({
required this.controller,
required this.overlayController,
super.key,
});
@ -34,7 +37,8 @@ class OverlayFooter extends StatelessWidget {
borderRadius: const BorderRadius.all(
Radius.circular(AppConfig.borderRadius),
),
child: ChatInputRow(controller, isOverlay: true),
child:
ChatInputRow(controller, overlayController: overlayController),
),
],
),

View file

@ -1,189 +0,0 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class OverlayMessageText extends StatefulWidget {
final PangeaMessageEvent pangeaMessageEvent;
final MessageOverlayController overlayController;
const OverlayMessageText({
super.key,
required this.pangeaMessageEvent,
required this.overlayController,
});
@override
OverlayMessageTextState createState() => OverlayMessageTextState();
}
class OverlayMessageTextState extends State<OverlayMessageText> {
List<PangeaToken>? _tokens;
@override
void initState() {
super.initState();
_setTokens();
}
Future<void> _setTokens() async {
final repEvent = widget.pangeaMessageEvent.messageDisplayRepresentation;
if (repEvent != null) {
_tokens = repEvent.tokens;
_tokens ??= await repEvent.tokensGlobal(
widget.pangeaMessageEvent.senderId,
widget.pangeaMessageEvent.originServerTs,
);
if (mounted) setState(() {});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final ownMessage = widget.pangeaMessageEvent.event.senderId ==
Matrix.of(context).client.userID;
final style = TextStyle(
color: ownMessage
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface,
height: 1.3,
fontSize: AppConfig.messageFontSize * AppConfig.fontSizeFactor,
);
if (_tokens == null || _tokens!.isEmpty) {
return Text(
widget.pangeaMessageEvent.event.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)),
hideReply: true,
),
style: style,
);
}
// Convert the entire message into a list of characters
final Characters messageCharacters =
widget.pangeaMessageEvent.messageDisplayText.characters;
// When building token positions, use grapheme cluster indices
// We use grapheme cluster indices to avoid splitting emojis and other
// complex characters that requires multiple code units.
// For instance, the emoji 🇺🇸 is represented by two code units:
// - \u{1F1FA}
// - \u{1F1F8}
final List<TokenPosition> tokenPositions = [];
int globalIndex = 0;
for (int i = 0; i < _tokens!.length; i++) {
final token = _tokens![i];
final start = token.start;
final end = token.end;
// Calculate the number of grapheme clusters up to the start and end positions
final int startIndex = messageCharacters.take(start).length;
final int endIndex = messageCharacters.take(end).length;
if (globalIndex < startIndex) {
tokenPositions.add(TokenPosition(start: globalIndex, end: startIndex));
}
tokenPositions.add(
TokenPosition(
start: startIndex,
end: endIndex,
tokenIndex: i,
token: token,
),
);
globalIndex = endIndex;
}
// debug prints for fixing words sticking together
// void printEscapedString(String input) {
// // Escaped string using Unicode escape sequences
// final String escapedString = input.replaceAllMapped(
// RegExp(r'[^\w\s]', unicode: true),
// (match) {
// final codeUnits = match.group(0)!.runes;
// String unicodeEscapes = '';
// for (final rune in codeUnits) {
// unicodeEscapes += '\\u{${rune.toRadixString(16)}}';
// }
// return unicodeEscapes;
// },
// );
// print("Escaped String: $escapedString");
// // Printing each character with its index
// int index = 0;
// for (final char in input.characters) {
// print("Index $index: $char");
// index++;
// }
// }
//TODO - take out of build function of every message
return RichText(
text: TextSpan(
children: tokenPositions.map((tokenPosition) {
final substring = messageCharacters
.skip(tokenPosition.start)
.take(tokenPosition.end - tokenPosition.start)
.toString();
if (tokenPosition.token != null) {
final isSelected =
widget.overlayController.isTokenSelected(tokenPosition.token!);
return TextSpan(
recognizer: TapGestureRecognizer()
..onTap = () {
debugPrint(
'tokenPosition.tokenIndex: ${tokenPosition.tokenIndex}',
);
widget.overlayController.onClickOverlayMessageToken(
tokenPosition.token!,
);
if (mounted) setState(() {});
},
text: substring,
style: style.merge(
TextStyle(
backgroundColor: isSelected
? Theme.of(context).brightness == Brightness.light
? Colors.black.withOpacity(0.4)
: Colors.white.withOpacity(0.4)
: Colors.transparent,
),
),
);
} else {
return TextSpan(
text: substring,
style: style,
);
}
}).toList(),
),
);
}
}
class TokenPosition {
final int start;
final int end;
final PangeaToken? token;
final int tokenIndex;
const TokenPosition({
required this.start,
required this.end,
this.token,
this.tokenIndex = -1,
});
}

View file

@ -1,18 +1,25 @@
import 'package:fluffychat/config/app_emojis.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
class PangeaReactionsPicker extends StatelessWidget {
final ChatController controller;
final MessageOverlayController? overlayController;
const PangeaReactionsPicker(this.controller, {super.key});
const PangeaReactionsPicker(
this.controller,
this.overlayController, {
super.key,
});
PangeaToken? get token => overlayController?.selectedToken;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (controller.showEmojiPicker) return const SizedBox.shrink();
final display = controller.editEvent == null &&
controller.replyEvent == null &&
@ -39,6 +46,15 @@ class PangeaReactionsPicker extends StatelessWidget {
emojis.remove(event.content.tryGetMap('m.relates_to')!['key']);
} catch (_) {}
}
for (final event in allReactionEvents) {
try {
emojis.remove(
event.content.tryGetMap('m.relates_to')!['key'],
);
} catch (_) {}
}
return Flexible(
child: Row(
children: [
@ -65,20 +81,6 @@ class PangeaReactionsPicker extends StatelessWidget {
),
),
),
InkWell(
borderRadius: BorderRadius.circular(8),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
width: 36,
height: 56,
decoration: BoxDecoration(
color: theme.colorScheme.onInverseSurface,
shape: BoxShape.circle,
),
child: const Icon(Icons.add_outlined),
),
onTap: () => controller.pickEmojiReactionAction(allReactionEvents),
),
],
),
);

View file

@ -11,7 +11,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_tts/flutter_tts.dart' as flutter_tts;
import 'package:matrix/matrix_api_lite/utils/logs.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:text_to_speech/text_to_speech.dart';
class TtsController {
@ -202,32 +201,34 @@ class TtsController {
stop();
Logs().i('Speaking: $text');
final result = await (_useAlternativeTTS
? _alternativeTTS.speak(text)
: _tts.speak(text))
.timeout(
const Duration(seconds: 5),
onTimeout: () {
ErrorHandler.logError(
e: "Timeout on tts.speak",
data: {"text": text},
);
},
final result = await Future(
() => (_useAlternativeTTS
? _alternativeTTS.speak(text)
: _tts.speak(text))
.timeout(
const Duration(seconds: 5),
onTimeout: () {
ErrorHandler.logError(
e: "Timeout on tts.speak",
data: {"text": text},
);
},
),
);
Logs().i('Finished speaking: $text, result: $result');
// return type is dynamic but apparent its supposed to be 1
// https://pub.dev/packages/flutter_tts
if (result != 1 && !kIsWeb) {
ErrorHandler.logError(
m: 'Unexpected result from tts.speak',
data: {
'result': result,
'text': text,
},
level: SentryLevel.warning,
);
}
// if (result != 1 && !kIsWeb) {
// ErrorHandler.logError(
// m: 'Unexpected result from tts.speak',
// data: {
// 'result': result,
// 'text': text,
// },
// level: SentryLevel.warning,
// );
// }
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
@ -243,3 +244,7 @@ class TtsController {
bool get isLanguageFullySupported =>
_availableLangCodes.contains(targetLanguage);
}
extension on (Future,) {
timeout(Duration duration, {required Null Function() onTimeout}) {}
}

View file

@ -196,56 +196,59 @@ class WordDataCardView extends StatelessWidget {
maxWidth: AppConfig.toolbarMinWidth,
maxHeight: AppConfig.toolbarMaxHeight,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
if (controller.widget.choiceFeedback != null)
Text(
controller.widget.choiceFeedback!,
style: BotStyle.text(context),
),
const SizedBox(height: 5.0),
if (controller.wordData != null &&
controller.wordNetError == null &&
controller.activeL1 != null &&
controller.activeL2 != null)
WordNetInfo(
wordData: controller.wordData!,
activeL1: controller.activeL1!,
activeL2: controller.activeL2!,
),
if (controller.isLoadingWordNet)
const ToolbarContentLoadingIndicator(),
const SizedBox(height: 5.0),
// if (controller.widget.hasInfo &&
// !controller.isLoadingContextualDefinition &&
// controller.contextualDefinitionRes == null)
// Material(
// type: MaterialType.transparency,
// child: ListTile(
// leading: const BotFace(
// width: 40, expression: BotExpression.surprised),
// title: Text(L10n.of(context).askPangeaBot),
// onTap: controller.handleGetDefinitionButtonPress,
// ),
// ),
if (controller.isLoadingContextualDefinition)
const ToolbarContentLoadingIndicator(),
if (controller.contextualDefinitionRes != null)
Text(
controller.contextualDefinitionRes!.text,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
if (controller.definitionError != null)
Text(
L10n.of(context).sorryNoResults,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
],
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
if (controller.widget.choiceFeedback != null)
Text(
controller.widget.choiceFeedback!,
style: BotStyle.text(context),
),
const SizedBox(height: 5.0),
if (controller.wordData != null &&
controller.wordNetError == null &&
controller.activeL1 != null &&
controller.activeL2 != null)
WordNetInfo(
wordData: controller.wordData!,
activeL1: controller.activeL1!,
activeL2: controller.activeL2!,
),
if (controller.isLoadingWordNet)
const ToolbarContentLoadingIndicator(),
const SizedBox(height: 5.0),
// if (controller.widget.hasInfo &&
// !controller.isLoadingContextualDefinition &&
// controller.contextualDefinitionRes == null)
// Material(
// type: MaterialType.transparency,
// child: ListTile(
// leading: const BotFace(
// width: 40, expression: BotExpression.surprised),
// title: Text(L10n.of(context).askPangeaBot),
// onTap: controller.handleGetDefinitionButtonPress,
// ),
// ),
if (controller.isLoadingContextualDefinition)
const ToolbarContentLoadingIndicator(),
if (controller.contextualDefinitionRes != null)
Text(
controller.contextualDefinitionRes!.text,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
if (controller.definitionError != null)
Text(
L10n.of(context).sorryNoResults,
style: BotStyle.text(context),
textAlign: TextAlign.center,
),
],
),
),
),
),

View file

@ -0,0 +1,63 @@
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:flutter/material.dart';
class EmojiPracticeButton extends StatefulWidget {
final PangeaToken token;
final VoidCallback onPressed;
final String? emoji;
final Function(String) setEmoji;
const EmojiPracticeButton({
required this.token,
required this.onPressed,
this.emoji,
required this.setEmoji,
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 _canDoActivity {
final canDo = widget.token.shouldDoActivity(
a: ActivityTypeEnum.emoji,
feature: null,
tag: null,
);
return canDo;
}
@override
Widget build(BuildContext context) {
final emoji = widget.token.getEmoji();
return SizedBox(
height: 40,
width: 40,
child: _canDoActivity || emoji != null
? 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(),
);
}
}

View file

@ -1,6 +1,5 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/choreographer/widgets/choice_array.dart';
import 'package:fluffychat/pangea/controllers/put_analytics_controller.dart';
@ -9,7 +8,9 @@ import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/utils/grammar/get_grammar_copy.dart';
import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/word_audio_button.dart';
@ -24,12 +25,14 @@ class MultipleChoiceActivity extends StatefulWidget {
final PracticeActivityModel currentActivity;
final Event event;
final VoidCallback? onError;
final MessageOverlayController overlayController;
const MultipleChoiceActivity({
super.key,
required this.practiceCardController,
required this.currentActivity,
required this.event,
required this.overlayController,
this.onError,
});
@ -62,7 +65,7 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
void speakTargetTokens() {
if (widget.practiceCardController.currentActivity?.shouldPlayTargetTokens ??
false) {
widget.practiceCardController.tts.tryToSpeak(
tts.tryToSpeak(
PangeaToken.reconstructText(
widget.practiceCardController.currentActivity!.targetTokens!,
),
@ -72,7 +75,8 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
}
}
TtsController get tts => widget.practiceCardController.tts;
TtsController get tts =>
widget.overlayController.widget.chatController.choreographer.tts;
void updateChoice(String value, int index) {
final bool isCorrect =
@ -145,7 +149,7 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
if (widget.currentActivity.content.isCorrect(value, index)) {
MatrixState.pangeaController.getAnalytics.analyticsStream.stream.first
.then((_) {
widget.practiceCardController.onActivityFinish();
widget.practiceCardController.onActivityFinish(correctAnswer: value);
});
}
@ -156,6 +160,40 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
}
}
List<Choice> choices(BuildContext context) {
final activity = widget.currentActivity.content;
final List<Choice> choices = [];
for (int i = 0; i < activity.choices.length; i++) {
final String value = activity.choices[i];
final color = currentRecordModel?.hasTextResponse(value) ?? false
? activity.choiceColor(i)
: null;
final isGold = activity.isCorrect(value, i);
choices.add(
Choice(
text: value,
color: color,
isGold: isGold,
),
);
}
return choices;
}
String _getDisplayCopy(String value) {
if (widget.currentActivity.activityType != ActivityTypeEnum.morphId) {
return value;
}
final morphFeature = widget.practiceCardController.widget.morphFeature;
if (morphFeature == null) return value;
return getGrammarCopy(
category: morphFeature,
lemma: value,
context: context,
) ??
value;
}
@override
Widget build(BuildContext context) {
final PracticeActivityModel practiceActivity = widget.currentActivity;
@ -175,7 +213,7 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
if (practiceActivity.activityType ==
ActivityTypeEnum.wordFocusListening)
WordAudioButton(
text: practiceActivity.content.answer,
text: practiceActivity.content.answers.first,
ttsController: tts,
eventID: widget.event.eventId,
),
@ -184,11 +222,9 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
MessageAudioCard(
messageEvent:
widget.practiceCardController.widget.pangeaMessageEvent,
overlayController:
widget.practiceCardController.widget.overlayController,
overlayController: widget.overlayController,
tts: tts,
setIsPlayingAudio: widget.practiceCardController.widget
.overlayController.setIsPlayingAudio,
setIsPlayingAudio: widget.overlayController.setIsPlayingAudio,
onError: widget.onError,
),
ChoicesArray(
@ -197,37 +233,27 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
originalSpan: "placeholder",
onPressed: updateChoice,
selectedChoiceIndex: selectedChoiceIndex,
choices: practiceActivity.content.choices
.mapIndexed(
(index, value) => Choice(
text: value,
color: currentRecordModel?.hasTextResponse(value) ?? false
? practiceActivity.content.choiceColor(index)
: null,
isGold: practiceActivity.content.isCorrect(value, index),
),
)
.toList(),
choices: choices(context),
isActive: true,
id: currentRecordModel?.hashCode.toString(),
tts: practiceActivity.activityType.includeTTSOnClick ? tts : null,
enableAudio: !widget
.practiceCardController.widget.overlayController.isPlayingAudio,
enableAudio: !widget.overlayController.isPlayingAudio,
getDisplayCopy: _getDisplayCopy,
),
],
);
return Container(
padding: const EdgeInsets.all(20),
return ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: AppConfig.toolbarMinWidth,
maxHeight: AppConfig.toolbarMaxHeight,
minWidth: AppConfig.toolbarMinWidth,
minHeight: AppConfig.toolbarMinHeight,
),
child:
practiceActivity.activityType == ActivityTypeEnum.hiddenWordListening
? SingleChildScrollView(child: content)
: content,
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20),
child: content,
),
),
);
}
}

View file

@ -2,40 +2,51 @@ import 'dart:async';
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/controllers/practice_activity_generation_controller.dart';
import 'package:fluffychat/pangea/controllers/put_analytics_controller.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/repo/practice/practice_repo.dart';
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/chat/tts_controller.dart';
import 'package:fluffychat/pangea/widgets/content_issue_button.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/no_more_practice_card.dart';
import 'package:fluffychat/pangea/widgets/word_zoom/word_zoom_widget.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
/// The wrapper for practice activity content.
/// Handles the activities associated with a message,
/// their navigation, and the management of completion records
class PracticeActivityCard extends StatefulWidget {
final PangeaMessageEvent pangeaMessageEvent;
final TargetTokensAndActivityType targetTokensAndActivityType;
final MessageOverlayController overlayController;
final WordZoomWidgetState? wordDetailsController;
final String? morphFeature;
//TODO - modifications
// 1) Future<PracticeActivityEvent> and Future<PracticeActivityModel> as parameters
// 2) onFinish callback as parameter
// 3) take out logic fetching activity
const PracticeActivityCard({
super.key,
required this.pangeaMessageEvent,
required this.targetTokensAndActivityType,
required this.overlayController,
this.morphFeature,
this.wordDetailsController,
});
@override
@ -44,27 +55,37 @@ class PracticeActivityCard extends StatefulWidget {
class PracticeActivityCardState extends State<PracticeActivityCard> {
PracticeActivityModel? currentActivity;
Completer<PracticeActivityEvent?>? currentActivityCompleter;
PracticeActivityRecordModel? currentCompletionRecord;
bool fetchingActivity = false;
List<PracticeActivityEvent> get practiceActivities =>
widget.pangeaMessageEvent.practiceActivities;
// Used to show an animation when the user completes an activity
// while simultaneously fetching a new activity and not showing the loading spinner
// until the appropriate time has passed to 'savor the joy'
Duration appropriateTimeForJoy = const Duration(milliseconds: 2500);
bool savoringTheJoy = false;
TtsController get tts =>
widget.overlayController.widget.chatController.choreographer.tts;
PracticeActivityRecordModel? currentCompletionRecord;
Completer<PracticeActivityEvent?>? currentActivityCompleter;
PracticeGenerationController practiceGenerationController =
PracticeGenerationController();
PangeaController get pangeaController => MatrixState.pangeaController;
@override
void initState() {
super.initState();
initialize();
_fetchActivity();
}
@override
void didUpdateWidget(PracticeActivityCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.targetTokensAndActivityType !=
widget.targetTokensAndActivityType ||
oldWidget.morphFeature != widget.morphFeature) {
_fetchActivity();
}
}
@override
void dispose() {
practiceGenerationController.dispose();
super.dispose();
}
void _updateFetchingActivity(bool value) {
@ -72,129 +93,120 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
if (mounted) setState(() => fetchingActivity = value);
}
void _setPracticeActivity(PracticeActivityModel? activity) {
//set elsewhere but just in case
fetchingActivity = false;
currentActivity = activity;
if (activity == null) {
widget.overlayController.exitPracticeFlow();
Future<void> _fetchActivity({
ActivityQualityFeedback? activityFeedback,
}) async {
if (!mounted ||
!pangeaController.languageController.languagesSet ||
widget.overlayController.messageAnalyticsEntry == null) {
debugger(when: kDebugMode);
_updateFetchingActivity(false);
return;
}
//make new completion record
currentCompletionRecord = PracticeActivityRecordModel(
question: activity.question,
);
widget.overlayController.setSelectedSpan(activity);
}
/// Get an existing activity if there is one.
/// If not, get a new activity from the server.
Future<void> initialize() async {
_setPracticeActivity(
await _fetchActivity(),
);
}
Future<PracticeActivityModel?> _fetchActivity({
ActivityQualityFeedback? activityFeedback,
}) async {
try {
debugPrint('Fetching activity');
_updateFetchingActivity(true);
// target tokens can be empty if activities have been completed for each
// it's set on initialization and then removed when each activity is completed
if (!mounted ||
!pangeaController.languageController.languagesSet ||
widget.overlayController.messageAnalyticsEntry == null) {
debugger(when: kDebugMode);
_updateFetchingActivity(false);
return null;
}
final nextActivitySpecs =
widget.overlayController.messageAnalyticsEntry?.nextActivity;
// the client is going to be choosing the next activity now
// if nothing is set then it must be done with practice
if (nextActivitySpecs == null) {
debugPrint("No next activity set, exiting practice flow");
_updateFetchingActivity(false);
return null;
}
// check if we already have an activity matching the specs
final existingActivity = practiceActivities.firstWhereOrNull(
(activity) =>
nextActivitySpecs.matchesActivity(activity.practiceActivity),
);
if (existingActivity != null) {
debugPrint('found existing activity');
_updateFetchingActivity(false);
existingActivity.practiceActivity.targetTokens =
nextActivitySpecs.tokens;
currentActivityCompleter = Completer();
currentActivityCompleter!.complete(existingActivity);
return existingActivity.practiceActivity;
}
debugPrint(
"client requesting ${nextActivitySpecs.activityType.string} for: ${nextActivitySpecs.tokens.map((t) => "construct: ${t.lemma.text}:${t.pos} points: ${t.vocabConstruct.points}").join(' ')}",
final activity = await _fetchActivityModel(
activityFeedback: activityFeedback,
);
// debugger(
// when: kDebugMode &&
// nextActivitySpecs.tokens
// .map((a) => a.vocabConstruct.points)
// .reduce((a, b) => a + b) >
// 30 &&
// nextActivitySpecs.activityType == ActivityTypeEnum.wordMeaning,
// );
final PracticeActivityModelResponse? activityResponse =
await pangeaController.practiceGenerationController
.getPracticeActivity(
MessageActivityRequest(
userL1: pangeaController.languageController.userL1!.langCode,
userL2: pangeaController.languageController.userL2!.langCode,
messageText: widget.pangeaMessageEvent.messageDisplayText,
messageTokens: widget.overlayController.tokens!,
activityQualityFeedback: activityFeedback,
targetTokens: nextActivitySpecs.tokens,
targetType: nextActivitySpecs.activityType,
),
widget.pangeaMessageEvent,
);
currentActivityCompleter = activityResponse?.eventCompleter;
_updateFetchingActivity(false);
if (activityResponse == null || activityResponse.activity == null) {
debugPrint('No activity found');
return null;
currentActivity = activity;
if (activity == null) {
widget.overlayController.exitPracticeFlow();
return;
}
activityResponse.activity!.targetTokens = nextActivitySpecs.tokens;
return activityResponse.activity;
currentCompletionRecord = PracticeActivityRecordModel(
question: activity.question,
);
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: e,
s: s,
m: 'Failed to get new activity',
data: {
'activity': currentActivity,
'record': currentCompletionRecord,
'activity': currentActivity?.toJson(),
'record': currentCompletionRecord?.toJson(),
'targetTokens': widget.targetTokensAndActivityType.tokens
.map((token) => token.toJson())
.toList(),
'activityType': widget.targetTokensAndActivityType.activityType,
'morphFeature': widget.morphFeature,
},
);
return null;
debugger(when: kDebugMode);
} finally {
_updateFetchingActivity(false);
}
}
Future<PracticeActivityModel?> _fetchActivityModel({
ActivityQualityFeedback? activityFeedback,
}) async {
debugPrint(
"fetching activity model of type: ${widget.targetTokensAndActivityType.activityType}",
);
// check if we already have an activity matching the specs
final tokens = widget.targetTokensAndActivityType.tokens;
final type = widget.targetTokensAndActivityType.activityType;
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(),
);
if (type != ActivityTypeEnum.morphId || sameActivity == false) {
return sameActivity;
}
return widget.morphFeature ==
activity.practiceActivity.tgtConstructs
.firstWhereOrNull(
(c) => c.type == ConstructTypeEnum.morph,
)
?.category;
},
);
if (existingActivity != null &&
existingActivity.practiceActivity.content.answers.isNotEmpty &&
!(existingActivity.practiceActivity.content.answers.length == 1 &&
existingActivity.practiceActivity.content.answers.first.isEmpty)) {
currentActivityCompleter = Completer();
currentActivityCompleter!.complete(existingActivity);
existingActivity.practiceActivity.targetTokens = tokens;
return existingActivity.practiceActivity;
}
final req = MessageActivityRequest(
userL1: MatrixState.pangeaController.languageController.userL1!.langCode,
userL2: MatrixState.pangeaController.languageController.userL2!.langCode,
messageText: widget.pangeaMessageEvent.messageDisplayText,
messageTokens: widget.overlayController.tokens!,
activityQualityFeedback: activityFeedback,
targetTokens: tokens,
targetType: type,
targetMorphFeature: widget.morphFeature,
);
final PracticeActivityModelResponse activityResponse =
await practiceGenerationController.getPracticeActivity(
req,
widget.pangeaMessageEvent,
);
if (activityResponse.activity == null) return null;
currentActivityCompleter = activityResponse.eventCompleter;
activityResponse.activity!.targetTokens = tokens;
return activityResponse.activity;
}
ConstructUseMetaData get metadata => ConstructUseMetaData(
eventId: widget.pangeaMessageEvent.eventId,
roomId: widget.pangeaMessageEvent.room.id,
@ -206,9 +218,7 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
debugger(when: savoringTheJoy && kDebugMode);
if (mounted) setState(() => savoringTheJoy = true);
await Future.delayed(appropriateTimeForJoy);
await Future.delayed(const Duration(seconds: 1));
if (mounted) setState(() => savoringTheJoy = false);
} catch (e, s) {
debugger(when: kDebugMode);
@ -228,30 +238,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() async {
void onActivityFinish({String? correctAnswer}) async {
try {
if (currentCompletionRecord == null || currentActivity == null) {
debugger(when: kDebugMode);
return;
}
widget.overlayController.messageAnalyticsEntry!
.onActivityComplete(currentActivity!);
widget.overlayController.onActivityFinish();
pangeaController.activityRecordController.completeActivity(
widget.pangeaMessageEvent.eventId,
);
// wait for the joy to be savored before resolving the activity
// and setting it to replace the previous activity
final Iterable<dynamic> result = await Future.wait([
_savorTheJoy(),
_fetchActivity(),
]);
_setPracticeActivity(result.last as PracticeActivityModel?);
await _savorTheJoy();
widget.wordDetailsController?.onActivityFinish(
activityType: currentActivity!.activityType,
correctAnswer: correctAnswer,
);
} catch (e, s) {
_onError();
debugger(when: kDebugMode);
@ -268,79 +271,14 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
void _onError() {
widget.overlayController.messageAnalyticsEntry?.revealAllTokens();
_setPracticeActivity(null);
currentActivity = null;
widget.overlayController.exitPracticeFlow();
}
bool _isActivityRedaction(EventUpdate update, String activityId) {
return update.content.containsKey('type') &&
update.content['type'] == 'm.room.redaction' &&
update.content.containsKey('content') &&
update.content['content']['redacts'] == activityId;
}
/// clear the current activity, record, and selection
/// fetch a new activity, including the offending activity in the request
Future<void> submitFeedback(String feedback) async {
if (currentActivity == null || currentCompletionRecord == null) {
debugger(when: kDebugMode);
return;
}
if (currentActivityCompleter != null) {
final activityEvent = await currentActivityCompleter!.future;
if (activityEvent != null) {
await activityEvent.event.redactEvent(reason: feedback);
final eventID = activityEvent.event.eventId;
await activityEvent.event.room.client.onEvent.stream
.firstWhere(
(update) => _isActivityRedaction(update, eventID),
)
.timeout(const Duration(milliseconds: 2500));
}
} else {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: Exception('No completer found for current activity'),
data: {
'activity': currentActivity,
'record': currentCompletionRecord,
'feedback': feedback,
},
);
}
_fetchActivity(
activityFeedback: ActivityQualityFeedback(
feedbackText: feedback,
badActivity: currentActivity!,
),
).then((activity) {
_setPracticeActivity(activity);
}).catchError((onError) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: onError,
m: 'Failed to get new activity',
data: {
'activity': currentActivity,
'record': currentCompletionRecord,
},
);
// clear the current activity and record
currentActivity = null;
currentCompletionRecord = null;
widget.overlayController.exitPracticeFlow();
});
}
PangeaController get pangeaController => MatrixState.pangeaController;
/// The widget that displays the current activity.
/// If there is no current activity, the widget returns a sizedbox with a height of 80.
/// If the activity type is multiple choice, the widget returns a MultipleChoiceActivity.
/// If the activity type is unknown, the widget logs an error and returns a text widget with an error message.
// /// The widget that displays the current activity.
// /// If there is no current activity, the widget returns a sizedbox with a height of 80.
// /// If the activity type is multiple choice, the widget returns a MultipleChoiceActivity.
// /// If the activity type is unknown, the widget logs an error and returns a text widget with an error message.
Widget? get activityWidget {
switch (currentActivity?.activityType) {
case null:
@ -348,11 +286,15 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
case ActivityTypeEnum.wordFocusListening:
case ActivityTypeEnum.hiddenWordListening:
case ActivityTypeEnum.wordMeaning:
case ActivityTypeEnum.lemmaId:
case ActivityTypeEnum.emoji:
case ActivityTypeEnum.morphId:
return MultipleChoiceActivity(
practiceCardController: this,
currentActivity: currentActivity!,
event: widget.pangeaMessageEvent.event,
onError: _onError,
overlayController: widget.overlayController,
);
}
}
@ -360,7 +302,8 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
@override
Widget build(BuildContext context) {
if (!fetchingActivity && currentActivity == null) {
return const GamifiedTextWidget();
debugPrint("don't think we should be here");
debugger(when: kDebugMode);
}
return Stack(
@ -379,14 +322,14 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
const ToolbarContentLoadingIndicator(),
],
// Flag button in the top right corner
Positioned(
top: 0,
right: 0,
child: ContentIssueButton(
isActive: currentActivity != null,
submitFeedback: submitFeedback,
),
),
// Positioned(
// top: 0,
// right: 0,
// child: ContentIssueButton(
// isActive: currentActivity != null,
// submitFeedback: submitFeedback,
// ),
// ),
],
);
}

View file

@ -0,0 +1,102 @@
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:flutter/material.dart';
class WordTextWithAudioButton extends StatefulWidget {
final String text;
final TtsController ttsController;
final String eventID;
const WordTextWithAudioButton({
super.key,
required this.text,
required this.ttsController,
required this.eventID,
});
@override
WordAudioButtonState createState() => WordAudioButtonState();
}
class WordAudioButtonState extends State<WordTextWithAudioButton> {
bool _isPlaying = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (event) => setState(() {}),
onExit: (event) => setState(() {}),
child: GestureDetector(
onTap: () async {
if (_isPlaying) {
await widget.ttsController.stop();
if (mounted) {
setState(() => _isPlaying = false);
}
} else {
if (mounted) {
setState(() => _isPlaying = true);
}
try {
await widget.ttsController.tryToSpeak(
widget.text,
context,
widget.eventID,
);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"text": widget.text,
"eventID": widget.eventID,
},
);
} finally {
if (mounted) {
setState(() => _isPlaying = false);
}
}
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(4),
boxShadow: _isHovering
? [
BoxShadow(
color: Theme.of(context).colorScheme.secondary,
blurRadius: 4,
spreadRadius: 1,
),
]
: [],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: _isPlaying
? Theme.of(context).colorScheme.secondary
: null,
),
),
const SizedBox(width: 4),
Icon(
_isPlaying ? Icons.play_arrow : Icons.play_arrow_outlined,
size: Theme.of(context).textTheme.bodyMedium?.fontSize,
),
],
),
),
),
);
}
final bool _isHovering = false;
}

View file

@ -0,0 +1,87 @@
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/repo/full_text_translation_repo.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
class ContextualTranslationWidget extends StatefulWidget {
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);
}
@override
Widget build(BuildContext context) {
return Center(
child: SizedBox(
height: 60,
width: 60,
child: IconButton(
iconSize: 30,
onPressed: widget.onPressed,
icon: const Icon(Symbols.dictionary),
),
),
);
}
}

View file

@ -0,0 +1,70 @@
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/widgets/matrix.dart';
import 'package:flutter/material.dart';
class LemmaDefinitionWidget extends StatefulWidget {
final PangeaToken token;
final String tokenLang;
final VoidCallback onPressed;
const LemmaDefinitionWidget({
super.key,
required this.token,
required this.tokenLang,
required this.onPressed,
});
@override
LemmaDefinitionWidgetState createState() => LemmaDefinitionWidgetState();
}
class LemmaDefinitionWidgetState extends State<LemmaDefinitionWidget> {
late Future<String> _definition;
@override
void initState() {
super.initState();
_definition = _fetchDefinition();
}
Future<String> _fetchDefinition() async {
if (widget.token.shouldDoPosActivity) {
return '?';
} else {
final res = await LemmaDictionaryRepo.get(
LemmaDefinitionRequest(
lemma: widget.token.lemma.text,
partOfSpeech: widget.token.pos,
lemmaLang: widget.tokenLang,
userL1: MatrixState
.pangeaController.languageController.userL1?.langCode ??
LanguageKeys.defaultLanguage,
),
);
return res.definition;
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: _definition,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (snapshot.hasError) {
// TODO better error widget
return Text('Error: ${snapshot.error}');
} else {
return ActionChip(
avatar: const Icon(Icons.book),
label: Text(snapshot.data ?? 'No definition found'),
onPressed: widget.onPressed,
);
}
},
);
}
}

View file

@ -0,0 +1,35 @@
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:flutter/material.dart';
class LemmaWidget extends StatelessWidget {
final PangeaToken token;
final VoidCallback onPressed;
final String? lemma;
final Function(String) setLemma;
const LemmaWidget({
super.key,
required this.token,
required this.onPressed,
this.lemma,
required this.setLemma,
});
@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),
),
);
}
}

View file

@ -0,0 +1,236 @@
import 'dart:developer';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/enum/analytics/morph_categories_enum.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
class ActivityMorph {
final String morphFeature;
final String morphTag;
bool revealed;
ActivityMorph({
required this.morphFeature,
required this.morphTag,
required this.revealed,
});
}
class MorphologicalListWidget extends StatefulWidget {
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,
});
@override
MorphologicalListWidgetState createState() => MorphologicalListWidgetState();
}
class MorphologicalListWidgetState extends State<MorphologicalListWidget> {
// TODO: make this is a list of morphological features icons based on MorphActivityGenerator.getSequence
// For each item in the sequence,
// if shouldDoActivity is true, show the template icon then stop
// if shouldDoActivity is false, show the actual icon and value then go to the next item
final List<ActivityMorph> _morphs = [];
@override
void initState() {
super.initState();
_setMorphs();
}
@override
void didUpdateWidget(covariant MorphologicalListWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.token != oldWidget.token) {
_setMorphs();
}
if (widget.completedActivities != oldWidget.completedActivities &&
oldWidget.selectedMorphFeature != null) {
final oldSelectedMorphIndex = _morphs.indexWhere(
(morph) => morph.morphFeature == oldWidget.selectedMorphFeature,
);
if (oldSelectedMorphIndex != -1 &&
oldSelectedMorphIndex < _morphs.length) {
setState(
() => _morphs[oldSelectedMorphIndex].revealed = true,
);
final nextIndex = oldSelectedMorphIndex + 1;
if (nextIndex < _morphs.length) {
widget.setMorphFeature(_morphs[nextIndex].morphFeature);
} else {
widget.setMorphFeature(null);
}
}
}
}
Future<void> _setMorphs() async {
_morphs.clear();
final morphEntries = widget.token.morph.entries.toList();
for (final morphEntry in morphEntries) {
final morphFeature = morphEntry.key;
final morphTag = morphEntry.value;
final shouldDoActivity = widget.token.shouldDoMorphActivity(morphFeature);
final canGenerateDistractors = await widget.token.canGenerateDistractors(
ActivityTypeEnum.morphId,
morphFeature: morphFeature,
morphTag: morphTag,
);
_morphs.add(
ActivityMorph(
morphFeature: morphFeature,
morphTag: morphTag,
revealed: !shouldDoActivity || !canGenerateDistractors,
),
);
}
_morphs.sort((a, b) {
if (a.revealed && !b.revealed) {
return -1;
} else if (!a.revealed && b.revealed) {
return 1;
}
if (a.morphFeature.toLowerCase() == "pos") {
return -1;
} else if (b.morphFeature.toLowerCase() == "pos") {
return 1;
}
return a.morphFeature.compareTo(b.morphFeature);
});
}
List<ActivityMorph> get _visibleMorphs {
final lastRevealedIndex = _morphs.lastIndexWhere((morph) => morph.revealed);
// if none of the morphs are revealed, show only the first one
if (lastRevealedIndex == -1) {
return _morphs.take(1).toList();
}
// show all the revealed morphs + the first one with an activity
return _morphs.take(lastRevealedIndex + 2).toList();
}
// TODO Use the icons that Khue is creating
IconData _getIconForMorphFeature(String feature) {
// Define a function to get the icon based on the universal dependency morphological feature (key)
switch (feature.toLowerCase()) {
case 'number':
// google material 123 icon
return Icons.format_list_numbered;
case 'gender':
return Icons.wc;
case 'tense':
return Icons.access_time;
case 'mood':
return Icons.mood;
case 'person':
return Icons.person;
case 'case':
return Icons.format_list_bulleted;
case 'degree':
return Icons.trending_up;
case 'verbform':
return Icons.text_format;
case 'voice':
return Icons.record_voice_over;
case 'aspect':
return Icons.aspect_ratio;
case 'prontype':
return Icons.text_fields;
case 'numtype':
return Icons.format_list_numbered;
case 'poss':
return Icons.account_balance;
case 'reflex':
return Icons.refresh;
case 'foreign':
return Icons.language;
case 'abbr':
return Icons.text_format;
case 'nountype':
return Symbols.abc;
case 'pos':
return Symbols.toys_and_games;
default:
debugger(when: kDebugMode);
return Icons.help_outline;
}
}
@override
Widget build(BuildContext context) {
return Wrap(
children: _visibleMorphs.map((morph) {
return Padding(
padding: const EdgeInsets.all(2.0),
child: MorphologicalActivityButton(
onPressed: widget.setMorphFeature,
morphCategory: morph.morphFeature,
icon: _getIconForMorphFeature(morph.morphFeature),
isUnlocked: morph.revealed,
isSelected: widget.selectedMorphFeature == morph.morphFeature,
),
);
}).toList(),
);
}
}
class MorphologicalActivityButton extends StatelessWidget {
final Function(String) onPressed;
final String morphCategory;
final IconData icon;
final bool isUnlocked;
final bool isSelected;
const MorphologicalActivityButton({
required this.onPressed,
required this.morphCategory,
required this.icon,
this.isUnlocked = true,
this.isSelected = false,
super.key,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Tooltip(
message: getMorphologicalCategoryCopy(
morphCategory,
context,
),
child: Opacity(
opacity: (isUnlocked && !isSelected) ? 0.75 : 1,
child: IconButton(
onPressed: () => onPressed(morphCategory),
icon: Icon(icon),
color: isSelected ? Theme.of(context).colorScheme.primary : null,
),
),
),
],
);
}
}

View file

@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
class PartOfSpeechWidget extends StatefulWidget {
final PangeaToken token;
const PartOfSpeechWidget({super.key, required this.token});
@override
_PartOfSpeechWidgetState createState() => _PartOfSpeechWidgetState();
}
class _PartOfSpeechWidgetState extends State<PartOfSpeechWidget> {
late Future<String> _partOfSpeech;
@override
void initState() {
super.initState();
_partOfSpeech = _fetchPartOfSpeech();
}
Future<String> _fetchPartOfSpeech() async {
if (widget.token.shouldDoPosActivity) {
return '?';
} else {
return widget.token.pos;
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: _partOfSpeech,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {
return ActionChip(
avatar: const Icon(Icons.label),
label: Text(snapshot.data ?? 'No part of speech found'),
onPressed: () {
// Handle chip click
},
);
}
},
);
}
}

View file

@ -0,0 +1,289 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/utils/grammar/get_grammar_copy.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/emoji_practice_button.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/word_text_with_audio_button.dart';
import 'package:fluffychat/pangea/widgets/word_zoom/contextual_translation_widget.dart';
import 'package:fluffychat/pangea/widgets/word_zoom/lemma_widget.dart';
import 'package:fluffychat/pangea/widgets/word_zoom/morphological_widget.dart';
import 'package:flutter/material.dart';
enum WordZoomSelection {
translation,
emoji,
}
class WordZoomWidget extends StatefulWidget {
final PangeaToken token;
final PangeaMessageEvent messageEvent;
final TtsController tts;
final MessageOverlayController overlayController;
const WordZoomWidget({
super.key,
required this.token,
required this.messageEvent,
required this.tts,
required this.overlayController,
});
@override
WordZoomWidgetState createState() => WordZoomWidgetState();
}
class WordZoomWidgetState extends State<WordZoomWidget> {
ActivityTypeEnum? _activityType;
// morphological activities
String? _selectedMorphFeature;
/// used to trigger a rebuild of the morph activity
/// button when a morph activity is completed
int completedMorphActivities = 0;
// 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;
});
}
});
}
@override
void initState() {
super.initState();
_initCanGenerateActivity();
}
@override
void didUpdateWidget(covariant WordZoomWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.token != oldWidget.token) {
_clean();
_initCanGenerateActivity();
}
}
bool _showActivityCard(ActivityTypeEnum? activityType) {
if (activityType == null) return false;
final shouldDo = widget.token.shouldDoActivity(
a: activityType,
feature: _selectedMorphFeature,
tag: _selectedMorphFeature == null
? null
: widget.token.morph[_selectedMorphFeature],
);
return canGenerateActivity[activityType]! && shouldDo;
}
void _clean() {
if (mounted) {
setState(() {
_activityType = null;
_selectedMorphFeature = null;
_definition = null;
_lemma = null;
_emoji = null;
});
}
}
void _setSelectedMorphFeature(String? feature) {
_selectedMorphFeature = _selectedMorphFeature == feature ? null : feature;
_setActivityType(
_selectedMorphFeature == null ? null : ActivityTypeEnum.morphId,
);
}
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;
}
}
Widget get _activityAnswer {
switch (_activityType) {
case ActivityTypeEnum.morphId:
if (_selectedMorphFeature == null) {
return const Text("There should be a selected morph feature");
}
final String morphTag = widget.token.morph[_selectedMorphFeature!];
final copy = getGrammarCopy(
category: _selectedMorphFeature!,
lemma: morphTag,
context: context,
);
return Text(copy ?? morphTag);
case ActivityTypeEnum.wordMeaning:
return _definition != null
? Text(_definition!)
: const Text("defintion 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:
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
? null
: 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
? null
: ActivityTypeEnum.lemmaId,
),
lemma: _lemma,
setLemma: _setLemma,
),
],
),
),
if (_activityType != null)
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_showActivityCard(_activityType))
PracticeActivityCard(
pangeaMessageEvent: widget.messageEvent,
targetTokensAndActivityType:
TargetTokensAndActivityType(
tokens: [widget.token],
activityType: _activityType!,
),
overlayController: widget.overlayController,
morphFeature: _selectedMorphFeature,
wordDetailsController: this,
)
else
_activityAnswer,
],
)
else
ContextualTranslationWidget(
token: widget.token,
fullText: widget.messageEvent.messageDisplayText,
langCode: widget.messageEvent.messageDisplayLangCode,
onPressed: () =>
_setActivityType(ActivityTypeEnum.wordMeaning),
definition: _definition,
setDefinition: _setDefinition,
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
MorphologicalListWidget(
token: widget.token,
setMorphFeature: _setSelectedMorphFeature,
selectedMorphFeature: _selectedMorphFeature,
completedActivities: completedMorphActivities,
),
],
),
],
),
),
),
);
}
}

View file

@ -117,6 +117,7 @@ abstract class ClientManager {
PangeaEventTypes.botOptions,
PangeaEventTypes.capacity,
EventTypes.RoomPowerLevels,
PangeaEventTypes.userChosenEmoji,
// Pangea#
},
logLevel: kReleaseMode ? Level.warning : Level.verbose,

View file

@ -353,6 +353,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
dart_levenshtein:
dependency: "direct main"
description:
name: dart_levenshtein
sha256: f38182278b774cbb0d5993de50848e65da5a9f722b7f07787f0c26de009e0322
url: "https://pub.dev"
source: hosted
version: "1.0.1"
dart_webrtc:
dependency: "direct overridden"
description:

View file

@ -142,6 +142,7 @@ dependencies:
rive: 0.11.11
text_to_speech: ^0.2.3
flutter_tts: ^4.2.0
dart_levenshtein: ^1.0.1
# Pangea#
dev_dependencies: