Merge branch 'main' into fluffychat-merge-2

This commit is contained in:
ggurdin 2026-02-03 10:52:14 -05:00
commit dbeae58a6b
No known key found for this signature in database
GPG key ID: A01CB41737CBB478
22 changed files with 571 additions and 202 deletions

View file

@ -35,8 +35,6 @@ abstract class SettingKeys {
static const String displayNavigationRail =
'chat.fluffy.display_navigation_rail';
// #Pangea
static const String useActivityImageAsChatBackground =
'pangea.use_activity_image_as_chat_background';
static const String volume = 'pangea.volume';
static const String showedActivityMenu =
'pangea.showed_activity_menu_tutorial';

View file

@ -76,10 +76,25 @@ class ChatSearchController extends State<ChatSearchPage>
(result) => (
<String, Event>{
for (final event in result.$1) event.eventId: event,
}.values.toList(),
// #Pangea
// }.values.toList(),
}
.values
.toList()
.where(
(e) => !e.hasAggregatedEvents(
timeline,
RelationshipTypes.edit,
),
)
.toList(),
// Pangea#
result.$2,
),
)
// #Pangea
.where((result) => result.$1.isNotEmpty)
// Pangea#
.asBroadcastStream();
});
}

View file

@ -61,6 +61,9 @@ class Settings3PidController extends State<Settings3Pid> {
auth: auth,
),
),
// #Pangea
showError: (e) => !e.toString().contains("Request has been canceled"),
// Pangea#
);
if (success.error != null) return;
setState(() => request = null);

View file

@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/user/style_settings_repo.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'settings_chat_view.dart';
class SettingsChat extends StatefulWidget {
@ -10,6 +13,15 @@ class SettingsChat extends StatefulWidget {
}
class SettingsChatController extends State<SettingsChat> {
// #Pangea
Future<void> setUseActivityImageBackground(bool value) async {
final userId = Matrix.of(context).client.userID!;
AppConfig.useActivityImageAsChatBackground = value;
setState(() {});
await StyleSettingsRepo.setUseActivityImageBackground(userId, value);
}
// Pangea#
@override
Widget build(BuildContext context) => SettingsChatView(this);
}

View file

@ -73,12 +73,10 @@ class SettingsChatView extends StatelessWidget {
),
// #Pangea
SettingsSwitchListTile.adaptive(
title: L10n.of(context).useActivityImageAsChatBackground,
onChanged: (b) =>
AppConfig.useActivityImageAsChatBackground = b,
storeKey: SettingKeys.useActivityImageAsChatBackground,
defaultValue: AppConfig.useActivityImageAsChatBackground,
SwitchListTile.adaptive(
value: AppConfig.useActivityImageAsChatBackground,
title: Text(L10n.of(context).useActivityImageAsChatBackground),
onChanged: controller.setUseActivityImageBackground,
),
// Divider(color: theme.dividerColor),
// ListTile(

View file

@ -242,6 +242,7 @@ class AnalyticsDataService {
String? roomId,
DateTime? since,
ConstructUseTypeEnum? type,
bool filterCapped = true,
}) async {
await _ensureInitialized();
final uses = await _analyticsClientGetter.database.getUses(
@ -264,7 +265,8 @@ class AnalyticsDataService {
cappedLastUseCache[use.identifier] = constructs.cappedLastUse;
}
final cappedLastUse = cappedLastUseCache[use.identifier];
if (cappedLastUse != null && use.timeStamp.isAfter(cappedLastUse)) {
if (filterCapped &&
(cappedLastUse != null && use.timeStamp.isAfter(cappedLastUse))) {
continue;
}
filtered.add(use);

View file

@ -23,7 +23,6 @@ import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
import 'package:fluffychat/pangea/text_to_speech/tts_controller.dart';
import 'package:fluffychat/pangea/toolbar/message_practice/practice_record_controller.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
@ -101,6 +100,8 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
final ValueNotifier<SelectedMorphChoice?> selectedMorphChoice =
ValueNotifier<SelectedMorphChoice?>(null);
final ValueNotifier<bool> hintPressedNotifier = ValueNotifier<bool>(false);
final Map<String, Map<String, String>> _choiceTexts = {};
final Map<String, Map<String, String?>> _choiceEmojis = {};
@ -125,6 +126,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
progressNotifier.dispose();
enableChoicesNotifier.dispose();
selectedMorphChoice.dispose();
hintPressedNotifier.dispose();
super.dispose();
}
@ -210,6 +212,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
activityState.value = const AsyncState.loading();
activityTarget.value = null;
selectedMorphChoice.value = null;
hintPressedNotifier.value = false;
enableChoicesNotifier.value = true;
progressNotifier.value = 0.0;
_queue.clear();
@ -282,6 +285,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
try {
activityState.value = const AsyncState.loading();
selectedMorphChoice.value = null;
hintPressedNotifier.value = false;
final req = activityTarget.value!;
final res = await _fetchActivity(req);
@ -324,6 +328,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
while (_queue.isNotEmpty) {
activityState.value = const AsyncState.loading();
selectedMorphChoice.value = null;
hintPressedNotifier.value = false;
final nextActivityCompleter = _queue.removeFirst();
try {
@ -477,6 +482,10 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
await _analyticsService.updateService.addAnalytics(null, [use]);
}
void onHintPressed() {
hintPressedNotifier.value = !hintPressedNotifier.value;
}
Future<void> onSelectChoice(
String choiceContent,
) async {
@ -528,21 +537,19 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
}
Future<List<InlineSpan>?> getExampleMessage(
PracticeTarget target,
MessageActivityRequest activityRequest,
) async {
final target = activityRequest.target;
final token = target.tokens.first;
final construct = target.targetTokenConstructID(token);
String? form;
if (widget.type == ConstructTypeEnum.morph) {
if (target.morphFeature == null) return null;
form = token.lemma.form;
return activityRequest.morphExampleInfo?.exampleMessage;
}
return ExampleMessageUtil.getExampleMessage(
await _analyticsService.getConstructUse(construct),
Matrix.of(context).client,
form: form,
);
}

View file

@ -1,21 +1,70 @@
import 'package:flutter/painting.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
class MorphExampleInfo {
final List<InlineSpan> exampleMessage;
const MorphExampleInfo({
required this.exampleMessage,
});
Map<String, dynamic> toJson() {
final segments = <Map<String, dynamic>>[];
for (final span in exampleMessage) {
if (span is TextSpan) {
segments.add({
'text': span.text ?? '',
'isBold': span.style?.fontWeight == FontWeight.bold,
});
}
}
return {
'segments': segments,
};
}
factory MorphExampleInfo.fromJson(Map<String, dynamic> json) {
final segments = json['segments'] as List<dynamic>? ?? [];
final spans = <InlineSpan>[];
for (final segment in segments) {
final text = segment['text'] as String? ?? '';
final isBold = segment['isBold'] as bool? ?? false;
spans.add(
TextSpan(
text: text,
style: isBold ? const TextStyle(fontWeight: FontWeight.bold) : null,
),
);
}
return MorphExampleInfo(exampleMessage: spans);
}
}
class AnalyticsActivityTarget {
final PracticeTarget target;
final GrammarErrorRequestInfo? grammarErrorInfo;
final MorphExampleInfo? morphExampleInfo;
AnalyticsActivityTarget({
required this.target,
this.grammarErrorInfo,
this.morphExampleInfo,
});
Map<String, dynamic> toJson() => {
'target': target.toJson(),
'grammarErrorInfo': grammarErrorInfo?.toJson(),
'morphExampleInfo': morphExampleInfo?.toJson(),
};
factory AnalyticsActivityTarget.fromJson(Map<String, dynamic> json) =>
@ -24,6 +73,9 @@ class AnalyticsActivityTarget {
grammarErrorInfo: json['grammarErrorInfo'] != null
? GrammarErrorRequestInfo.fromJson(json['grammarErrorInfo'])
: null,
morphExampleInfo: json['morphExampleInfo'] != null
? MorphExampleInfo.fromJson(json['morphExampleInfo'])
: null,
);
}
@ -79,6 +131,7 @@ class AnalyticsPracticeSessionModel {
activityQualityFeedback: null,
target: target.target,
grammarErrorInfo: target.grammarErrorInfo,
morphExampleInfo: target.morphExampleInfo,
);
}).toList();
}

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/example_message_util.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
import 'package:fluffychat/pangea/common/network/requests.dart';
@ -12,8 +13,11 @@ import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/languages/language_constants.dart';
import 'package:fluffychat/pangea/lemmas/lemma.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_repo.dart';
import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_request.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
@ -63,15 +67,18 @@ class AnalyticsPracticeSessionRepo {
final remainingCount = (AnalyticsPracticeConstants.practiceGroupSize +
AnalyticsPracticeConstants.errorBufferSize) -
targets.length;
final morphEntries = morphs.entries.take(remainingCount);
final morphEntries = morphs.take(remainingCount);
for (final entry in morphEntries) {
targets.add(
AnalyticsActivityTarget(
target: PracticeTarget(
tokens: [entry.key],
tokens: [entry.token],
activityType: ActivityTypeEnum.grammarCategory,
morphFeature: entry.value,
morphFeature: entry.feature,
),
morphExampleInfo: MorphExampleInfo(
exampleMessage: entry.exampleMessage,
),
),
);
@ -125,12 +132,37 @@ class AnalyticsPracticeSessionRepo {
return targets;
}
static Future<Map<PangeaToken, MorphFeaturesEnum>> _fetchMorphs() async {
static Future<List<MorphPracticeTarget>> _fetchMorphs() async {
final constructs = await MatrixState
.pangeaController.matrixState.analyticsDataService
.getAggregatedConstructs(ConstructTypeEnum.morph)
.then((map) => map.values.toList());
final morphInfoRequest = MorphInfoRequest(
userL1: MatrixState.pangeaController.userController.userL1?.langCode ??
LanguageKeys.defaultLanguage,
userL2: MatrixState.pangeaController.userController.userL2?.langCode ??
LanguageKeys.defaultLanguage,
);
final morphInfoResult = await MorphInfoRepo.get(
MatrixState.pangeaController.userController.accessToken,
morphInfoRequest,
);
// Build list of features with multiple tags (valid for practice)
final List<String> validFeatures = [];
if (!morphInfoResult.isError) {
final response = morphInfoResult.asValue?.value;
if (response != null) {
for (final feature in response.features) {
if (feature.tags.length > 1) {
validFeatures.add(feature.code);
}
}
}
}
// sort by last used descending, nulls first
constructs.sort((a, b) {
final dateA = a.lastUsed;
@ -141,7 +173,7 @@ class AnalyticsPracticeSessionRepo {
return dateA.compareTo(dateB);
});
final targets = <PangeaToken, MorphFeaturesEnum>{};
final targets = <MorphPracticeTarget>[];
final Set<String> seenForms = {};
for (final entry in constructs) {
@ -152,10 +184,14 @@ class AnalyticsPracticeSessionRepo {
}
final feature = MorphFeaturesEnumExtension.fromString(entry.id.category);
if (feature == MorphFeaturesEnum.Unknown) {
// Only include features that are in the valid list (have multiple tags)
if (feature == MorphFeaturesEnum.Unknown ||
(validFeatures.isNotEmpty && !validFeatures.contains(feature.name))) {
continue;
}
List<InlineSpan>? exampleMessage;
for (final use in entry.cappedUses) {
if (targets.length >=
(AnalyticsPracticeConstants.practiceGroupSize +
@ -169,6 +205,17 @@ class AnalyticsPracticeSessionRepo {
continue;
}
exampleMessage = await ExampleMessageUtil.getExampleMessage(
await MatrixState.pangeaController.matrixState.analyticsDataService
.getConstructUse(entry.id),
MatrixState.pangeaController.matrixState.client,
form: form,
);
if (exampleMessage == null) {
continue;
}
seenForms.add(form);
final token = PangeaToken(
lemma: Lemma(
@ -180,7 +227,13 @@ class AnalyticsPracticeSessionRepo {
pos: 'other',
morph: {feature: use.lemma},
);
targets[token] = feature;
targets.add(
MorphPracticeTarget(
feature: feature,
token: token,
exampleMessage: exampleMessage,
),
);
break;
}
}
@ -189,14 +242,32 @@ class AnalyticsPracticeSessionRepo {
}
static Future<List<AnalyticsActivityTarget>> _fetchErrors() async {
final uses = await MatrixState
// Fetch all recent uses in one call (not filtering blocked constructs)
final allRecentUses = await MatrixState
.pangeaController.matrixState.analyticsDataService
.getUses(count: 100, type: ConstructUseTypeEnum.ga);
.getUses(count: 200, filterCapped: false);
// Filter for grammar error uses
final grammarErrorUses = allRecentUses
.where((use) => use.useType == ConstructUseTypeEnum.ga)
.toList();
// Create list of recently used constructs
final cutoffTime = DateTime.now().subtract(const Duration(hours: 24));
final recentlyPracticedConstructs = allRecentUses
.where(
(use) =>
use.metadata.timeStamp.isAfter(cutoffTime) &&
(use.useType == ConstructUseTypeEnum.corGE ||
use.useType == ConstructUseTypeEnum.incGE),
)
.map((use) => use.identifier)
.toSet();
final client = MatrixState.pangeaController.matrixState.client;
final Map<String, PangeaMessageEvent?> idsToEvents = {};
for (final use in uses) {
for (final use in grammarErrorUses) {
final eventID = use.metadata.eventId;
if (eventID == null || idsToEvents.containsKey(eventID)) continue;
@ -257,7 +328,11 @@ class AnalyticsPracticeSessionRepo {
if (igcMatch!.match.offset == 0 &&
igcMatch.match.length >= stepText.trim().characters.length) {
// Skip if the grammar error spans the entire step
continue;
}
if (igcMatch.match.isNormalizationError()) {
// Skip normalization errors
continue;
}
@ -272,8 +347,22 @@ class AnalyticsPracticeSessionRepo {
)
.toList();
// Skip if no valid tokens found for this grammar error
if (choiceTokens.isEmpty) continue;
// Skip if no valid tokens found for this grammar error, or only one answer
if (choiceTokens.length <= 1) {
continue;
}
final firstToken = choiceTokens.first;
final tokenIdentifier = ConstructIdentifier(
lemma: firstToken.lemma.text,
type: ConstructTypeEnum.vocab,
category: firstToken.pos,
);
final hasRecentPractice =
recentlyPracticedConstructs.contains(tokenIdentifier);
if (hasRecentPractice) continue;
String? translation;
try {
@ -291,6 +380,7 @@ class AnalyticsPracticeSessionRepo {
}
if (translation == null) continue;
targets.add(
AnalyticsActivityTarget(
target: PracticeTarget(
@ -312,3 +402,15 @@ class AnalyticsPracticeSessionRepo {
return targets;
}
}
class MorphPracticeTarget {
final PangeaToken token;
final MorphFeaturesEnum feature;
final List<InlineSpan> exampleMessage;
MorphPracticeTarget({
required this.token,
required this.feature,
required this.exampleMessage,
});
}

View file

@ -16,6 +16,7 @@ import 'package:fluffychat/pangea/analytics_practice/practice_timer_widget.dart'
import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart';
import 'package:fluffychat/pangea/common/utils/async_state.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/common/widgets/pressable_button.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_widget.dart';
@ -161,28 +162,7 @@ class _AnalyticsActivityView extends StatelessWidget {
const SizedBox(height: 16.0),
_ActivityChoicesWidget(controller),
const SizedBox(height: 16.0),
ListenableBuilder(
listenable: Listenable.merge([
controller.activityState,
controller.selectedMorphChoice,
]),
builder: (context, _) {
final activityState = controller.activityState.value;
final selectedChoice = controller.selectedMorphChoice.value;
if (activityState
is! AsyncLoaded<MultipleChoicePracticeActivityModel> ||
selectedChoice == null) {
return const SizedBox.shrink();
}
return MorphMeaningWidget(
feature: selectedChoice.feature,
tag: selectedChoice.tag,
blankErrorFeedback: true,
);
},
),
_WrongAnswerFeedback(controller: controller),
],
);
}
@ -214,15 +194,12 @@ class _AnalyticsPracticeCenterContent extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
_ErrorBlankWidget(
activity: activity,
),
const SizedBox(height: 12),
_GrammarErrorTranslationButton(
key: ValueKey(
'${activity.eventID}_${activity.errorOffset}_${activity.errorLength}',
),
translation: activity.translation,
activity: activity,
),
const SizedBox(height: 12),
],
),
_ => const SizedBox(),
@ -230,11 +207,31 @@ class _AnalyticsPracticeCenterContent extends StatelessWidget {
),
),
),
ActivityTypeEnum.grammarCategory => Center(
child: Column(
children: [
_CorrectAnswerHint(controller: controller),
_ExampleMessageWidget(
controller.getExampleMessage(target!),
),
const SizedBox(height: 12),
ValueListenableBuilder(
valueListenable: controller.hintPressedNotifier,
builder: (context, hintPressed, __) {
return HintButton(
depressed: hintPressed,
onPressed: controller.onHintPressed,
);
},
),
],
),
),
_ => SizedBox(
height: 100.0,
child: Center(
child: _ExampleMessageWidget(
controller.getExampleMessage(target!.target),
controller.getExampleMessage(target!),
),
),
),
@ -284,18 +281,123 @@ class _ExampleMessageWidget extends StatelessWidget {
}
}
class _ErrorBlankWidget extends StatelessWidget {
final GrammarErrorPracticeActivityModel activity;
class _CorrectAnswerHint extends StatelessWidget {
final AnalyticsPracticeState controller;
const _ErrorBlankWidget({
required this.activity,
const _CorrectAnswerHint({
required this.controller,
});
@override
Widget build(BuildContext context) {
final text = activity.text;
final errorOffset = activity.errorOffset;
final errorLength = activity.errorLength;
return ValueListenableBuilder(
valueListenable: controller.hintPressedNotifier,
builder: (context, hintPressed, __) {
if (!hintPressed) {
return const SizedBox.shrink();
}
return ValueListenableBuilder(
valueListenable: controller.activityState,
builder: (context, state, __) {
if (state is! AsyncLoaded<MultipleChoicePracticeActivityModel>) {
return const SizedBox.shrink();
}
final activity = state.value;
if (activity is! MorphPracticeActivityModel) {
return const SizedBox.shrink();
}
final correctAnswerTag =
activity.multipleChoiceContent.answers.first;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: MorphMeaningWidget(
feature: activity.morphFeature,
tag: correctAnswerTag,
),
);
},
);
},
);
}
}
class _WrongAnswerFeedback extends StatelessWidget {
final AnalyticsPracticeState controller;
const _WrongAnswerFeedback({
required this.controller,
});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: Listenable.merge([
controller.activityState,
controller.selectedMorphChoice,
]),
builder: (context, _) {
final activityState = controller.activityState.value;
final selectedChoice = controller.selectedMorphChoice.value;
if (activityState
is! AsyncLoaded<MultipleChoicePracticeActivityModel> ||
selectedChoice == null) {
return const SizedBox.shrink();
}
final activity = activityState.value;
final isWrongAnswer =
!activity.multipleChoiceContent.isCorrect(selectedChoice.tag);
if (!isWrongAnswer) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: MorphMeaningWidget(
feature: selectedChoice.feature,
tag: selectedChoice.tag,
blankErrorFeedback: true,
),
);
},
);
}
}
class _ErrorBlankWidget extends StatefulWidget {
final GrammarErrorPracticeActivityModel activity;
const _ErrorBlankWidget({
super.key,
required this.activity,
});
@override
State<_ErrorBlankWidget> createState() => _ErrorBlankWidgetState();
}
class _ErrorBlankWidgetState extends State<_ErrorBlankWidget> {
late final String translation = widget.activity.translation;
bool _showTranslation = false;
void _toggleTranslation() {
setState(() {
_showTranslation = !_showTranslation;
});
}
@override
Widget build(BuildContext context) {
final text = widget.activity.text;
final errorOffset = widget.activity.errorOffset;
final errorLength = widget.activity.errorLength;
const maxContextChars = 50;
@ -342,122 +444,107 @@ class _ErrorBlankWidget extends StatelessWidget {
final after = chars.skip(errorEnd).take(afterEnd - errorEnd).toString();
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Color.alphaBlend(
Colors.white.withAlpha(180),
ThemeData.dark().colorScheme.primary,
),
borderRadius: BorderRadius.circular(16),
),
child: RichText(
text: TextSpan(
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize,
return Column(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
children: [
if (trimmedBefore) const TextSpan(text: ''),
if (before.isNotEmpty) TextSpan(text: before),
WidgetSpan(
child: Container(
height: 4.0,
width: (errorLength * 8).toDouble(),
padding: const EdgeInsets.only(bottom: 2.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
decoration: BoxDecoration(
color: Color.alphaBlend(
Colors.white.withAlpha(180),
ThemeData.dark().colorScheme.primary,
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
RichText(
text: TextSpan(
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize:
AppConfig.fontSizeFactor * AppConfig.messageFontSize,
),
children: [
if (trimmedBefore) const TextSpan(text: ''),
if (before.isNotEmpty) TextSpan(text: before),
WidgetSpan(
child: Container(
height: 4.0,
width: (errorLength * 8).toDouble(),
padding: const EdgeInsets.only(bottom: 2.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
),
),
),
if (after.isNotEmpty) TextSpan(text: after),
if (trimmedAfter) const TextSpan(text: ''),
],
),
),
),
if (after.isNotEmpty) TextSpan(text: after),
if (trimmedAfter) const TextSpan(text: ''),
],
const SizedBox(height: 8),
_showTranslation
? Text(
translation,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize: AppConfig.fontSizeFactor *
AppConfig.messageFontSize,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.left,
)
: const SizedBox.shrink(),
],
),
),
),
const SizedBox(height: 8),
HintButton(depressed: _showTranslation, onPressed: _toggleTranslation),
],
);
}
}
class _GrammarErrorTranslationButton extends StatefulWidget {
final String translation;
class HintButton extends StatelessWidget {
final VoidCallback onPressed;
final bool depressed;
const _GrammarErrorTranslationButton({
const HintButton({
required this.onPressed,
required this.depressed,
super.key,
required this.translation,
});
@override
State<_GrammarErrorTranslationButton> createState() =>
_GrammarErrorTranslationButtonState();
}
class _GrammarErrorTranslationButtonState
extends State<_GrammarErrorTranslationButton> {
bool _showTranslation = false;
void _toggleTranslation() {
if (_showTranslation) {
setState(() {
_showTranslation = false;
});
} else {
setState(() {
_showTranslation = true;
});
}
}
@override
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
onTap: _toggleTranslation,
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8.0,
children: [
if (_showTranslation)
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Color.alphaBlend(
Colors.white.withAlpha(180),
ThemeData.dark().colorScheme.primary,
),
borderRadius: BorderRadius.circular(16),
),
child: Text(
widget.translation,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize:
AppConfig.fontSizeFactor * AppConfig.messageFontSize,
),
),
),
)
else
ElevatedButton(
onPressed: _toggleTranslation,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(8),
),
child: const Icon(
Icons.lightbulb_outline,
size: 20,
),
),
],
),
return PressableButton(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.primaryContainer,
onPressed: onPressed,
depressed: depressed,
playSound: true,
colorFactor: 0.3,
builder: (context, depressed, shadowColor) => Stack(
alignment: Alignment.center,
children: [
Container(
height: 40.0,
width: 40.0,
decoration: BoxDecoration(
color: depressed
? shadowColor
: Theme.of(context).colorScheme.primaryContainer,
shape: BoxShape.circle,
),
),
const Icon(
Icons.lightbulb_outline,
size: 20,
),
],
),
);
}

View file

@ -35,7 +35,7 @@ class GrammarChoiceCard extends StatelessWidget {
final baseTextSize =
(Theme.of(context).textTheme.titleMedium?.fontSize ?? 16) *
(height / 72.0).clamp(1.0, 1.4);
final emojiSize = baseTextSize * 1.2;
final emojiSize = baseTextSize * 1.5;
final copy = getGrammarCopy(
category: feature.name,
lemma: tag,
@ -54,7 +54,7 @@ class GrammarChoiceCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: height * .7,
width: height,
height: height,
child: Center(
child: MorphIcon(

View file

@ -1,3 +1,4 @@
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/morphs/default_morph_mapping.dart';
import 'package:fluffychat/pangea/morphs/morph_models.dart';
@ -58,6 +59,8 @@ class MorphCategoryActivityGenerator {
choices: choices.toSet(),
answers: {morphTag},
),
morphExampleInfo:
req.morphExampleInfo ?? const MorphExampleInfo(exampleMessage: []),
),
);
}

View file

@ -24,7 +24,7 @@ class BotOptionsModel {
final String? textAdventureGameMasterInstructions;
final String? targetLanguage;
final String? targetVoice;
final GenderEnum targetGender;
final Map<String, GenderEnum> userGenders;
const BotOptionsModel({
////////////////////////////////////////////////////////////////////////////
@ -37,7 +37,7 @@ class BotOptionsModel {
this.mode = BotMode.discussion,
this.targetLanguage,
this.targetVoice,
this.targetGender = GenderEnum.unselected,
this.userGenders = const {},
////////////////////////////////////////////////////////////////////////////
// Discussion Mode Options
@ -61,6 +61,22 @@ class BotOptionsModel {
});
factory BotOptionsModel.fromJson(json) {
final genderEntry = json[ModelKey.targetGender];
Map<String, GenderEnum> targetGenders = {};
if (genderEntry is Map<String, dynamic>) {
targetGenders = Map<String, GenderEnum>.fromEntries(
genderEntry.entries.map(
(e) => MapEntry(
e.key,
GenderEnum.values.firstWhere(
(g) => g.name == e.value,
orElse: () => GenderEnum.unselected,
),
),
),
);
}
return BotOptionsModel(
//////////////////////////////////////////////////////////////////////////
// General Bot Options
@ -76,12 +92,7 @@ class BotOptionsModel {
mode: json[ModelKey.mode] ?? BotMode.discussion,
targetLanguage: json[ModelKey.targetLanguage],
targetVoice: json[ModelKey.targetVoice],
targetGender: json[ModelKey.targetGender] != null
? GenderEnum.values.firstWhere(
(g) => g.name == json[ModelKey.targetGender],
orElse: () => GenderEnum.unselected,
)
: GenderEnum.unselected,
userGenders: targetGenders,
//////////////////////////////////////////////////////////////////////////
// Discussion Mode Options
@ -112,6 +123,11 @@ class BotOptionsModel {
Map<String, dynamic> toJson() {
final data = <String, dynamic>{};
try {
final Map<String, String> gendersEntry = {};
for (final entry in userGenders.entries) {
gendersEntry[entry.key] = entry.value.name;
}
// data[ModelKey.isConversationBotChat] = isConversationBotChat;
data[ModelKey.languageLevel] = languageLevel.storageInt;
data[ModelKey.safetyModeration] = safetyModeration;
@ -130,7 +146,7 @@ class BotOptionsModel {
data[ModelKey.customTriggerReactionKey] = customTriggerReactionKey ?? "";
data[ModelKey.textAdventureGameMasterInstructions] =
textAdventureGameMasterInstructions;
data[ModelKey.targetGender] = targetGender.name;
data[ModelKey.targetGender] = gendersEntry;
return data;
} catch (e, s) {
debugger(when: kDebugMode);
@ -159,7 +175,7 @@ class BotOptionsModel {
String? textAdventureGameMasterInstructions,
String? targetLanguage,
String? targetVoice,
GenderEnum? targetGender,
Map<String, GenderEnum>? userGenders,
}) {
return BotOptionsModel(
languageLevel: languageLevel ?? this.languageLevel,
@ -183,7 +199,7 @@ class BotOptionsModel {
this.textAdventureGameMasterInstructions,
targetLanguage: targetLanguage ?? this.targetLanguage,
targetVoice: targetVoice ?? this.targetVoice,
targetGender: targetGender ?? this.targetGender,
userGenders: userGenders ?? this.userGenders,
);
}
}

View file

@ -9,6 +9,7 @@ import 'package:fluffychat/pangea/chat_settings/constants/bot_mode.dart';
import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/learning_settings/gender_enum.dart';
import 'package:fluffychat/pangea/user/user_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -64,15 +65,22 @@ extension BotClientExtension on Client {
if (botOptions.targetLanguage == targetLanguage &&
botOptions.languageLevel == languageLevel &&
botOptions.targetVoice == voice &&
botOptions.targetGender == gender) {
botOptions.userGenders[userID] == gender) {
continue;
}
final updatedGenders =
Map<String, GenderEnum>.from(botOptions.userGenders);
if (updatedGenders[userID] != gender) {
updatedGenders[userID!] = gender;
}
final updated = botOptions.copyWith(
targetLanguage: targetLanguage,
languageLevel: languageLevel,
targetVoice: voice,
targetGender: gender,
userGenders: updatedGenders,
);
futures.add(targetBotRoom.setBotOptions(updated));
}

View file

@ -66,8 +66,10 @@ class PangeaController {
});
subscriptionController.reinitialize();
StyleSettingsRepo.fontSizeFactor(userID!).then((factor) {
AppConfig.fontSizeFactor = factor;
StyleSettingsRepo.settings(userID!).then((settings) {
AppConfig.fontSizeFactor = settings.fontSizeFactor;
AppConfig.useActivityImageAsChatBackground =
settings.useActivityImageBackground;
});
}

View file

@ -312,6 +312,7 @@ class OverlayUtil {
closePrevOverlay: false,
backDropToDismiss: false,
ignorePointer: true,
canPop: false,
);
}

View file

@ -133,7 +133,6 @@ class SettingsLearningController extends State<SettingsLearning> {
waitForDataInSync: true,
),
onError: (e, s) {
debugPrint("Error resetting instruction tooltips: $e");
debugger(when: kDebugMode);
ErrorHandler.logError(
e: e,

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
import 'package:fluffychat/pangea/choreographer/choreo_record_model.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
@ -79,6 +80,7 @@ class MessageActivityRequest {
final PracticeTarget target;
final ActivityQualityFeedback? activityQualityFeedback;
final GrammarErrorRequestInfo? grammarErrorInfo;
final MorphExampleInfo? morphExampleInfo;
MessageActivityRequest({
required this.userL1,
@ -86,6 +88,7 @@ class MessageActivityRequest {
required this.activityQualityFeedback,
required this.target,
this.grammarErrorInfo,
this.morphExampleInfo,
}) {
if (target.tokens.isEmpty) {
throw Exception('Target tokens must not be empty');

View file

@ -3,6 +3,7 @@ import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
@ -111,6 +112,9 @@ sealed class PracticeActivityModel {
tokens: tokens,
morphFeature: morph!,
multipleChoiceContent: multipleChoiceContent!,
morphExampleInfo: json['morph_example_info'] != null
? MorphExampleInfo.fromJson(json['morph_example_info'])
: const MorphExampleInfo(exampleMessage: []),
);
case ActivityTypeEnum.lemmaAudio:
assert(
@ -302,11 +306,13 @@ sealed class MorphPracticeActivityModel
}
class MorphCategoryPracticeActivityModel extends MorphPracticeActivityModel {
final MorphExampleInfo morphExampleInfo;
MorphCategoryPracticeActivityModel({
required super.tokens,
required super.langCode,
required super.morphFeature,
required super.multipleChoiceContent,
required this.morphExampleInfo,
});
@override
@ -330,6 +336,13 @@ class MorphCategoryPracticeActivityModel extends MorphPracticeActivityModel {
xp: useType.pointValue,
);
}
@override
Map<String, dynamic> toJson() {
final json = super.toJson();
json['morph_example_info'] = morphExampleInfo.toJson();
return json;
}
}
class MorphMatchPracticeActivityModel extends MorphPracticeActivityModel {

View file

@ -83,10 +83,12 @@ class _NewWordOverlayState extends State<NewWordOverlay>
@override
void dispose() {
_controller?.dispose();
MatrixState.pAnyState.closeOverlay(widget.transformTargetId);
MatrixState.pAnyState.closeOverlay(_overlayKey);
super.dispose();
}
String get _overlayKey => "new-word-overlay-${widget.transformTargetId}";
void _showFlyingWidget() {
if (_controller == null || _opacityAnim == null || _moveAnim == null) {
return;
@ -96,9 +98,10 @@ class _NewWordOverlayState extends State<NewWordOverlay>
context: context,
closePrevOverlay: false,
ignorePointer: true,
canPop: false,
offset: const Offset(0, 45),
targetAnchor: Alignment.center,
overlayKey: widget.transformTargetId,
overlayKey: _overlayKey,
transformTargetId: widget.transformTargetId,
child: AnimatedBuilder(
animation: _controller!,

View file

@ -1,21 +1,39 @@
import 'package:get_storage/get_storage.dart';
class _StyleSettings {
final double fontSizeFactor;
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
const _StyleSettings({
class StyleSettings {
final double fontSizeFactor;
final bool useActivityImageBackground;
const StyleSettings({
this.fontSizeFactor = 1.0,
this.useActivityImageBackground = true,
});
Map<String, dynamic> toJson() {
return {
'fontSizeFactor': fontSizeFactor,
'useActivityImageBackground': useActivityImageBackground,
};
}
factory _StyleSettings.fromJson(Map<String, dynamic> json) {
return _StyleSettings(
factory StyleSettings.fromJson(Map<String, dynamic> json) {
return StyleSettings(
fontSizeFactor: (json['fontSizeFactor'] as num?)?.toDouble() ?? 1.0,
useActivityImageBackground:
json['useActivityImageBackground'] as bool? ?? true,
);
}
StyleSettings copyWith({
double? fontSizeFactor,
bool? useActivityImageBackground,
}) {
return StyleSettings(
fontSizeFactor: fontSizeFactor ?? this.fontSizeFactor,
useActivityImageBackground:
useActivityImageBackground ?? this.useActivityImageBackground,
);
}
}
@ -23,18 +41,42 @@ class _StyleSettings {
class StyleSettingsRepo {
static final GetStorage _storage = GetStorage("style_settings");
static Future<double> fontSizeFactor(String userId) async {
static String _storageKey(String userId) => '${userId}_style_settings';
static Future<StyleSettings> settings(String userId) async {
await GetStorage.init("style_settings");
final json =
_storage.read<Map<String, dynamic>>('${userId}_style_settings');
final settings =
json != null ? _StyleSettings.fromJson(json) : const _StyleSettings();
return settings.fontSizeFactor;
final key = _storageKey(userId);
final json = _storage.read<Map<String, dynamic>>(key);
if (json == null) return const StyleSettings();
try {
return StyleSettings.fromJson(json);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"settings_entry": json,
},
);
_storage.remove(key);
return const StyleSettings();
}
}
static Future<void> setFontSizeFactor(String userId, double factor) async {
await GetStorage.init("style_settings");
final settings = _StyleSettings(fontSizeFactor: factor);
await _storage.write('${userId}_style_settings', settings.toJson());
final currentSettings = await settings(userId);
final updatedSettings = currentSettings.copyWith(fontSizeFactor: factor);
await _storage.write(_storageKey(userId), updatedSettings.toJson());
}
static Future<void> setUseActivityImageBackground(
String userId,
bool useBackground,
) async {
final currentSettings = await settings(userId);
final updatedSettings = currentSettings.copyWith(
useActivityImageBackground: useBackground,
);
await _storage.write(_storageKey(userId), updatedSettings.toJson());
}
}

View file

@ -566,8 +566,10 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
// double.tryParse(store.getString(SettingKeys.fontSizeFactor) ?? '') ??
// AppConfig.fontSizeFactor;
if (client.isLogged()) {
StyleSettingsRepo.fontSizeFactor(client.userID!).then((factor) {
AppConfig.fontSizeFactor = factor;
StyleSettingsRepo.settings(client.userID!).then((settings) {
AppConfig.fontSizeFactor = settings.fontSizeFactor;
AppConfig.useActivityImageAsChatBackground =
settings.useActivityImageBackground;
});
}
// Pangea#