From 27e5387804c0fd5d8da619952a406b91b9d41f04 Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Sun, 8 Feb 2026 09:29:29 -0500 Subject: [PATCH 01/12] chore: bump version to 4.1.17+7 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index cfc708560..620576dd3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ description: Learn a language while texting your friends. # Pangea# publish_to: none # On version bump also increase the build number for F-Droid -version: 4.1.16+6 +version: 4.1.17+7 environment: sdk: ">=3.0.0 <4.0.0" From e83f76b95bc37d3361d6d329ddc7987719da9a81 Mon Sep 17 00:00:00 2001 From: avashilling <165050625+avashilling@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:03:05 -0500 Subject: [PATCH 02/12] 5385 explore tv static shimmer for loading (#5554) * glitch static loading prototypes * feat: shimmer pulse for clickable widget --------- Co-authored-by: ggurdin --- .../common/widgets/shimmer_background.dart | 123 ++++++++++++++---- .../lemmas/lemma_highlight_emoji_row.dart | 52 +------- 2 files changed, 99 insertions(+), 76 deletions(-) diff --git a/lib/pangea/common/widgets/shimmer_background.dart b/lib/pangea/common/widgets/shimmer_background.dart index e3f5b83c7..66a6bafc9 100644 --- a/lib/pangea/common/widgets/shimmer_background.dart +++ b/lib/pangea/common/widgets/shimmer_background.dart @@ -1,55 +1,126 @@ import 'package:flutter/material.dart'; -import 'package:shimmer/shimmer.dart'; - import 'package:fluffychat/config/app_config.dart'; -class ShimmerBackground extends StatelessWidget { +class ShimmerBackground extends StatefulWidget { final Widget child; final Color shimmerColor; - final Color? baseColor; final bool enabled; final BorderRadius? borderRadius; + final Duration delayBetweenPulses; const ShimmerBackground({ super.key, required this.child, this.shimmerColor = AppConfig.goldLight, - this.baseColor, this.enabled = true, this.borderRadius, + this.delayBetweenPulses = Duration.zero, }); + @override + State createState() => _ShimmerBackgroundState(); +} + +class _ShimmerBackgroundState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + Duration pulseDuration = const Duration(milliseconds: 1000); + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: pulseDuration, + vsync: this, + ); + + _animation = Tween( + begin: 0.0, + end: 0.3, + ).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ), + ); + + if (widget.enabled) { + _startPulsing(); + } + } + + void _startPulsing() { + if (widget.delayBetweenPulses == Duration.zero) { + _controller.repeat(reverse: true); + } else { + _pulseOnce(); + } + } + + void _pulseOnce() async { + await _controller.forward(); + await _controller.reverse(); + if (mounted && widget.enabled) { + await Future.delayed(widget.delayBetweenPulses); + if (mounted && widget.enabled) { + _pulseOnce(); + } + } + } + + @override + void didUpdateWidget(ShimmerBackground oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.enabled != oldWidget.enabled) { + if (widget.enabled) { + _startPulsing(); + } else { + _controller.stop(); + _controller.reset(); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - if (!enabled) { - return child; + if (!widget.enabled) { + return widget.child; } final borderRadius = - this.borderRadius ?? BorderRadius.circular(AppConfig.borderRadius); - return Stack( - children: [ - child, - Positioned.fill( - child: IgnorePointer( - child: ClipRRect( - borderRadius: borderRadius, - child: Shimmer.fromColors( - baseColor: baseColor ?? shimmerColor.withValues(alpha: 0.1), - highlightColor: shimmerColor.withValues(alpha: 0.6), - direction: ShimmerDirection.ltr, - child: Container( - decoration: BoxDecoration( - color: shimmerColor.withValues(alpha: 0.3), - borderRadius: borderRadius, + widget.borderRadius ?? BorderRadius.circular(AppConfig.borderRadius); + + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Stack( + children: [ + widget.child, + Positioned.fill( + child: IgnorePointer( + child: ClipRRect( + borderRadius: borderRadius, + child: Container( + decoration: BoxDecoration( + color: widget.shimmerColor + .withValues(alpha: _animation.value), + borderRadius: borderRadius, + ), ), ), ), ), - ), - ), - ], + ], + ); + }, ); } } diff --git a/lib/pangea/lemmas/lemma_highlight_emoji_row.dart b/lib/pangea/lemmas/lemma_highlight_emoji_row.dart index 21396f612..241b7f85d 100644 --- a/lib/pangea/lemmas/lemma_highlight_emoji_row.dart +++ b/lib/pangea/lemmas/lemma_highlight_emoji_row.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:shimmer/shimmer.dart'; @@ -136,49 +134,6 @@ class EmojiChoiceItem extends StatefulWidget { } class EmojiChoiceItemState extends State { - bool shimmer = false; - Timer? _shimmerTimer; - - @override - void initState() { - super.initState(); - _showShimmer(); - } - - @override - void didUpdateWidget(covariant EmojiChoiceItem oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.emoji != widget.emoji) { - _showShimmer(); - } - } - - @override - void dispose() { - _shimmerTimer?.cancel(); - super.dispose(); - } - - void _showShimmer() { - if (!widget.showShimmer || !widget.enabled) return; - - setState(() => shimmer = true); - _shimmerTimer?.cancel(); - _shimmerTimer = Timer(const Duration(milliseconds: 1500), () { - if (mounted) { - setState(() => shimmer = false); - _repeatShimmer(); - } - }); - } - - void _repeatShimmer() { - _shimmerTimer?.cancel(); - _shimmerTimer = Timer(const Duration(seconds: 5), () { - if (mounted) _showShimmer(); - }); - } - @override Widget build(BuildContext context) { return HoverBuilder( @@ -191,11 +146,8 @@ class EmojiChoiceItemState extends State { child: Stack( children: [ ShimmerBackground( - enabled: shimmer, - shimmerColor: (Theme.of(context).brightness == Brightness.dark) - ? Colors.white - : Theme.of(context).colorScheme.primary, - baseColor: Colors.transparent, + enabled: widget.showShimmer && widget.enabled, + delayBetweenPulses: const Duration(seconds: 5), child: CompositedTransformTarget( link: MatrixState.pAnyState .layerLinkAndKey(widget.transformTargetId) From 27740f296777187e4362c447002810fba85db9f3 Mon Sep 17 00:00:00 2001 From: avashilling <165050625+avashilling@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:17:59 -0500 Subject: [PATCH 03/12] chore: add audio on match in practice (#5580) * chore: add audio on match in practice * play audio for target instead of choice * remove debugprint --------- Co-authored-by: ggurdin --- .../toolbar/message_practice/practice_controller.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/pangea/toolbar/message_practice/practice_controller.dart b/lib/pangea/toolbar/message_practice/practice_controller.dart index 5230499c3..e642be6f6 100644 --- a/lib/pangea/toolbar/message_practice/practice_controller.dart +++ b/lib/pangea/toolbar/message_practice/practice_controller.dart @@ -16,6 +16,7 @@ import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.d import 'package:fluffychat/pangea/practice_activities/practice_selection.dart'; import 'package:fluffychat/pangea/practice_activities/practice_selection_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/message_practice_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/morph_selection.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/practice_record_controller.dart'; @@ -225,6 +226,14 @@ class PracticeController with ChangeNotifier { } } + if (_activity is LemmaMeaningPracticeActivityModel || + _activity is EmojiPracticeActivityModel) { + TtsController.tryToSpeak( + token.text.content, + langCode: MatrixState.pangeaController.userController.userL2!.langCode, + ); + } + notifyListeners(); } } From 27776a289622096b6e180b346a7d8bf213551d93 Mon Sep 17 00:00:00 2001 From: avashilling <165050625+avashilling@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:32:50 -0500 Subject: [PATCH 04/12] 5582 grammar tweaks (#5594) * feat: add hint counter for grammar activities - fade out hint button to morph info in category questions - animated transitions for smoother hint display * chore: copy change and variable completion message * chore: only fetch necessary types in fetchErrors to end up with more grammar errors, but still only use one getUses call * translations --------- Co-authored-by: ggurdin --- lib/l10n/intl_ar.arb | 32 +- lib/l10n/intl_be.arb | 32 +- lib/l10n/intl_bn.arb | 32 +- lib/l10n/intl_bo.arb | 32 +- lib/l10n/intl_ca.arb | 32 +- lib/l10n/intl_cs.arb | 32 +- lib/l10n/intl_da.arb | 32 +- lib/l10n/intl_de.arb | 32 +- lib/l10n/intl_el.arb | 32 +- lib/l10n/intl_en.arb | 8 +- lib/l10n/intl_eo.arb | 32 +- lib/l10n/intl_es.arb | 32 +- lib/l10n/intl_et.arb | 32 +- lib/l10n/intl_eu.arb | 32 +- lib/l10n/intl_fa.arb | 32 +- lib/l10n/intl_fi.arb | 32 +- lib/l10n/intl_fil.arb | 32 +- lib/l10n/intl_fr.arb | 32 +- lib/l10n/intl_ga.arb | 32 +- lib/l10n/intl_gl.arb | 32 +- lib/l10n/intl_he.arb | 32 +- lib/l10n/intl_hi.arb | 32 +- lib/l10n/intl_hr.arb | 32 +- lib/l10n/intl_hu.arb | 32 +- lib/l10n/intl_ia.arb | 32 +- lib/l10n/intl_id.arb | 32 +- lib/l10n/intl_ie.arb | 32 +- lib/l10n/intl_it.arb | 32 +- lib/l10n/intl_ja.arb | 32 +- lib/l10n/intl_ka.arb | 32 +- lib/l10n/intl_ko.arb | 32 +- lib/l10n/intl_lt.arb | 32 +- lib/l10n/intl_lv.arb | 32 +- lib/l10n/intl_nb.arb | 32 +- lib/l10n/intl_nl.arb | 32 +- lib/l10n/intl_pl.arb | 32 +- lib/l10n/intl_pt.arb | 32 +- lib/l10n/intl_pt_BR.arb | 32 +- lib/l10n/intl_pt_PT.arb | 32 +- lib/l10n/intl_ro.arb | 32 +- lib/l10n/intl_ru.arb | 32 +- lib/l10n/intl_sk.arb | 32 +- lib/l10n/intl_sl.arb | 32 +- lib/l10n/intl_sr.arb | 32 +- lib/l10n/intl_sv.arb | 32 +- lib/l10n/intl_ta.arb | 32 +- lib/l10n/intl_te.arb | 32 +- lib/l10n/intl_th.arb | 32 +- lib/l10n/intl_tr.arb | 32 +- lib/l10n/intl_uk.arb | 32 +- lib/l10n/intl_vi.arb | 32 +- lib/l10n/intl_yue.arb | 32 +- lib/l10n/intl_zh.arb | 32 +- lib/l10n/intl_zh_Hant.arb | 32 +- .../analytics_data_service.dart | 4 +- .../analytics_data/analytics_database.dart | 4 +- .../analytics_practice_page.dart | 41 ++- .../analytics_practice_session_repo.dart | 13 +- .../analytics_practice_view.dart | 332 ++++++++++-------- .../completed_activity_session_view.dart | 4 +- 60 files changed, 1360 insertions(+), 742 deletions(-) diff --git a/lib/l10n/intl_ar.arb b/lib/l10n/intl_ar.arb index cebe2097f..cb7e976f9 100644 --- a/lib/l10n/intl_ar.arb +++ b/lib/l10n/intl_ar.arb @@ -1,6 +1,6 @@ { "@@locale": "ar", - "@@last_modified": "2026-02-05 10:09:56.397837", + "@@last_modified": "2026-02-09 10:31:06.969392", "about": "حول", "@about": { "type": "String", @@ -10954,8 +10954,6 @@ "congratulations": "مبروك!", "anotherRound": "جولة أخرى", "noActivityRequest": "لا يوجد طلب نشاط حالي.", - "quit": "خروج", - "congratulationsYouveCompletedPractice": "تهانينا! لقد أكملت جلسة التدريب.", "mustHave10Words": "يجب أن يكون لديك على الأقل 10 كلمات مفردات لممارستها. حاول التحدث إلى صديق أو بوت بانجيا لاكتشاف المزيد!", "botSettings": "إعدادات البوت", "activitySettingsOverrideWarning": "اللغة ومستوى اللغة محددان بواسطة خطة النشاط", @@ -11004,14 +11002,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11201,5 +11191,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "ممارسة مثالية!", + "greatPractice": "ممارسة رائعة!", + "usedNoHints": "عمل رائع بعدم استخدام أي تلميحات!", + "youveCompletedPractice": "لقد أكملت الممارسة، استمر في ذلك لتحسين مهاراتك!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_be.arb b/lib/l10n/intl_be.arb index 72fb9a8ca..9f56ebca9 100644 --- a/lib/l10n/intl_be.arb +++ b/lib/l10n/intl_be.arb @@ -1908,7 +1908,7 @@ "playWithAI": "Пакуль гуляйце з ШІ", "courseStartDesc": "Pangea Bot гатовы да працы ў любы час!\n\n...але навучанне лепш з сябрамі!", "@@locale": "be", - "@@last_modified": "2026-02-05 10:09:46.469770", + "@@last_modified": "2026-02-09 10:30:56.849128", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11836,8 +11836,6 @@ "congratulations": "Сардэчна віншуем!", "anotherRound": "Яшчэ адзін раунд", "noActivityRequest": "Няма бягучага запыту на актыўнасць.", - "quit": "Выйсці", - "congratulationsYouveCompletedPractice": "Сардэчна віншуем! Вы завяршылі сесію практыкі.", "mustHave10Words": "Вы павінны мець не менш за 10 слоў для практыкавання. Паспрабуйце пагаварыць з сябрам або Pangea Bot, каб даведацца больш!", "botSettings": "Налады бота", "activitySettingsOverrideWarning": "Мова і ўзровень мовы вызначаюцца планам актыўнасці", @@ -11886,14 +11884,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12083,5 +12073,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Ідэальная практыка!", + "greatPractice": "Выдатная практыка!", + "usedNoHints": "Малайчына, што не карыстаўся падказкамі!", + "youveCompletedPractice": "Вы завяршылі практыку, працягвайце, каб стаць лепшым!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_bn.arb b/lib/l10n/intl_bn.arb index ce0510c60..fb33c73ee 100644 --- a/lib/l10n/intl_bn.arb +++ b/lib/l10n/intl_bn.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:10:08.830801", + "@@last_modified": "2026-02-09 10:31:21.327976", "about": "সম্পর্কে", "@about": { "type": "String", @@ -11841,8 +11841,6 @@ "congratulations": "অভিনন্দন!", "anotherRound": "আরেকটি রাউন্ড", "noActivityRequest": "বর্তমান কোন কার্যকলাপের অনুরোধ নেই।", - "quit": "বিরতি", - "congratulationsYouveCompletedPractice": "অভিনন্দন! আপনি অনুশীলন সেশন সম্পন্ন করেছেন।", "mustHave10Words": "আপনার অনুশীলনের জন্য অন্তত 10টি শব্দ থাকতে হবে। আরও জানার জন্য একটি বন্ধুর সাথে কথা বলুন বা Pangea Bot এর সাথে কথা বলুন!", "botSettings": "বট সেটিংস", "activitySettingsOverrideWarning": "কার্যকলাপ পরিকল্পনার দ্বারা নির্ধারিত ভাষা এবং ভাষার স্তর", @@ -11891,14 +11889,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12088,5 +12078,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "পারফেক্ট প্র্যাকটিস!", + "greatPractice": "দারুণ প্র্যাকটিস!", + "usedNoHints": "কোনো হিন্ট ব্যবহার না করার জন্য ভালো কাজ!", + "youveCompletedPractice": "আপনি প্র্যাকটিস সম্পন্ন করেছেন, উন্নতির জন্য এভাবে চালিয়ে যান!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_bo.arb b/lib/l10n/intl_bo.arb index 7cf7f1681..c43b02482 100644 --- a/lib/l10n/intl_bo.arb +++ b/lib/l10n/intl_bo.arb @@ -4276,7 +4276,7 @@ "joinPublicTrip": "མི་ཚེས་ལ་ལོག་འབད།", "startOwnTrip": "ངེད་རང་གི་ལོག་ལ་སྦྱོར་བཅོས།", "@@locale": "bo", - "@@last_modified": "2026-02-05 10:10:06.262776", + "@@last_modified": "2026-02-09 10:31:18.713273", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -10491,8 +10491,6 @@ "congratulations": "Čestitamo!", "anotherRound": "Još jedan krug", "noActivityRequest": "Ninguna solicitud de actividad actual.", - "quit": "Salir", - "congratulationsYouveCompletedPractice": "¡Felicidades! Has completado la sesión de práctica.", "mustHave10Words": "Debes tener al menos 10 palabras de vocabulario para practicarlas. ¡Intenta hablar con un amigo o con Pangea Bot para descubrir más!", "botSettings": "Configuraciones del Bot", "activitySettingsOverrideWarning": "Idioma y nivel de idioma determinados por el plan de actividad", @@ -10541,14 +10539,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -10738,5 +10728,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Perfekta praktik!", + "greatPractice": "Stora praktik!", + "usedNoHints": "Bra jobbat utan att använda några ledtrådar!", + "youveCompletedPractice": "Du har slutfört praktiken, fortsätt så för att bli bättre!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index e2777e39a..6567634fd 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:09:47.712187", + "@@last_modified": "2026-02-09 10:30:58.877498", "about": "Quant a", "@about": { "type": "String", @@ -10761,8 +10761,6 @@ "congratulations": "Felicitats!", "anotherRound": "Una altra ronda", "noActivityRequest": "No hi ha cap sol·licitud d'activitat actual.", - "quit": "Sortir", - "congratulationsYouveCompletedPractice": "Felicitats! Has completat la sessió de pràctica.", "mustHave10Words": "Has de tenir almenys 10 paraules de vocabulari per practicar-les. Prova a parlar amb un amic o amb el Pangea Bot per descobrir-ne més!", "botSettings": "Configuració del Bot", "activitySettingsOverrideWarning": "Idioma i nivell d'idioma determinats pel pla d'activitat", @@ -10811,14 +10809,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11008,5 +10998,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Pràctica perfecta!", + "greatPractice": "Gran pràctica!", + "usedNoHints": "Bon treball sense utilitzar cap pista!", + "youveCompletedPractice": "Has completat la pràctica, continua així per millorar!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 9e1758776..2f0de87e6 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -1,6 +1,6 @@ { "@@locale": "cs", - "@@last_modified": "2026-02-05 10:09:43.831148", + "@@last_modified": "2026-02-09 10:30:53.085602", "about": "O aplikaci", "@about": { "type": "String", @@ -11344,8 +11344,6 @@ "congratulations": "Gratulujeme!", "anotherRound": "Další kolo", "noActivityRequest": "Žádná aktuální žádost o aktivitu.", - "quit": "Ukončit", - "congratulationsYouveCompletedPractice": "Gratulujeme! Dokončili jste cvičební sezení.", "mustHave10Words": "Musíte mít alespoň 10 slovní zásoby, abyste je mohli procvičovat. Zkuste si promluvit s přítelem nebo Pangea Botem, abyste objevili více!", "botSettings": "Nastavení bota", "activitySettingsOverrideWarning": "Jazyk a jazyková úroveň určené plánem aktivity", @@ -11394,14 +11392,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11591,5 +11581,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Dokonalá praxe!", + "greatPractice": "Skvělá praxe!", + "usedNoHints": "Dobrá práce, že jsi nepoužil žádné nápovědy!", + "youveCompletedPractice": "Dokončil jsi praxi, pokračuj v tom, abys se zlepšil!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_da.arb b/lib/l10n/intl_da.arb index eef287d6f..cb13a9d91 100644 --- a/lib/l10n/intl_da.arb +++ b/lib/l10n/intl_da.arb @@ -1927,7 +1927,7 @@ "playWithAI": "Leg med AI for nu", "courseStartDesc": "Pangea Bot er klar til at starte når som helst!\n\n...men læring er bedre med venner!", "@@locale": "da", - "@@last_modified": "2026-02-05 10:09:17.541713", + "@@last_modified": "2026-02-09 10:30:26.547080", "@aboutHomeserver": { "type": "String", "placeholders": { @@ -11798,8 +11798,6 @@ "congratulations": "Tillykke!", "anotherRound": "En runde mere", "noActivityRequest": "Ingen aktuelle aktivitetsanmodning.", - "quit": "Afslut", - "congratulationsYouveCompletedPractice": "Tillykke! Du har gennemført øvelsessessionen.", "mustHave10Words": "Du skal have mindst 10 ordforrådsord for at øve dem. Prøv at tale med en ven eller Pangea Bot for at opdage mere!", "botSettings": "Botindstillinger", "activitySettingsOverrideWarning": "Sprog og sprogniveau bestemt af aktivitetsplan", @@ -11848,14 +11846,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12045,5 +12035,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Perfekt praksis!", + "greatPractice": "God praksis!", + "usedNoHints": "Godt klaret uden at bruge nogen hints!", + "youveCompletedPractice": "Du har gennemført praksis, bliv ved med det for at blive bedre!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 9c51df9e8..c85543eca 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1,6 +1,6 @@ { "@@locale": "de", - "@@last_modified": "2026-02-05 10:09:37.665075", + "@@last_modified": "2026-02-09 10:30:45.424459", "alwaysUse24HourFormat": "true", "@alwaysUse24HourFormat": { "description": "Set to true to always display time of day in 24 hour format." @@ -10744,8 +10744,6 @@ "congratulations": "Herzlichen Glückwunsch!", "anotherRound": "Eine weitere Runde", "noActivityRequest": "Keine aktuellen Aktivitätsanfragen.", - "quit": "Beenden", - "congratulationsYouveCompletedPractice": "Herzlichen Glückwunsch! Sie haben die Übungssitzung abgeschlossen.", "mustHave10Words": "Sie müssen mindestens 10 Vokabeln haben, um sie zu üben. Versuchen Sie, mit einem Freund oder dem Pangea Bot zu sprechen, um mehr zu entdecken!", "botSettings": "Bot-Einstellungen", "activitySettingsOverrideWarning": "Sprache und Sprachniveau werden durch den Aktivitätsplan bestimmt", @@ -10794,14 +10792,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -10991,5 +10981,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Perfekte Übung!", + "greatPractice": "Großartige Übung!", + "usedNoHints": "Gut gemacht, keine Hinweise zu verwenden!", + "youveCompletedPractice": "Du hast die Übung abgeschlossen, mach weiter so, um besser zu werden!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_el.arb b/lib/l10n/intl_el.arb index d2aecfddb..41cbb375a 100644 --- a/lib/l10n/intl_el.arb +++ b/lib/l10n/intl_el.arb @@ -4453,7 +4453,7 @@ "playWithAI": "Παίξτε με την Τεχνητή Νοημοσύνη προς το παρόν", "courseStartDesc": "Ο Pangea Bot είναι έτοιμος να ξεκινήσει οποιαδήποτε στιγμή!\n\n...αλλά η μάθηση είναι καλύτερη με φίλους!", "@@locale": "el", - "@@last_modified": "2026-02-05 10:10:14.390437", + "@@last_modified": "2026-02-09 10:31:27.836094", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11795,8 +11795,6 @@ "congratulations": "Συγχαρητήρια!", "anotherRound": "Μια ακόμη γύρος", "noActivityRequest": "Δεν υπάρχει τρέχουσα αίτηση δραστηριότητας.", - "quit": "Έξοδος", - "congratulationsYouveCompletedPractice": "Συγχαρητήρια! Έχετε ολοκληρώσει την πρακτική συνεδρία.", "mustHave10Words": "Πρέπει να έχετε τουλάχιστον 10 λέξεις λεξιλογίου για να τις εξασκήσετε. Δοκιμάστε να μιλήσετε με έναν φίλο ή με τον Pangea Bot για να ανακαλύψετε περισσότερα!", "botSettings": "Ρυθμίσεις Bot", "activitySettingsOverrideWarning": "Η γλώσσα και το επίπεδο γλώσσας καθορίζονται από το σχέδιο δραστηριότητας", @@ -11845,14 +11843,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12042,5 +12032,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Τέλεια πρακτική!", + "greatPractice": "Υπέροχη πρακτική!", + "usedNoHints": "Καλή δουλειά που δεν χρησιμοποίησες καθόλου υποδείξεις!", + "youveCompletedPractice": "Έχεις ολοκληρώσει την πρακτική, συνέχισε έτσι για να γίνεις καλύτερος!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index a47f1a03e..f062e5a51 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5027,8 +5027,6 @@ "genericWebRecordingError": "Something went wrong. We recommend using the Chrome browser when recording messages.", "screenSizeWarning": "For the best experience using this application, please expand your screen size.", "noActivityRequest": "No current activity request.", - "quit": "Quit", - "congratulationsYouveCompletedPractice": "Congratulations! You've completed the practice session.", "activitiesToUnlockTopicTitle": "Activities to Unlock Next Topic", "activitiesToUnlockTopicDesc": "Set the number of activities to unlock the next course topic", "mustHave10Words": "You must have at least 10 vocab words to practice them. Try talking to a friend or Pangea Bot to discover more!", @@ -5073,5 +5071,9 @@ "supportSubtitle": "Questions? We're here to help!", "autoIGCToolName": "Enable writing assistance", "autoIGCToolDescription": "Automatically run Pangea Chat tools to correct sent messages to target language.", - "emptyAudioError": "Recording failed. Please check your audio permissions and try again." + "emptyAudioError": "Recording failed. Please check your audio permissions and try again.", + "perfectPractice": "Perfect practice!", + "greatPractice": "Great practice!", + "usedNoHints": "Nice job not using any hints!", + "youveCompletedPractice": "You've completed practice, keep it up to get better!" } diff --git a/lib/l10n/intl_eo.arb b/lib/l10n/intl_eo.arb index 2c6c6ed93..1f4a4fc1d 100644 --- a/lib/l10n/intl_eo.arb +++ b/lib/l10n/intl_eo.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:10:17.896498", + "@@last_modified": "2026-02-09 10:31:32.343732", "about": "Prio", "@about": { "type": "String", @@ -11826,8 +11826,6 @@ "congratulations": "Gratulon!", "anotherRound": "Alia rundo", "noActivityRequest": "Neniu aktuala aktivitecpeticio.", - "quit": "Eliri", - "congratulationsYouveCompletedPractice": "Gratulojn! Vi kompletigis la praktikadon.", "mustHave10Words": "Vi devas havi almenaŭ 10 vortojn por praktiki ilin. Provu paroli kun amiko aŭ Pangea Bot por malkovri pli!", "botSettings": "Botaj Agordoj", "activitySettingsOverrideWarning": "Lingvo kaj lingvonivelo determinita de la aktiviteca plano", @@ -11876,14 +11874,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12073,5 +12063,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Perfekta praktiko!", + "greatPractice": "Granda praktiko!", + "usedNoHints": "Bonega laboro ne uzi iujn sugestojn!", + "youveCompletedPractice": "Vi finis la praktikon, daŭrigu por pliboniĝi!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 8458be45b..aa2560f1b 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1,6 +1,6 @@ { "@@locale": "es", - "@@last_modified": "2026-02-05 10:09:12.250951", + "@@last_modified": "2026-02-09 10:30:21.560804", "about": "Acerca de", "@about": { "type": "String", @@ -7979,8 +7979,6 @@ "congratulations": "¡Felicidades!", "anotherRound": "Otra ronda", "noActivityRequest": "No hay solicitudes de actividad actuales.", - "quit": "Salir", - "congratulationsYouveCompletedPractice": "¡Felicidades! Has completado la sesión de práctica.", "mustHave10Words": "Debes tener al menos 10 palabras de vocabulario para practicarlas. ¡Intenta hablar con un amigo o con Pangea Bot para descubrir más!", "botSettings": "Configuración del Bot", "activitySettingsOverrideWarning": "Idioma y nivel de idioma determinados por el plan de actividad", @@ -8029,14 +8027,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -8226,5 +8216,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "¡Práctica perfecta!", + "greatPractice": "¡Gran práctica!", + "usedNoHints": "¡Buen trabajo no usando ninguna pista!", + "youveCompletedPractice": "¡Has completado la práctica, sigue así para mejorar!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_et.arb b/lib/l10n/intl_et.arb index ddb897208..b331e9a9f 100644 --- a/lib/l10n/intl_et.arb +++ b/lib/l10n/intl_et.arb @@ -1,6 +1,6 @@ { "@@locale": "et", - "@@last_modified": "2026-02-05 10:09:36.127342", + "@@last_modified": "2026-02-09 10:30:44.206560", "about": "Rakenduse teave", "@about": { "type": "String", @@ -11008,8 +11008,6 @@ "congratulations": "Palju õnne!", "anotherRound": "Veel üks voor", "noActivityRequest": "Praegu ei ole aktiivsuse taotlust.", - "quit": "Välju", - "congratulationsYouveCompletedPractice": "Palju õnne! Olete lõpetanud harjut seansi.", "mustHave10Words": "Te peate omama vähemalt 10 sõnavara sõna, et neid harjutada. Proovige rääkida sõbraga või Pangea Botiga, et rohkem avastada!", "botSettings": "Boti seaded", "activitySettingsOverrideWarning": "Keele ja keele taseme määrab tegevusplaan", @@ -11058,14 +11056,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11255,5 +11245,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Täiuslik harjutamine!", + "greatPractice": "Suurepärane harjutamine!", + "usedNoHints": "Hea töö, et ei kasutanud mingeid vihjeid!", + "youveCompletedPractice": "Oled harjutamise lõpetanud, jätka samas vaimus, et paremaks saada!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_eu.arb b/lib/l10n/intl_eu.arb index 9bab7424f..6cde5e0a0 100644 --- a/lib/l10n/intl_eu.arb +++ b/lib/l10n/intl_eu.arb @@ -1,6 +1,6 @@ { "@@locale": "eu", - "@@last_modified": "2026-02-05 10:09:33.401642", + "@@last_modified": "2026-02-09 10:30:41.236742", "about": "Honi buruz", "@about": { "type": "String", @@ -10737,8 +10737,6 @@ "congratulations": "Zorionak!", "anotherRound": "Beste txanda bat", "noActivityRequest": "Ez dago egungo jarduera eskaerarik.", - "quit": "Irten", - "congratulationsYouveCompletedPractice": "Zorionak! Praktika saioa amaitu duzu.", "mustHave10Words": "Gutxienez 10 hiztegi hitz izan behar dituzu praktikan jartzeko. Saiatu lagun batekin edo Pangea Bot-ekin hitz egiten gehiago ezagutzeko!", "botSettings": "Botaren Ezarpenak", "activitySettingsOverrideWarning": "Jarduera planak zehaztutako hizkuntza eta hizkuntza maila", @@ -10787,14 +10785,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -10984,5 +10974,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Praktika perfektua!", + "greatPractice": "Praktika handia!", + "usedNoHints": "Lan ona, ez duzu inolako iradokizunik erabili!", + "youveCompletedPractice": "Praktika amaitu duzu, jarraitu horrela hobetzeko!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fa.arb b/lib/l10n/intl_fa.arb index 45ee57de3..be63f02dd 100644 --- a/lib/l10n/intl_fa.arb +++ b/lib/l10n/intl_fa.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:10:10.668033", + "@@last_modified": "2026-02-09 10:31:23.313797", "repeatPassword": "تکرار رمزعبور", "@repeatPassword": {}, "about": "درباره", @@ -11469,8 +11469,6 @@ "congratulations": "تبریک می‌گویم!", "anotherRound": "یک دور دیگر", "noActivityRequest": "درخواست فعالیت فعلی وجود ندارد.", - "quit": "خروج", - "congratulationsYouveCompletedPractice": "تبریک! شما جلسه تمرین را کامل کرده‌اید.", "mustHave10Words": "شما باید حداقل 10 کلمه واژگان برای تمرین داشته باشید. سعی کنید با یک دوست یا ربات پانژیا صحبت کنید تا بیشتر کشف کنید!", "botSettings": "تنظیمات ربات", "activitySettingsOverrideWarning": "زبان و سطح زبان تعیین شده توسط برنامه فعالیت", @@ -11519,14 +11517,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11716,5 +11706,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "تمرین عالی!", + "greatPractice": "تمرین فوق‌العاده!", + "usedNoHints": "کار خوبی کردید که از هیچ راهنمایی استفاده نکردید!", + "youveCompletedPractice": "شما تمرین را کامل کردید، ادامه دهید تا بهتر شوید!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fi.arb b/lib/l10n/intl_fi.arb index 475407fe6..c65b2dc8f 100644 --- a/lib/l10n/intl_fi.arb +++ b/lib/l10n/intl_fi.arb @@ -4006,7 +4006,7 @@ "playWithAI": "Leiki tekoälyn kanssa nyt", "courseStartDesc": "Pangea Bot on valmis milloin tahansa!\n\n...mutta oppiminen on parempaa ystävien kanssa!", "@@locale": "fi", - "@@last_modified": "2026-02-05 10:09:16.239112", + "@@last_modified": "2026-02-09 10:30:24.862038", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11360,8 +11360,6 @@ "congratulations": "Onnittelut!", "anotherRound": "Toinen kierros", "noActivityRequest": "Ei nykyistä aktiviteettipyyntöä.", - "quit": "Poistu", - "congratulationsYouveCompletedPractice": "Onnittelut! Olet suorittanut harjoitussession.", "mustHave10Words": "Sinulla on oltava vähintään 10 sanastoa harjoiteltavaksi. Yritä puhua ystävän tai Pangea Botin kanssa löytääksesi lisää!", "botSettings": "Bottiasetukset", "activitySettingsOverrideWarning": "Kieli ja kielitaso määräytyvät aktiviteettisuunnitelman mukaan", @@ -11410,14 +11408,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11607,5 +11597,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Täydellinen harjoittelu!", + "greatPractice": "Loistava harjoittelu!", + "usedNoHints": "Hyvä työ, ettet käyttänyt mitään vihjeitä!", + "youveCompletedPractice": "Olet suorittanut harjoituksen, jatka samaan malliin parantuaksesi!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fil.arb b/lib/l10n/intl_fil.arb index d1c809870..6b8fc6e95 100644 --- a/lib/l10n/intl_fil.arb +++ b/lib/l10n/intl_fil.arb @@ -2784,7 +2784,7 @@ "selectAll": "Piliin lahat", "deselectAll": "Huwag piliin lahat", "@@locale": "fil", - "@@last_modified": "2026-02-05 10:09:53.428313", + "@@last_modified": "2026-02-09 10:31:04.579384", "@setCustomPermissionLevel": { "type": "String", "placeholders": {} @@ -11713,8 +11713,6 @@ "congratulations": "Binabati kita!", "anotherRound": "Isa pang round", "noActivityRequest": "Walang kasalukuyang kahilingan sa aktibidad.", - "quit": "Lumabas", - "congratulationsYouveCompletedPractice": "Binabati kita! Natapos mo na ang sesyon ng pagsasanay.", "mustHave10Words": "Dapat mayroon kang hindi bababa sa 10 salita ng bokabularyo upang sanayin ang mga ito. Subukan mong makipag-usap sa isang kaibigan o sa Pangea Bot upang matuklasan pa!", "botSettings": "Mga Setting ng Bot", "activitySettingsOverrideWarning": "Wika at antas ng wika na tinutukoy ng plano ng aktibidad", @@ -11763,14 +11761,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11960,5 +11950,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Perpektong pagsasanay!", + "greatPractice": "Mahusay na pagsasanay!", + "usedNoHints": "Magandang trabaho sa hindi paggamit ng anumang mga pahiwatig!", + "youveCompletedPractice": "Natapos mo na ang pagsasanay, ipagpatuloy mo lang ito upang maging mas mahusay!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 30464e9eb..7a95f7509 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1,6 +1,6 @@ { "@@locale": "fr", - "@@last_modified": "2026-02-05 10:10:24.987990", + "@@last_modified": "2026-02-09 10:31:39.092623", "about": "À propos", "@about": { "type": "String", @@ -11061,8 +11061,6 @@ "congratulations": "Félicitations !", "anotherRound": "Un autre tour", "noActivityRequest": "Aucune demande d'activité en cours.", - "quit": "Quitter", - "congratulationsYouveCompletedPractice": "Félicitations ! Vous avez terminé la session de pratique.", "mustHave10Words": "Vous devez avoir au moins 10 mots de vocabulaire à pratiquer. Essayez de parler à un ami ou au Pangea Bot pour en découvrir plus !", "botSettings": "Paramètres du bot", "activitySettingsOverrideWarning": "Langue et niveau de langue déterminés par le plan d'activité", @@ -11111,14 +11109,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11308,5 +11298,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Pratique parfaite !", + "greatPractice": "Super pratique !", + "usedNoHints": "Bien joué de ne pas avoir utilisé d'indices !", + "youveCompletedPractice": "Vous avez terminé la pratique, continuez comme ça pour vous améliorer !", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ga.arb b/lib/l10n/intl_ga.arb index ac8fb9bf9..dbc6a0884 100644 --- a/lib/l10n/intl_ga.arb +++ b/lib/l10n/intl_ga.arb @@ -4514,7 +4514,7 @@ "playWithAI": "Imir le AI faoi láthair", "courseStartDesc": "Tá Bot Pangea réidh chun dul am ar bith!\n\n...ach is fearr foghlaim le cairde!", "@@locale": "ga", - "@@last_modified": "2026-02-05 10:10:23.901035", + "@@last_modified": "2026-02-09 10:31:37.968813", "@customReaction": { "type": "String", "placeholders": {} @@ -10735,8 +10735,6 @@ "congratulations": "Comhghairdeas!", "anotherRound": "Ciorcal eile", "noActivityRequest": "Níl aon iarratas gníomhaíochta reatha.", - "quit": "Dícheangail", - "congratulationsYouveCompletedPractice": "Comhghairdeas! Tá an seisiún cleachtaidh críochnaithe agat.", "mustHave10Words": "Caithfidh go mbeidh 10 focal le haghaidh cleachtaidh agat ar a laghad. Bain triail as labhairt le cara nó le Pangea Bot chun tuilleadh a fháil amach!", "botSettings": "Socruithe an Bhot", "activitySettingsOverrideWarning": "Teanga agus leibhéal teanga a chinneadh de réir plean gníomhaíochta", @@ -10785,14 +10783,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -10982,5 +10972,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Cleachtadh foirfe!", + "greatPractice": "Cleachtadh iontach!", + "usedNoHints": "Obair mhaith gan aon leideanna a úsáid!", + "youveCompletedPractice": "Tá do chleachtadh críochnaithe, coinnigh ort chun feabhsú!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_gl.arb b/lib/l10n/intl_gl.arb index 44263a6c5..91c508254 100644 --- a/lib/l10n/intl_gl.arb +++ b/lib/l10n/intl_gl.arb @@ -1,6 +1,6 @@ { "@@locale": "gl", - "@@last_modified": "2026-02-05 10:09:14.434046", + "@@last_modified": "2026-02-09 10:30:23.363980", "about": "Acerca de", "@about": { "type": "String", @@ -10734,8 +10734,6 @@ "congratulations": "Parabéns!", "anotherRound": "Outra ronda", "noActivityRequest": "Non hai solicitudes de actividade actuais.", - "quit": "Saír", - "congratulationsYouveCompletedPractice": "Parabéns! Completaches a sesión de práctica.", "mustHave10Words": "Debes ter polo menos 10 palabras de vocabulario para practicálas. Intenta falar cun amigo ou co Pangea Bot para descubrir máis!", "botSettings": "Configuración do Bot", "activitySettingsOverrideWarning": "Idioma e nivel de idioma determinados polo plan de actividade", @@ -10784,14 +10782,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -10981,5 +10971,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Práctica perfecta!", + "greatPractice": "Gran práctica!", + "usedNoHints": "Bo traballo sen usar pistas!", + "youveCompletedPractice": "Completaches a práctica, segue así para mellorar!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_he.arb b/lib/l10n/intl_he.arb index 63f1dae8f..344403974 100644 --- a/lib/l10n/intl_he.arb +++ b/lib/l10n/intl_he.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:09:28.796405", + "@@last_modified": "2026-02-09 10:30:36.836306", "about": "אודות", "@about": { "type": "String", @@ -11786,8 +11786,6 @@ "congratulations": "מזל טוב!", "anotherRound": "סיבוב נוסף", "noActivityRequest": "אין בקשת פעילות נוכחית.", - "quit": "צא", - "congratulationsYouveCompletedPractice": "מזל טוב! סיימת את מושב האימון.", "mustHave10Words": "עליך שיהיו לפחות 10 מילים לאוצר מילים כדי לתרגל אותן. נסה לדבר עם חבר או עם פנגיאה בוט כדי לגלות עוד!", "botSettings": "הגדרות בוט", "activitySettingsOverrideWarning": "שפה ורמת שפה נקבעות על ידי תוכנית הפעילות", @@ -11836,14 +11834,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12033,5 +12023,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "תרגול מושלם!", + "greatPractice": "תרגול נהדר!", + "usedNoHints": "עבודה טובה שלא השתמשת ברמזים!", + "youveCompletedPractice": "סיימת את התרגול, המשך כך כדי להשתפר!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_hi.arb b/lib/l10n/intl_hi.arb index d7758c041..61a8e2885 100644 --- a/lib/l10n/intl_hi.arb +++ b/lib/l10n/intl_hi.arb @@ -4480,7 +4480,7 @@ "playWithAI": "अभी के लिए एआई के साथ खेलें", "courseStartDesc": "पैंजिया बॉट कभी भी जाने के लिए तैयार है!\n\n...लेकिन दोस्तों के साथ सीखना बेहतर है!", "@@locale": "hi", - "@@last_modified": "2026-02-05 10:10:16.696075", + "@@last_modified": "2026-02-09 10:31:30.698303", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11822,8 +11822,6 @@ "congratulations": "बधाई हो!", "anotherRound": "एक और राउंड", "noActivityRequest": "कोई वर्तमान गतिविधि अनुरोध नहीं है।", - "quit": "बंद करें", - "congratulationsYouveCompletedPractice": "बधाई हो! आपने अभ्यास सत्र पूरा कर लिया है।", "mustHave10Words": "आपके पास उन्हें अभ्यास करने के लिए कम से कम 10 शब्द होने चाहिए। अधिक जानने के लिए किसी मित्र या Pangea Bot से बात करने की कोशिश करें!", "botSettings": "बॉट सेटिंग्स", "activitySettingsOverrideWarning": "भाषा और भाषा स्तर गतिविधि योजना द्वारा निर्धारित किया गया है", @@ -11872,14 +11870,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12069,5 +12059,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "संपूर्ण अभ्यास!", + "greatPractice": "महान अभ्यास!", + "usedNoHints": "कोई संकेत न उपयोग करने के लिए अच्छा काम!", + "youveCompletedPractice": "आपने अभ्यास पूरा कर लिया है, बेहतर होने के लिए इसे जारी रखें!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_hr.arb b/lib/l10n/intl_hr.arb index fa3e6d919..d827d59b3 100644 --- a/lib/l10n/intl_hr.arb +++ b/lib/l10n/intl_hr.arb @@ -1,6 +1,6 @@ { "@@locale": "hr", - "@@last_modified": "2026-02-05 10:09:27.459987", + "@@last_modified": "2026-02-09 10:30:35.547762", "about": "Informacije", "@about": { "type": "String", @@ -11109,8 +11109,6 @@ "congratulations": "Čestitamo!", "anotherRound": "Još jedan krug", "noActivityRequest": "Nema trenutnog zahtjeva za aktivnost.", - "quit": "Izlaz", - "congratulationsYouveCompletedPractice": "Čestitamo! Završili ste sesiju vježbanja.", "mustHave10Words": "Morate imati najmanje 10 riječi za vokabular kako biste ih vježbali. Pokušajte razgovarati s prijateljem ili Pangea Botom kako biste otkrili više!", "botSettings": "Postavke Bota", "activitySettingsOverrideWarning": "Jezik i razina jezika određeni planom aktivnosti", @@ -11159,14 +11157,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11356,5 +11346,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Savršena praksa!", + "greatPractice": "Sjajna praksa!", + "usedNoHints": "Odlično, niste koristili nikakve savjete!", + "youveCompletedPractice": "Završili ste praksu, nastavite tako da postanete bolji!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_hu.arb b/lib/l10n/intl_hu.arb index 7cdb4f9a1..ae2ab1174 100644 --- a/lib/l10n/intl_hu.arb +++ b/lib/l10n/intl_hu.arb @@ -1,6 +1,6 @@ { "@@locale": "hu", - "@@last_modified": "2026-02-05 10:09:19.675804", + "@@last_modified": "2026-02-09 10:30:28.032874", "about": "Névjegy", "@about": { "type": "String", @@ -10738,8 +10738,6 @@ "congratulations": "Gratulálunk!", "anotherRound": "Még egy kör", "noActivityRequest": "Jelenleg nincs aktivitás kérés.", - "quit": "Kilépés", - "congratulationsYouveCompletedPractice": "Gratulálunk! Befejezted a gyakorló ülést.", "mustHave10Words": "Legalább 10 szókincsszót kellene gyakorolnod. Próbálj meg beszélni egy baráttal vagy a Pangea Bot-tal, hogy többet felfedezhess!", "botSettings": "Bot beállítások", "activitySettingsOverrideWarning": "A nyelvet és a nyelvi szintet az aktivitási terv határozza meg", @@ -10788,14 +10786,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -10985,5 +10975,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Tökéletes gyakorlás!", + "greatPractice": "Nagyszerű gyakorlás!", + "usedNoHints": "Jó munka, hogy nem használtál semmilyen tippet!", + "youveCompletedPractice": "Befejezted a gyakorlást, folytasd így, hogy jobb legyél!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ia.arb b/lib/l10n/intl_ia.arb index 3321a7c8a..1b5f6a205 100644 --- a/lib/l10n/intl_ia.arb +++ b/lib/l10n/intl_ia.arb @@ -1955,7 +1955,7 @@ "playWithAI": "Joca con le IA pro ora", "courseStartDesc": "Pangea Bot es preste a comenzar a qualunque momento!\n\n...ma apprender es melior con amicos!", "@@locale": "ia", - "@@last_modified": "2026-02-05 10:09:29.962506", + "@@last_modified": "2026-02-09 10:30:38.575679", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11815,8 +11815,6 @@ "congratulations": "Gratulon!", "anotherRound": "Alia rundo", "noActivityRequest": "Ninguna solicitud de actividad actual.", - "quit": "Salir", - "congratulationsYouveCompletedPractice": "¡Felicidades! Has completado la sesión de práctica.", "mustHave10Words": "Debes tener al menos 10 palabras de vocabulario para practicarlas. ¡Intenta hablar con un amigo o con Pangea Bot para descubrir más!", "botSettings": "Configuraciones del Bot", "activitySettingsOverrideWarning": "Idioma y nivel de idioma determinados por el plan de actividad", @@ -11865,14 +11863,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12062,5 +12052,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Praktiko perfekta!", + "greatPractice": "Praktiko granda!", + "usedNoHints": "Bonega laboro ne uzante iujn indikojn!", + "youveCompletedPractice": "Vi finis la praktikon, daŭrigu por pliboniĝi!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_id.arb b/lib/l10n/intl_id.arb index cb48fe670..e773a3d57 100644 --- a/lib/l10n/intl_id.arb +++ b/lib/l10n/intl_id.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:09:21.065759", + "@@last_modified": "2026-02-09 10:30:29.458472", "setAsCanonicalAlias": "Atur sebagai alias utama", "@setAsCanonicalAlias": { "type": "String", @@ -10728,8 +10728,6 @@ "congratulations": "Selamat!", "anotherRound": "Putaran lain", "noActivityRequest": "Tidak ada permintaan aktivitas saat ini.", - "quit": "Keluar", - "congratulationsYouveCompletedPractice": "Selamat! Anda telah menyelesaikan sesi latihan.", "mustHave10Words": "Anda harus memiliki setidaknya 10 kata kosakata untuk berlatih. Cobalah berbicara dengan teman atau Pangea Bot untuk menemukan lebih banyak!", "botSettings": "Pengaturan Bot", "activitySettingsOverrideWarning": "Bahasa dan tingkat bahasa ditentukan oleh rencana aktivitas", @@ -10778,14 +10776,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -10975,5 +10965,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Latihan yang sempurna!", + "greatPractice": "Latihan yang hebat!", + "usedNoHints": "Kerja bagus tidak menggunakan petunjuk!", + "youveCompletedPractice": "Anda telah menyelesaikan latihan, teruskan untuk menjadi lebih baik!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ie.arb b/lib/l10n/intl_ie.arb index d473493ff..1ea252757 100644 --- a/lib/l10n/intl_ie.arb +++ b/lib/l10n/intl_ie.arb @@ -4369,7 +4369,7 @@ "playWithAI": "Joca con AI pro ora", "courseStartDesc": "Pangea Bot es preste a partir a qualunque momento!\n\n...ma apprender es melior con amicos!", "@@locale": "ie", - "@@last_modified": "2026-02-05 10:09:26.195275", + "@@last_modified": "2026-02-09 10:30:34.375227", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11711,8 +11711,6 @@ "congratulations": "Gratulon!", "anotherRound": "Alia rundo", "noActivityRequest": "Ninguna solicitud de actividad actual.", - "quit": "Salir", - "congratulationsYouveCompletedPractice": "¡Felicidades! Has completado la sesión de práctica.", "mustHave10Words": "Debes tener al menos 10 palabras de vocabulario para practicarlas. ¡Intenta hablar con un amigo o con Pangea Bot para descubrir más!", "botSettings": "Configuración del Bot", "activitySettingsOverrideWarning": "Idioma y nivel de idioma determinados por el plan de actividad", @@ -11761,14 +11759,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11958,5 +11948,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Praktika perfekt!", + "greatPractice": "Praktika granda!", + "usedNoHints": "Bonega laboro ne uzante iujn indicojn!", + "youveCompletedPractice": "Vi kompletigis la praktikon, daŭrigu por pliboniĝi!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 066e58676..7d131fbd0 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:09:41.124551", + "@@last_modified": "2026-02-09 10:30:49.194487", "about": "Informazioni", "@about": { "type": "String", @@ -10740,8 +10740,6 @@ "congratulations": "Congratulazioni!", "anotherRound": "Un altro turno", "noActivityRequest": "Nessuna richiesta di attività attuale.", - "quit": "Esci", - "congratulationsYouveCompletedPractice": "Congratulazioni! Hai completato la sessione di pratica.", "mustHave10Words": "Devi avere almeno 10 parole di vocabolario per praticarle. Prova a parlare con un amico o con Pangea Bot per scoprire di più!", "botSettings": "Impostazioni del Bot", "activitySettingsOverrideWarning": "Lingua e livello di lingua determinati dal piano di attività", @@ -10790,14 +10788,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -10987,5 +10977,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Pratica perfetta!", + "greatPractice": "Ottima pratica!", + "usedNoHints": "Ottimo lavoro non usando suggerimenti!", + "youveCompletedPractice": "Hai completato la pratica, continua così per migliorare!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ja.arb b/lib/l10n/intl_ja.arb index 360e48bfa..ac6b45a6e 100644 --- a/lib/l10n/intl_ja.arb +++ b/lib/l10n/intl_ja.arb @@ -1,6 +1,6 @@ { "@@locale": "ja", - "@@last_modified": "2026-02-05 10:10:15.587333", + "@@last_modified": "2026-02-09 10:31:29.266500", "about": "このアプリについて", "@about": { "type": "String", @@ -11527,8 +11527,6 @@ "congratulations": "おめでとうございます!", "anotherRound": "もう一回", "noActivityRequest": "現在のアクティビティリクエストはありません。", - "quit": "終了", - "congratulationsYouveCompletedPractice": "おめでとうございます!練習セッションを完了しました。", "mustHave10Words": "練習するには、少なくとも10語の語彙が必要です。友達やPangea Botに話しかけて、もっと発見してみてください!", "botSettings": "ボット設定", "activitySettingsOverrideWarning": "アクティビティプランによって決定された言語と言語レベル", @@ -11577,14 +11575,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11774,5 +11764,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "完璧な練習!", + "greatPractice": "素晴らしい練習!", + "usedNoHints": "ヒントを使わずによくやった!", + "youveCompletedPractice": "練習を完了しました。これを続けて上達してください!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ka.arb b/lib/l10n/intl_ka.arb index 78003f70e..876451d36 100644 --- a/lib/l10n/intl_ka.arb +++ b/lib/l10n/intl_ka.arb @@ -2591,7 +2591,7 @@ "playWithAI": "ამ დროისთვის ითამაშეთ AI-თან", "courseStartDesc": "Pangea Bot მზადაა ნებისმიერ დროს გასასვლელად!\n\n...მაგრამ სწავლა უკეთესია მეგობრებთან ერთად!", "@@locale": "ka", - "@@last_modified": "2026-02-05 10:10:20.523925", + "@@last_modified": "2026-02-09 10:31:35.201910", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11767,8 +11767,6 @@ "congratulations": "გილოცავთ!", "anotherRound": "მეორე რაუნდი", "noActivityRequest": "ამჟამად აქტივობის მოთხოვნა არ არის.", - "quit": "გამოსვლა", - "congratulationsYouveCompletedPractice": "გილოცავთ! თქვენ დაასრულეთ პრაქტიკის სესია.", "mustHave10Words": "თქვენ უნდა გქონდეთ მინიმუმ 10 სიტყვა, რომ მათ პრაქტიკაში გამოიყენოთ. სცადეთ მეგობართან ან Pangea Bot-თან საუბარი, რომ მეტი აღმოაჩინოთ!", "botSettings": "ბოტის პარამეტრები", "activitySettingsOverrideWarning": "ენასა და ენების დონეს განსაზღვრავს აქტივობის გეგმა", @@ -11817,14 +11815,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12014,5 +12004,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "შესანიშნავი პრაქტიკა!", + "greatPractice": "დიდებული პრაქტიკა!", + "usedNoHints": "კარგი საქმე, რომ არ გამოიყენე არცერთი მინიშნება!", + "youveCompletedPractice": "თქვენ დაასრულეთ პრაქტიკა, გააგრძელეთ ასე, რომ უკეთესი გახდეთ!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ko.arb b/lib/l10n/intl_ko.arb index 63b504369..f79ecf8f8 100644 --- a/lib/l10n/intl_ko.arb +++ b/lib/l10n/intl_ko.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:09:10.325473", + "@@last_modified": "2026-02-09 10:30:18.919050", "about": "소개", "@about": { "type": "String", @@ -10845,8 +10845,6 @@ "congratulations": "축하합니다!", "anotherRound": "또 다른 라운드", "noActivityRequest": "현재 활동 요청이 없습니다.", - "quit": "종료", - "congratulationsYouveCompletedPractice": "축하합니다! 연습 세션을 완료했습니다.", "mustHave10Words": "연습할 단어가 최소 10개 이상 있어야 합니다. 친구나 Pangea Bot과 대화하여 더 많은 것을 발견해 보세요!", "botSettings": "봇 설정", "activitySettingsOverrideWarning": "활동 계획에 의해 결정된 언어 및 언어 수준", @@ -10895,14 +10893,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11092,5 +11082,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "완벽한 연습!", + "greatPractice": "훌륭한 연습!", + "usedNoHints": "힌트를 사용하지 않아서 잘했어요!", + "youveCompletedPractice": "연습을 완료했습니다. 계속해서 더 나아지세요!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_lt.arb b/lib/l10n/intl_lt.arb index 8a69923f6..280b130fb 100644 --- a/lib/l10n/intl_lt.arb +++ b/lib/l10n/intl_lt.arb @@ -3858,7 +3858,7 @@ "playWithAI": "Žaiskite su dirbtiniu intelektu dabar", "courseStartDesc": "Pangea botas pasiruošęs bet kada pradėti!\n\n...bet mokymasis yra geresnis su draugais!", "@@locale": "lt", - "@@last_modified": "2026-02-05 10:10:01.069181", + "@@last_modified": "2026-02-09 10:31:13.179961", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11542,8 +11542,6 @@ "congratulations": "Sveikiname!", "anotherRound": "Dar viena raundas", "noActivityRequest": "Nėra dabartinio veiklos prašymo.", - "quit": "Išeiti", - "congratulationsYouveCompletedPractice": "Sveikiname! Jūs baigėte praktikos sesiją.", "mustHave10Words": "Turite turėti bent 10 žodžių, kad galėtumėte juos praktikuoti. Pabandykite pasikalbėti su draugu arba Pangea Bot, kad sužinotumėte daugiau!", "botSettings": "Roboto nustatymai", "activitySettingsOverrideWarning": "Kalba ir kalbos lygis nustatomi pagal veiklos planą", @@ -11592,14 +11590,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11789,5 +11779,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Tobulas praktika!", + "greatPractice": "Puiki praktika!", + "usedNoHints": "Puikus darbas, kad nenaudojote jokių užuominų!", + "youveCompletedPractice": "Jūs baigėte praktiką, tęskite, kad taptumėte geresni!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_lv.arb b/lib/l10n/intl_lv.arb index 55e1affc6..d51702338 100644 --- a/lib/l10n/intl_lv.arb +++ b/lib/l10n/intl_lv.arb @@ -4480,7 +4480,7 @@ "playWithAI": "Tagad spēlējiet ar AI", "courseStartDesc": "Pangea bots ir gatavs jebkurā laikā!\n\n...bet mācīties ir labāk ar draugiem!", "@@locale": "lv", - "@@last_modified": "2026-02-05 10:09:54.766036", + "@@last_modified": "2026-02-09 10:31:05.908661", "analyticsInactiveTitle": "Pieprasījumi neaktīviem lietotājiem nevar tikt nosūtīti", "analyticsInactiveDesc": "Neaktīvi lietotāji, kuri nav pieteikušies kopš šīs funkcijas ieviešanas, neredzēs jūsu pieprasījumu.\n\nPieprasījuma poga parādīsies, kad viņi atgriezīsies. Jūs varat atkārtoti nosūtīt pieprasījumu vēlāk, noklikšķinot uz pieprasījuma pogas viņu vārdā, kad tā būs pieejama.", "accessRequestedTitle": "Pieprasījums piekļūt analītikai", @@ -10723,8 +10723,6 @@ "congratulations": "Apsveicam!", "anotherRound": "Vēl viena kārta", "noActivityRequest": "Nav pašreizējo aktivitāšu pieprasījumu.", - "quit": "Iziet", - "congratulationsYouveCompletedPractice": "Apsveicam! Jūs esat pabeidzis prakses sesiju.", "mustHave10Words": "Jums jābūt vismaz 10 vārdiem, lai tos praktizētu. Mēģiniet parunāt ar draugu vai Pangea Bot, lai uzzinātu vairāk!", "botSettings": "Bota iestatījumi", "activitySettingsOverrideWarning": "Valoda un valodas līmenis, ko nosaka aktivitāšu plāns", @@ -10773,14 +10771,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -10970,5 +10960,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Lieliska prakse!", + "greatPractice": "Lieliska prakse!", + "usedNoHints": "Lieliski, ka neizmantoji nevienu padomu!", + "youveCompletedPractice": "Tu esi pabeidzis praksi, turpini tādā garā, lai uzlabotos!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_nb.arb b/lib/l10n/intl_nb.arb index 9b520323a..faf5dc164 100644 --- a/lib/l10n/intl_nb.arb +++ b/lib/l10n/intl_nb.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:09:45.325631", + "@@last_modified": "2026-02-09 10:30:55.280815", "about": "Om", "@about": { "type": "String", @@ -11830,8 +11830,6 @@ "congratulations": "Gratulerer!", "anotherRound": "En runde til", "noActivityRequest": "Ingen nåværende aktivitetsforespørsel.", - "quit": "Avslutt", - "congratulationsYouveCompletedPractice": "Gratulerer! Du har fullført økt med øvelser.", "mustHave10Words": "Du må ha minst 10 ordforråd for å øve på dem. Prøv å snakke med en venn eller Pangea Bot for å oppdage mer!", "botSettings": "Bot-innstillinger", "activitySettingsOverrideWarning": "Språk og språknivå bestemt av aktivitetsplan", @@ -11880,14 +11878,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12077,5 +12067,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Perfekt øvelse!", + "greatPractice": "Flott øvelse!", + "usedNoHints": "Bra jobba med å ikke bruke noen hint!", + "youveCompletedPractice": "Du har fullført øvelsen, fortsett slik for å bli bedre!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_nl.arb b/lib/l10n/intl_nl.arb index 6dea520bb..981a2e752 100644 --- a/lib/l10n/intl_nl.arb +++ b/lib/l10n/intl_nl.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:10:04.986596", + "@@last_modified": "2026-02-09 10:31:17.188799", "about": "Over ons", "@about": { "type": "String", @@ -10737,8 +10737,6 @@ "congratulations": "Gefeliciteerd!", "anotherRound": "Nog een ronde", "noActivityRequest": "Geen huidige activiteit aanvraag.", - "quit": "Afsluiten", - "congratulationsYouveCompletedPractice": "Gefeliciteerd! Je hebt de oefensessie voltooid.", "mustHave10Words": "Je moet minstens 10 vocabulairewoorden hebben om ze te oefenen. Probeer met een vriend of Pangea Bot te praten om meer te ontdekken!", "botSettings": "Botinstellingen", "activitySettingsOverrideWarning": "Taal en taalniveau bepaald door het activiteitenplan", @@ -10787,14 +10785,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -10984,5 +10974,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Perfecte oefening!", + "greatPractice": "Geweldige oefening!", + "usedNoHints": "Goed gedaan, geen hints gebruikt!", + "youveCompletedPractice": "Je hebt de oefening voltooid, ga zo door om beter te worden!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index aaa6f96a0..2bdb85519 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -1,6 +1,6 @@ { "@@locale": "pl", - "@@last_modified": "2026-02-05 10:10:11.791789", + "@@last_modified": "2026-02-09 10:31:24.557539", "about": "O aplikacji", "@about": { "type": "String", @@ -10735,8 +10735,6 @@ "congratulations": "Gratulacje!", "anotherRound": "Kolejna runda", "noActivityRequest": "Brak bieżącego żądania aktywności.", - "quit": "Zakończ", - "congratulationsYouveCompletedPractice": "Gratulacje! Ukończyłeś sesję ćwiczeń.", "mustHave10Words": "Musisz mieć co najmniej 10 słówek do ćwiczenia. Spróbuj porozmawiać z przyjacielem lub Pangea Bot, aby odkryć więcej!", "botSettings": "Ustawienia bota", "activitySettingsOverrideWarning": "Język i poziom językowy określone przez plan aktywności", @@ -10785,14 +10783,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -10982,5 +10972,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Idealna praktyka!", + "greatPractice": "Świetna praktyka!", + "usedNoHints": "Dobra robota, że nie korzystałeś z żadnych wskazówek!", + "youveCompletedPractice": "Ukończyłeś praktykę, kontynuuj, aby się poprawić!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 9b8ecb9b7..6f537dcd1 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:09:34.897101", + "@@last_modified": "2026-02-09 10:30:42.465226", "copiedToClipboard": "Copiada para a área de transferência", "@copiedToClipboard": { "type": "String", @@ -11837,8 +11837,6 @@ "congratulations": "Parabéns!", "anotherRound": "Outra rodada", "noActivityRequest": "Nenhum pedido de atividade atual.", - "quit": "Sair", - "congratulationsYouveCompletedPractice": "Parabéns! Você completou a sessão de prática.", "mustHave10Words": "Você deve ter pelo menos 10 palavras de vocabulário para praticá-las. Tente conversar com um amigo ou com o Pangea Bot para descobrir mais!", "botSettings": "Configurações do Bot", "activitySettingsOverrideWarning": "Idioma e nível de idioma determinados pelo plano de atividade", @@ -11887,14 +11885,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12084,5 +12074,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Prática perfeita!", + "greatPractice": "Ótima prática!", + "usedNoHints": "Bom trabalho em não usar dicas!", + "youveCompletedPractice": "Você completou a prática, continue assim para melhorar!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pt_BR.arb b/lib/l10n/intl_pt_BR.arb index cbe1ca38b..6fd6bd30c 100644 --- a/lib/l10n/intl_pt_BR.arb +++ b/lib/l10n/intl_pt_BR.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:09:31.755690", + "@@last_modified": "2026-02-09 10:30:39.705540", "about": "Sobre", "@about": { "type": "String", @@ -11095,8 +11095,6 @@ "congratulations": "Parabéns!", "anotherRound": "Outra rodada", "noActivityRequest": "Nenhum pedido de atividade atual.", - "quit": "Sair", - "congratulationsYouveCompletedPractice": "Parabéns! Você completou a sessão de prática.", "mustHave10Words": "Você deve ter pelo menos 10 palavras de vocabulário para praticá-las. Tente conversar com um amigo ou com o Pangea Bot para descobrir mais!", "botSettings": "Configurações do Bot", "activitySettingsOverrideWarning": "Idioma e nível de idioma determinados pelo plano de atividade", @@ -11145,14 +11143,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11342,5 +11332,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Prática perfeita!", + "greatPractice": "Ótima prática!", + "usedNoHints": "Bom trabalho não usando dicas!", + "youveCompletedPractice": "Você completou a prática, continue assim para melhorar!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pt_PT.arb b/lib/l10n/intl_pt_PT.arb index c74ddde52..2e2dad6cf 100644 --- a/lib/l10n/intl_pt_PT.arb +++ b/lib/l10n/intl_pt_PT.arb @@ -3328,7 +3328,7 @@ "selectAll": "Selecionar tudo", "deselectAll": "Desmarcar tudo", "@@locale": "pt_PT", - "@@last_modified": "2026-02-05 10:09:50.725651", + "@@last_modified": "2026-02-09 10:31:01.370163", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11766,8 +11766,6 @@ "congratulations": "Parabéns!", "anotherRound": "Outra rodada", "noActivityRequest": "Nenhum pedido de atividade atual.", - "quit": "Sair", - "congratulationsYouveCompletedPractice": "Parabéns! Você completou a sessão de prática.", "mustHave10Words": "Você deve ter pelo menos 10 palavras de vocabulário para praticar. Tente conversar com um amigo ou com o Pangea Bot para descobrir mais!", "botSettings": "Configurações do Bot", "activitySettingsOverrideWarning": "Idioma e nível de idioma determinados pelo plano de atividade", @@ -11816,14 +11814,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12013,5 +12003,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Prática perfeita!", + "greatPractice": "Ótima prática!", + "usedNoHints": "Bom trabalho em não usar dicas!", + "youveCompletedPractice": "Você completou a prática, continue assim para melhorar!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ro.arb b/lib/l10n/intl_ro.arb index 8dc92a39b..b63507f52 100644 --- a/lib/l10n/intl_ro.arb +++ b/lib/l10n/intl_ro.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:09:23.119007", + "@@last_modified": "2026-02-09 10:30:31.024987", "about": "Despre", "@about": { "type": "String", @@ -11472,8 +11472,6 @@ "congratulations": "Felicitări!", "anotherRound": "Încă o rundă", "noActivityRequest": "Nu există cereri de activitate curente.", - "quit": "Ieși", - "congratulationsYouveCompletedPractice": "Felicitări! Ai completat sesiunea de practică.", "mustHave10Words": "Trebuie să ai cel puțin 10 cuvinte de vocabular pentru a le exersa. Încearcă să vorbești cu un prieten sau cu Pangea Bot pentru a descoperi mai multe!", "botSettings": "Setări Bot", "activitySettingsOverrideWarning": "Limba și nivelul de limbă sunt determinate de planul de activitate", @@ -11522,14 +11520,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11719,5 +11709,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Practica perfectă!", + "greatPractice": "Practica grozavă!", + "usedNoHints": "Bravo că nu ai folosit niciun indiciu!", + "youveCompletedPractice": "Ai finalizat practica, continuă așa pentru a te îmbunătăți!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 777cd260a..acd67ec0d 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -1,6 +1,6 @@ { "@@locale": "ru", - "@@last_modified": "2026-02-05 10:10:19.334362", + "@@last_modified": "2026-02-09 10:31:33.668055", "about": "О проекте", "@about": { "type": "String", @@ -10826,8 +10826,6 @@ "congratulations": "Поздравляем!", "anotherRound": "Еще один раунд", "noActivityRequest": "Нет текущего запроса на активность.", - "quit": "Выйти", - "congratulationsYouveCompletedPractice": "Поздравляем! Вы завершили практическую сессию.", "mustHave10Words": "Вы должны иметь как минимум 10 слов для практики. Попробуйте поговорить с другом или Pangea Bot, чтобы узнать больше!", "botSettings": "Настройки бота", "activitySettingsOverrideWarning": "Язык и уровень языка определяются планом активности", @@ -10876,14 +10874,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11092,5 +11082,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Идеальная практика!", + "greatPractice": "Отличная практика!", + "usedNoHints": "Хорошая работа, что не использовали подсказки!", + "youveCompletedPractice": "Вы завершили практику, продолжайте в том же духе, чтобы стать лучше!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sk.arb b/lib/l10n/intl_sk.arb index d6437f578..f372fb272 100644 --- a/lib/l10n/intl_sk.arb +++ b/lib/l10n/intl_sk.arb @@ -1,6 +1,6 @@ { "@@locale": "sk", - "@@last_modified": "2026-02-05 10:09:24.752898", + "@@last_modified": "2026-02-09 10:30:32.743132", "about": "O aplikácii", "@about": { "type": "String", @@ -11821,8 +11821,6 @@ "congratulations": "Gratulujeme!", "anotherRound": "Ďalšie kolo", "noActivityRequest": "Žiadna aktuálna požiadavka na aktivitu.", - "quit": "Ukončiť", - "congratulationsYouveCompletedPractice": "Gratulujeme! Dokončili ste cvičebnú reláciu.", "mustHave10Words": "Musíte mať aspoň 10 slovíčok na precvičovanie. Skúste sa porozprávať s priateľom alebo Pangea Botom, aby ste objavili viac!", "botSettings": "Nastavenia bota", "activitySettingsOverrideWarning": "Jazyk a jazyková úroveň určené plánom aktivity", @@ -11871,14 +11869,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12068,5 +12058,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Dokonalá prax!", + "greatPractice": "Skvelá prax!", + "usedNoHints": "Dobrý výkon, že si nepoužil žiadne nápovedy!", + "youveCompletedPractice": "Dokončil si prax, pokračuj v tom, aby si sa zlepšil!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sl.arb b/lib/l10n/intl_sl.arb index 9743b3040..3a79289b9 100644 --- a/lib/l10n/intl_sl.arb +++ b/lib/l10n/intl_sl.arb @@ -2461,7 +2461,7 @@ "playWithAI": "Za zdaj igrajte z AI-jem", "courseStartDesc": "Pangea Bot je pripravljen kadarkoli!\n\n...ampak je bolje učiti se s prijatelji!", "@@locale": "sl", - "@@last_modified": "2026-02-05 10:09:38.721866", + "@@last_modified": "2026-02-09 10:30:46.615986", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11818,8 +11818,6 @@ "congratulations": "Čestitamo!", "anotherRound": "Še en krog", "noActivityRequest": "Trenutno ni zahtevka za aktivnost.", - "quit": "Izhod", - "congratulationsYouveCompletedPractice": "Čestitamo! Zaključili ste vadbeno sejo.", "mustHave10Words": "Imeti morate vsaj 10 besed za besedišče, da jih lahko vadite. Poskusite se pogovoriti s prijateljem ali Pangea Botom, da odkrijete več!", "botSettings": "Nastavitve bota", "activitySettingsOverrideWarning": "Jezik in jezikovna raven sta določena z načrtom aktivnosti", @@ -11868,14 +11866,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12065,5 +12055,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Popolna praksa!", + "greatPractice": "Super praksa!", + "usedNoHints": "Odlično, da niste uporabili nobenih namigov!", + "youveCompletedPractice": "Zaključili ste prakso, nadaljujte tako, da boste boljši!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sr.arb b/lib/l10n/intl_sr.arb index 831875ff3..b064171a5 100644 --- a/lib/l10n/intl_sr.arb +++ b/lib/l10n/intl_sr.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:10:22.625655", + "@@last_modified": "2026-02-09 10:31:36.571438", "about": "О програму", "@about": { "type": "String", @@ -11839,8 +11839,6 @@ "congratulations": "Čestitamo!", "anotherRound": "Još jedan krug", "noActivityRequest": "Nema trenutnog zahteva za aktivnost.", - "quit": "Izlaz", - "congratulationsYouveCompletedPractice": "Čestitamo! Završili ste sesiju vežbanja.", "mustHave10Words": "Morate imati najmanje 10 reči za rečnik da biste ih vežbali. Pokušajte da razgovarate sa prijateljem ili Pangea Bot-om da biste otkrili više!", "botSettings": "Podešavanja Bota", "activitySettingsOverrideWarning": "Jezik i nivo jezika određeni planom aktivnosti", @@ -11889,14 +11887,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12086,5 +12076,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Savršena praksa!", + "greatPractice": "Sjajna praksa!", + "usedNoHints": "Odlično, niste koristili nikakve savete!", + "youveCompletedPractice": "Završili ste praksu, nastavite tako da postanete bolji!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sv.arb b/lib/l10n/intl_sv.arb index 721c6fbe0..c5a281579 100644 --- a/lib/l10n/intl_sv.arb +++ b/lib/l10n/intl_sv.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:10:13.035755", + "@@last_modified": "2026-02-09 10:31:25.986941", "about": "Om", "@about": { "type": "String", @@ -11215,8 +11215,6 @@ "congratulations": "Grattis!", "anotherRound": "En runda till", "noActivityRequest": "Ingen aktuell aktivitetsförfrågan.", - "quit": "Avsluta", - "congratulationsYouveCompletedPractice": "Grattis! Du har slutfört övningssessionen.", "mustHave10Words": "Du måste ha minst 10 ord för att öva dem. Försök att prata med en vän eller Pangea Bot för att upptäcka mer!", "botSettings": "Botinställningar", "activitySettingsOverrideWarning": "Språk och språknivå bestäms av aktivitetsplanen", @@ -11265,14 +11263,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11462,5 +11452,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Perfekt träning!", + "greatPractice": "Bra träning!", + "usedNoHints": "Bra jobbat utan att använda några ledtrådar!", + "youveCompletedPractice": "Du har slutfört träningen, fortsätt så för att bli bättre!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ta.arb b/lib/l10n/intl_ta.arb index 744131c01..82c5efb6a 100644 --- a/lib/l10n/intl_ta.arb +++ b/lib/l10n/intl_ta.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:10:03.555298", + "@@last_modified": "2026-02-09 10:31:16.047418", "acceptedTheInvitation": "👍 {username} அழைப்பை ஏற்றுக்கொண்டது", "@acceptedTheInvitation": { "type": "String", @@ -10961,8 +10961,6 @@ "congratulations": "வாழ்த்துகள்!", "anotherRound": "மற்றொரு சுற்று", "noActivityRequest": "தற்போதைய செயல்பாட்டுக்கான கோரிக்கை இல்லை.", - "quit": "விலகுங்கள்", - "congratulationsYouveCompletedPractice": "வாழ்த்துகள்! நீங்கள் பயிற்சி அமர்வை முடித்துவிட்டீர்கள்.", "mustHave10Words": "நீங்கள் பயிற்சிக்காக குறைந்தது 10 சொற்களை வைத்திருக்க வேண்டும். மேலும் கண்டுபிடிக்க நண்பருடன் அல்லது பாஙோ பாட்டுடன் பேச முயற்சிக்கவும்!", "botSettings": "பாடல் அமைப்புகள்", "activitySettingsOverrideWarning": "செயல்பாட்டு திட்டத்தால் நிர்ணயிக்கப்பட்ட மொழி மற்றும் மொழி நிலை", @@ -11011,14 +11009,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11208,5 +11198,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "சரியான பயிற்சி!", + "greatPractice": "மிகவும் நல்ல பயிற்சி!", + "usedNoHints": "எந்த உதவியையும் பயன்படுத்தாததற்கு நல்ல வேலை!", + "youveCompletedPractice": "நீங்கள் பயிற்சியை முடித்துவிட்டீர்கள், மேலும் மேம்பட தொடர்ந்து முயற்சிக்கவும்!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_te.arb b/lib/l10n/intl_te.arb index e1f8f030c..5dbeb11ec 100644 --- a/lib/l10n/intl_te.arb +++ b/lib/l10n/intl_te.arb @@ -1917,7 +1917,7 @@ "playWithAI": "ఇప్పుడే AI తో ఆడండి", "courseStartDesc": "పాంజియా బాట్ ఎప్పుడైనా సిద్ధంగా ఉంటుంది!\n\n...కానీ స్నేహితులతో నేర్చుకోవడం మెరుగైనది!", "@@locale": "te", - "@@last_modified": "2026-02-05 10:09:59.064928", + "@@last_modified": "2026-02-09 10:31:11.484072", "@setCustomPermissionLevel": { "type": "String", "placeholders": {} @@ -11826,8 +11826,6 @@ "congratulations": "అభినందనలు!", "anotherRound": "మరొక రౌండ్", "noActivityRequest": "ప్రస్తుతం ఎలాంటి కార్యకలాపం అభ్యర్థన లేదు.", - "quit": "విడుదల", - "congratulationsYouveCompletedPractice": "అభినందనలు! మీరు అభ్యాస సెషన్‌ను పూర్తి చేశారు.", "mustHave10Words": "మీరు వాటిని అభ్యాసం చేయడానికి కనీసం 10 పదాలను కలిగి ఉండాలి. మరింత తెలుసుకోవడానికి మీ స్నేహితుడితో లేదా పాంజియా బాట్‌తో మాట్లాడండి!", "botSettings": "బాట్ సెట్టింగులు", "activitySettingsOverrideWarning": "కార్యకలాపం ప్రణాళిక ద్వారా నిర్ణయించబడిన భాష మరియు భాష స్థాయి", @@ -11876,14 +11874,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12073,5 +12063,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "సంపూర్ణ అభ్యాసం!", + "greatPractice": "మంచి అభ్యాసం!", + "usedNoHints": "ఏ సూచనలు ఉపయోగించకపోవడం మంచి పని!", + "youveCompletedPractice": "మీరు అభ్యాసం పూర్తి చేసారు, మెరుగుపడటానికి ఇలాగే కొనసాగించండి!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_th.arb b/lib/l10n/intl_th.arb index 2f5e88895..d048c59c9 100644 --- a/lib/l10n/intl_th.arb +++ b/lib/l10n/intl_th.arb @@ -4453,7 +4453,7 @@ "playWithAI": "เล่นกับ AI ชั่วคราว", "courseStartDesc": "Pangea Bot พร้อมที่จะเริ่มต้นได้ทุกเมื่อ!\n\n...แต่การเรียนรู้ดีกว่ากับเพื่อน!", "@@locale": "th", - "@@last_modified": "2026-02-05 10:09:49.236652", + "@@last_modified": "2026-02-09 10:31:00.286726", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11795,8 +11795,6 @@ "congratulations": "ขอแสดงความยินดี!", "anotherRound": "อีกหนึ่งรอบ", "noActivityRequest": "ไม่มีคำขอทำกิจกรรมในขณะนี้", - "quit": "ออก", - "congratulationsYouveCompletedPractice": "ขอแสดงความยินดี! คุณได้เสร็จสิ้นการฝึกฝนแล้ว", "mustHave10Words": "คุณต้องมีคำศัพท์อย่างน้อย 10 คำเพื่อฝึกฝน ลองพูดคุยกับเพื่อนหรือ Pangea Bot เพื่อค้นพบเพิ่มเติม!", "botSettings": "การตั้งค่า Bot", "activitySettingsOverrideWarning": "ภาษาและระดับภาษาที่กำหนดโดยแผนกิจกรรม", @@ -11845,14 +11843,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12042,5 +12032,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "การฝึกฝนที่สมบูรณ์แบบ!", + "greatPractice": "การฝึกฝนที่ยอดเยี่ยม!", + "usedNoHints": "ทำได้ดีที่ไม่ใช้คำใบ้ใด ๆ!", + "youveCompletedPractice": "คุณได้ทำการฝึกฝนเสร็จสิ้นแล้ว ทำต่อไปเพื่อให้ดีขึ้น!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index 3df573555..0048a617e 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -1,6 +1,6 @@ { "@@locale": "tr", - "@@last_modified": "2026-02-05 10:09:57.710087", + "@@last_modified": "2026-02-09 10:31:10.118107", "about": "Hakkında", "@about": { "type": "String", @@ -10959,8 +10959,6 @@ "congratulations": "Tebrikler!", "anotherRound": "Bir tur daha", "noActivityRequest": "Şu anda etkinlik talebi yok.", - "quit": "Çık", - "congratulationsYouveCompletedPractice": "Tebrikler! Pratik oturumunu tamamladınız.", "mustHave10Words": "Pratik yapmak için en az 10 kelimeye sahip olmalısınız. Daha fazla keşfetmek için bir arkadaşınızla veya Pangea Bot ile konuşmayı deneyin!", "botSettings": "Bot Ayarları", "activitySettingsOverrideWarning": "Etkinlik planı tarafından belirlenen dil ve dil seviyesi", @@ -11009,14 +11007,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -11206,5 +11196,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Mükemmel pratik!", + "greatPractice": "Harika pratik!", + "usedNoHints": "Hiç ipucu kullanmadığın için iyi iş çıkardın!", + "youveCompletedPractice": "Pratiği tamamladın, daha iyi olmak için devam et!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_uk.arb b/lib/l10n/intl_uk.arb index d4d7de8bb..dbfcea87b 100644 --- a/lib/l10n/intl_uk.arb +++ b/lib/l10n/intl_uk.arb @@ -1,6 +1,6 @@ { "@@locale": "uk", - "@@last_modified": "2026-02-05 10:09:42.549877", + "@@last_modified": "2026-02-09 10:30:50.645170", "about": "Про застосунок", "@about": { "type": "String", @@ -10731,8 +10731,6 @@ "congratulations": "Вітаємо!", "anotherRound": "Ще один раунд", "noActivityRequest": "Немає поточного запиту на активність.", - "quit": "Вийти", - "congratulationsYouveCompletedPractice": "Вітаємо! Ви завершили практичну сесію.", "mustHave10Words": "Вам потрібно мати принаймні 10 слів для практики. Спробуйте поговорити з другом або Pangea Bot, щоб дізнатися більше!", "botSettings": "Налаштування бота", "activitySettingsOverrideWarning": "Мова та рівень мови визначаються планом активності", @@ -10781,14 +10779,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -10978,5 +10968,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Ідеальна практика!", + "greatPractice": "Чудова практика!", + "usedNoHints": "Чудова робота, що не використовували підказки!", + "youveCompletedPractice": "Ви завершили практику, продовжуйте в тому ж дусі, щоб покращитися!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_vi.arb b/lib/l10n/intl_vi.arb index 7c8c9bc73..a8b484fc8 100644 --- a/lib/l10n/intl_vi.arb +++ b/lib/l10n/intl_vi.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:10:02.295528", + "@@last_modified": "2026-02-09 10:31:14.436728", "about": "Giới thiệu", "@about": { "type": "String", @@ -6315,8 +6315,6 @@ "congratulations": "Chúc mừng!", "anotherRound": "Một vòng nữa", "noActivityRequest": "Không có yêu cầu hoạt động nào hiện tại.", - "quit": "Thoát", - "congratulationsYouveCompletedPractice": "Chúc mừng! Bạn đã hoàn thành buổi thực hành.", "mustHave10Words": "Bạn phải có ít nhất 10 từ vựng để thực hành. Hãy thử nói chuyện với một người bạn hoặc Pangea Bot để khám phá thêm!", "botSettings": "Cài đặt Bot", "activitySettingsOverrideWarning": "Ngôn ngữ và cấp độ ngôn ngữ được xác định bởi kế hoạch hoạt động", @@ -6365,14 +6363,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -6562,5 +6552,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "Thực hành hoàn hảo!", + "greatPractice": "Thực hành tuyệt vời!", + "usedNoHints": "Làm tốt lắm khi không sử dụng bất kỳ gợi ý nào!", + "youveCompletedPractice": "Bạn đã hoàn thành thực hành, hãy tiếp tục để cải thiện!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_yue.arb b/lib/l10n/intl_yue.arb index 410706220..b796030d0 100644 --- a/lib/l10n/intl_yue.arb +++ b/lib/l10n/intl_yue.arb @@ -1853,7 +1853,7 @@ "selectAll": "全選", "deselectAll": "取消全選", "@@locale": "yue", - "@@last_modified": "2026-02-05 10:09:39.916672", + "@@last_modified": "2026-02-09 10:30:47.898227", "@ignoreUser": { "type": "String", "placeholders": {} @@ -11828,8 +11828,6 @@ "congratulations": "恭喜!", "anotherRound": "再來一輪", "noActivityRequest": "目前沒有活動請求。", - "quit": "退出", - "congratulationsYouveCompletedPractice": "恭喜!你已完成練習課程。", "mustHave10Words": "你必須至少有 10 個詞彙來練習它們。試著和朋友或 Pangea Bot 談談以發現更多!", "botSettings": "機械人設置", "activitySettingsOverrideWarning": "活動計劃決定的語言和語言水平", @@ -11878,14 +11876,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -12075,5 +12065,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "完美的練習!", + "greatPractice": "很棒的練習!", + "usedNoHints": "不使用任何提示,做得好!", + "youveCompletedPractice": "你已經完成了練習,繼續努力以變得更好!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index e141c5b19..50917324d 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -1,6 +1,6 @@ { "@@locale": "zh", - "@@last_modified": "2026-02-05 10:10:07.531332", + "@@last_modified": "2026-02-09 10:31:19.946784", "about": "关于", "@about": { "type": "String", @@ -10728,8 +10728,6 @@ "congratulations": "恭喜!", "anotherRound": "再来一轮", "noActivityRequest": "当前没有活动请求。", - "quit": "退出", - "congratulationsYouveCompletedPractice": "恭喜!您已完成练习课程。", "mustHave10Words": "您必须至少有 10 个词汇来进行练习。尝试与朋友或 Pangea Bot 交谈以发现更多!", "botSettings": "机器人设置", "activitySettingsOverrideWarning": "活动计划确定的语言和语言级别", @@ -10778,14 +10776,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -10975,5 +10965,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "完美的练习!", + "greatPractice": "很棒的练习!", + "usedNoHints": "很好,没使用任何提示!", + "youveCompletedPractice": "你已经完成了练习,继续努力以变得更好!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_zh_Hant.arb b/lib/l10n/intl_zh_Hant.arb index 0aea18e73..6180510c5 100644 --- a/lib/l10n/intl_zh_Hant.arb +++ b/lib/l10n/intl_zh_Hant.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-05 10:09:52.100652", + "@@last_modified": "2026-02-09 10:31:02.904070", "about": "關於", "@about": { "type": "String", @@ -10735,8 +10735,6 @@ "congratulations": "恭喜!", "anotherRound": "再來一輪", "noActivityRequest": "目前沒有活動請求。", - "quit": "退出", - "congratulationsYouveCompletedPractice": "恭喜!您已完成練習課程。", "mustHave10Words": "您必須至少有 10 個詞彙來進行練習。嘗試與朋友或 Pangea Bot 交談以發現更多!", "botSettings": "機器人設定", "activitySettingsOverrideWarning": "語言和語言級別由活動計劃決定", @@ -10785,14 +10783,6 @@ "type": "String", "placeholders": {} }, - "@quit": { - "type": "String", - "placeholders": {} - }, - "@congratulationsYouveCompletedPractice": { - "type": "String", - "placeholders": {} - }, "@mustHave10Words": { "type": "String", "placeholders": {} @@ -10982,5 +10972,25 @@ "@grammarCopyPOScompn": { "type": "String", "placeholders": {} + }, + "perfectPractice": "完美的練習!", + "greatPractice": "很棒的練習!", + "usedNoHints": "不使用任何提示,做得好!", + "youveCompletedPractice": "你已完成練習,繼續努力以變得更好!", + "@perfectPractice": { + "type": "String", + "placeholders": {} + }, + "@greatPractice": { + "type": "String", + "placeholders": {} + }, + "@usedNoHints": { + "type": "String", + "placeholders": {} + }, + "@youveCompletedPractice": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart index bd371aa81..4c703aacb 100644 --- a/lib/pangea/analytics_data/analytics_data_service.dart +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -241,7 +241,7 @@ class AnalyticsDataService { int? count, String? roomId, DateTime? since, - ConstructUseTypeEnum? type, + List? types, bool filterCapped = true, }) async { await _ensureInitialized(); @@ -249,7 +249,7 @@ class AnalyticsDataService { count: count, roomId: roomId, since: since, - type: type, + types: types, ); final blocked = blockedConstructs; diff --git a/lib/pangea/analytics_data/analytics_database.dart b/lib/pangea/analytics_data/analytics_database.dart index 870bd9f61..478f16155 100644 --- a/lib/pangea/analytics_data/analytics_database.dart +++ b/lib/pangea/analytics_data/analytics_database.dart @@ -204,7 +204,7 @@ class AnalyticsDatabase with DatabaseFileStorage { int? count, String? roomId, DateTime? since, - ConstructUseTypeEnum? type, + List? types, }) async { final stopwatch = Stopwatch()..start(); final results = []; @@ -216,7 +216,7 @@ class AnalyticsDatabase with DatabaseFileStorage { if (roomId != null && use.metadata.roomId != roomId) { return true; // skip but continue } - if (type != null && use.useType != type) { + if (types != null && !types.contains(use.useType)) { return true; // skip but continue } diff --git a/lib/pangea/analytics_practice/analytics_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart index 1bcc52d01..b64b363f1 100644 --- a/lib/pangea/analytics_practice/analytics_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -13,6 +13,7 @@ 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_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/analytics_practice/analytics_practice_session_repo.dart'; import 'package:fluffychat/pangea/analytics_practice/analytics_practice_view.dart'; @@ -101,6 +102,8 @@ class AnalyticsPracticeState extends State ValueNotifier(null); final ValueNotifier hintPressedNotifier = ValueNotifier(false); + final ValueNotifier hintsUsedNotifier = ValueNotifier(0); + static const int maxHints = 5; final Map> _choiceTexts = {}; final Map> _choiceEmojis = {}; @@ -127,6 +130,7 @@ class AnalyticsPracticeState extends State enableChoicesNotifier.dispose(); selectedMorphChoice.dispose(); hintPressedNotifier.dispose(); + hintsUsedNotifier.dispose(); super.dispose(); } @@ -213,6 +217,7 @@ class AnalyticsPracticeState extends State activityTarget.value = null; selectedMorphChoice.value = null; hintPressedNotifier.value = false; + hintsUsedNotifier.value = 0; enableChoicesNotifier.value = true; progressNotifier.value = 0.0; _queue.clear(); @@ -483,7 +488,11 @@ class AnalyticsPracticeState extends State } void onHintPressed() { - hintPressedNotifier.value = !hintPressedNotifier.value; + if (hintsUsedNotifier.value >= maxHints) return; + if (!hintPressedNotifier.value) { + hintsUsedNotifier.value++; + } + hintPressedNotifier.value = true; } Future onSelectChoice( @@ -556,6 +565,36 @@ class AnalyticsPracticeState extends State Future get derivedAnalyticsData => _analyticsService.derivedData; + /// Returns congratulations message based on performance + String getCompletionMessage(BuildContext context) { + final accuracy = _sessionLoader.value?.state.accuracy ?? 0; + final hasTimeBonus = (_sessionLoader.value?.state.elapsedSeconds ?? 0) <= + AnalyticsPracticeConstants.timeForBonus; + final hintsUsed = hintsUsedNotifier.value; + + final bool perfectAccuracy = accuracy == 100; + final bool noHintsUsed = hintsUsed == 0; + final bool hintsAvailable = widget.type == ConstructTypeEnum.morph; + + //check how many conditions for bonuses the user met and return message accordingly + final conditionsMet = [ + perfectAccuracy, + !hintsAvailable || noHintsUsed, + hasTimeBonus, + ].where((c) => c).length; + + if (conditionsMet == 3) { + return L10n.of(context).perfectPractice; + } + if (conditionsMet >= 2) { + return L10n.of(context).greatPractice; + } + if (hintsAvailable && noHintsUsed) { + return L10n.of(context).usedNoHints; + } + return L10n.of(context).youveCompletedPractice; + } + @override Widget build(BuildContext context) => AnalyticsPracticeView(this); } diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index 5e82d6b6d..abf2b47b0 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -242,17 +242,24 @@ class AnalyticsPracticeSessionRepo { } static Future> _fetchErrors() async { - // Fetch all recent uses in one call (not filtering blocked constructs) final allRecentUses = await MatrixState .pangeaController.matrixState.analyticsDataService - .getUses(count: 200, filterCapped: false); + .getUses( + count: 300, + filterCapped: false, + types: [ + ConstructUseTypeEnum.ga, + ConstructUseTypeEnum.corGE, + ConstructUseTypeEnum.incGE, + ], + ); // Filter for grammar error uses final grammarErrorUses = allRecentUses .where((use) => use.useType == ConstructUseTypeEnum.ga) .toList(); - // Create list of recently used constructs + // Create list of recently practiced constructs (last 24 hours) final cutoffTime = DateTime.now().subtract(const Duration(hours: 24)); final recentlyPracticedConstructs = allRecentUses .where( diff --git a/lib/pangea/analytics_practice/analytics_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart index 9ada4f9d2..e425c7495 100644 --- a/lib/pangea/analytics_practice/analytics_practice_view.dart +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -121,6 +121,12 @@ class _AnalyticsActivityView extends StatelessWidget { return ListView( children: [ + //Hints counter bar for grammar activities only + if (controller.widget.type == ConstructTypeEnum.morph) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _HintsCounterBar(controller: controller), + ), //per-activity instructions, add switch statement once there are more types const InstructionsInlineTooltip( instructionsEnum: InstructionsEnum.selectMeaning, @@ -160,6 +166,10 @@ class _AnalyticsActivityView extends StatelessWidget { child: _AnalyticsPracticeCenterContent(controller: controller), ), const SizedBox(height: 16.0), + (controller.widget.type == ConstructTypeEnum.morph) + ? Center(child: _HintSection(controller: controller)) + : const SizedBox.shrink(), + const SizedBox(height: 16.0), _ActivityChoicesWidget(controller), const SizedBox(height: 16.0), _WrongAnswerFeedback(controller: controller), @@ -181,50 +191,35 @@ class _AnalyticsPracticeCenterContent extends StatelessWidget { valueListenable: controller.activityTarget, builder: (context, target, __) => switch (target?.target.activityType) { null => const SizedBox(), - ActivityTypeEnum.grammarError => SizedBox( - height: 160.0, - child: SingleChildScrollView( - child: ValueListenableBuilder( - valueListenable: controller.activityState, - builder: (context, state, __) => switch (state) { - AsyncLoaded( - value: final GrammarErrorPracticeActivityModel activity - ) => - Column( - mainAxisSize: MainAxisSize.min, - children: [ - _ErrorBlankWidget( - key: ValueKey( - '${activity.eventID}_${activity.errorOffset}_${activity.errorLength}', - ), - activity: activity, - ), - const SizedBox(height: 12), - ], - ), - _ => const SizedBox(), - }, - ), + ActivityTypeEnum.grammarError => SingleChildScrollView( + child: ListenableBuilder( + listenable: Listenable.merge([ + controller.activityState, + controller.hintPressedNotifier, + ]), + builder: (context, __) { + final state = controller.activityState.value; + if (state + is! AsyncLoaded) { + return const SizedBox(); + } + final activity = state.value; + if (activity is! GrammarErrorPracticeActivityModel) { + return const SizedBox(); + } + return _ErrorBlankWidget( + key: ValueKey( + '${activity.eventID}_${activity.errorOffset}_${activity.errorLength}', + ), + activity: activity, + showTranslation: controller.hintPressedNotifier.value, + ); + }, ), ), 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, - ); - }, - ), - ], + child: _ExampleMessageWidget( + controller.getExampleMessage(target!), ), ), _ => SizedBox( @@ -281,45 +276,96 @@ class _ExampleMessageWidget extends StatelessWidget { } } -class _CorrectAnswerHint extends StatelessWidget { +class _HintsCounterBar extends StatelessWidget { final AnalyticsPracticeState controller; - const _CorrectAnswerHint({ - required this.controller, - }); + const _HintsCounterBar({required this.controller}); @override Widget build(BuildContext context) { return ValueListenableBuilder( - valueListenable: controller.hintPressedNotifier, - builder: (context, hintPressed, __) { - if (!hintPressed) { + valueListenable: controller.hintsUsedNotifier, + builder: (context, hintsUsed, __) { + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + AnalyticsPracticeState.maxHints, + (index) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Icon( + index < hintsUsed ? Icons.lightbulb : Icons.lightbulb_outline, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ), + ); + }, + ); + } +} + +class _HintSection extends StatelessWidget { + final AnalyticsPracticeState controller; + + const _HintSection({required this.controller}); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([ + controller.activityState, + controller.hintPressedNotifier, + controller.hintsUsedNotifier, + ]), + builder: (context, __) { + final state = controller.activityState.value; + if (state is! AsyncLoaded) { return const SizedBox.shrink(); } - return ValueListenableBuilder( - valueListenable: controller.activityState, - builder: (context, state, __) { - if (state is! AsyncLoaded) { - return const SizedBox.shrink(); - } + final activity = state.value; + final hintPressed = controller.hintPressedNotifier.value; + final hintsUsed = controller.hintsUsedNotifier.value; + final maxHintsReached = hintsUsed >= AnalyticsPracticeState.maxHints; - final activity = state.value; - if (activity is! MorphPracticeActivityModel) { - return const SizedBox.shrink(); - } + return ConstrainedBox( + constraints: const BoxConstraints( + minHeight: 50.0, + ), + child: Builder( + builder: (context) { + // For grammar category: fade out button and show hint content + if (activity is MorphPracticeActivityModel) { + return AnimatedCrossFade( + duration: const Duration(milliseconds: 200), + crossFadeState: hintPressed + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: HintButton( + onPressed: + maxHintsReached ? () {} : controller.onHintPressed, + depressed: maxHintsReached, + ), + secondChild: MorphMeaningWidget( + feature: activity.morphFeature, + tag: activity.multipleChoiceContent.answers.first, + ), + ); + } - final correctAnswerTag = - activity.multipleChoiceContent.answers.first; - - return Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: MorphMeaningWidget( - feature: activity.morphFeature, - tag: correctAnswerTag, - ), - ); - }, + // For grammar error: button stays pressed, hint shows in ErrorBlankWidget + return HintButton( + onPressed: (hintPressed || maxHintsReached) + ? () {} + : controller.onHintPressed, + depressed: hintPressed || maxHintsReached, + ); + }, + ), ); }, ); @@ -371,33 +417,21 @@ class _WrongAnswerFeedback extends StatelessWidget { } } -class _ErrorBlankWidget extends StatefulWidget { +class _ErrorBlankWidget extends StatelessWidget { final GrammarErrorPracticeActivityModel activity; + final bool showTranslation; const _ErrorBlankWidget({ super.key, required this.activity, + required this.showTranslation, }); - @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; + final text = activity.text; + final errorOffset = activity.errorOffset; + final errorLength = activity.errorLength; const maxContextChars = 50; @@ -444,66 +478,72 @@ class _ErrorBlankWidgetState extends State<_ErrorBlankWidget> { final after = chars.skip(errorEnd).take(afterEnd - errorEnd).toString(); - return Column( - children: [ - 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: 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: '…'), - ], - ), - ), - 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(), - ], - ), + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Color.alphaBlend( + Colors.white.withAlpha(180), + ThemeData.dark().colorScheme.primary, ), - const SizedBox(height: 8), - HintButton(depressed: _showTranslation, onPressed: _toggleTranslation), - ], + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + 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: '…'), + ], + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: showTranslation + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Text( + activity.translation, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryFixed, + fontSize: AppConfig.fontSizeFactor * + AppConfig.messageFontSize, + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ) + : const SizedBox.shrink(), + ), + ], + ), ); } } diff --git a/lib/pangea/analytics_practice/completed_activity_session_view.dart b/lib/pangea/analytics_practice/completed_activity_session_view.dart index d9aefee22..31df2f6ce 100644 --- a/lib/pangea/analytics_practice/completed_activity_session_view.dart +++ b/lib/pangea/analytics_practice/completed_activity_session_view.dart @@ -47,7 +47,7 @@ class CompletedActivitySessionView extends StatelessWidget { child: Column( children: [ Text( - L10n.of(context).congratulationsYouveCompletedPractice, + controller.getCompletionMessage(context), style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), @@ -171,7 +171,7 @@ class CompletedActivitySessionView extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - L10n.of(context).quit, + L10n.of(context).done, ), ], ), From 1417d8b94465ee987e4ea9b4ea8481b86d8de7ee Mon Sep 17 00:00:00 2001 From: Kelrap <99418823+Kelrap@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:34:52 -0500 Subject: [PATCH 05/12] Line notifications setting button up with rest of page (#5571) --- .../settings_notifications/settings_notifications_view.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/pages/settings_notifications/settings_notifications_view.dart b/lib/pages/settings_notifications/settings_notifications_view.dart index e4e14aa79..e601fcb31 100644 --- a/lib/pages/settings_notifications/settings_notifications_view.dart +++ b/lib/pages/settings_notifications/settings_notifications_view.dart @@ -57,9 +57,7 @@ class SettingsNotificationsView extends StatelessWidget { child: snapshot.data != false ? const SizedBox() : Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - ), + padding: const EdgeInsets.fromLTRB(16, 8, 28, 8), child: ListTile( tileColor: theme.colorScheme.primaryContainer, leading: Icon( From fbd31f11187511dc55fa4e456cfe6e9e64ce0090 Mon Sep 17 00:00:00 2001 From: Kelrap <99418823+Kelrap@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:35:36 -0500 Subject: [PATCH 06/12] Make course access titles wrap (#5584) --- .../pages/pangea_chat_access_settings.dart | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/pangea/chat_settings/pages/pangea_chat_access_settings.dart b/lib/pangea/chat_settings/pages/pangea_chat_access_settings.dart index 8cf55cb3a..ff33177e8 100644 --- a/lib/pangea/chat_settings/pages/pangea_chat_access_settings.dart +++ b/lib/pangea/chat_settings/pages/pangea_chat_access_settings.dart @@ -122,18 +122,19 @@ class ChatAccessTitle extends StatelessWidget { final theme = Theme.of(context); final isColumnMode = FluffyThemes.isColumnMode(context); return Row( - mainAxisSize: MainAxisSize.min, children: [ Icon( icon, size: isColumnMode ? 32.0 : 24.0, ), SizedBox(width: isColumnMode ? 32.0 : 16.0), - Text( - title, - style: isColumnMode - ? theme.textTheme.titleLarge - : theme.textTheme.titleMedium, + Flexible( + child: Text( + title, + style: isColumnMode + ? theme.textTheme.titleLarge + : theme.textTheme.titleMedium, + ), ), ], ); From a5c82b2753a4d5201804177f8cae20156b151753 Mon Sep 17 00:00:00 2001 From: Kelrap <99418823+Kelrap@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:42:04 -0500 Subject: [PATCH 07/12] Make password recovery settings more similar to change password page (#5589) Co-authored-by: ggurdin --- lib/l10n/intl_en.arb | 3 +++ lib/pages/settings_3pid/settings_3pid_view.dart | 15 +++++++++++---- .../settings_password/settings_password_view.dart | 14 +++++++------- .../settings_security/settings_security_view.dart | 10 ++++++++++ 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index f062e5a51..8008ab3d0 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5072,6 +5072,9 @@ "autoIGCToolName": "Enable writing assistance", "autoIGCToolDescription": "Automatically run Pangea Chat tools to correct sent messages to target language.", "emptyAudioError": "Recording failed. Please check your audio permissions and try again.", + "changeEmail": "Change email", + "withTheseAddressesDescription": "With these email addresses you can log in, recover your password, and manage subscriptions.", + "noAddressDescription": "You have not added any email addresses yet.", "perfectPractice": "Perfect practice!", "greatPractice": "Great practice!", "usedNoHints": "Nice job not using any hints!", diff --git a/lib/pages/settings_3pid/settings_3pid_view.dart b/lib/pages/settings_3pid/settings_3pid_view.dart index c466b0a24..e854ff634 100644 --- a/lib/pages/settings_3pid/settings_3pid_view.dart +++ b/lib/pages/settings_3pid/settings_3pid_view.dart @@ -20,7 +20,10 @@ class Settings3PidView extends StatelessWidget { return Scaffold( appBar: AppBar( leading: const Center(child: BackButton()), - title: Text(L10n.of(context).passwordRecovery), + // #Pangea + // title: Text(L10n.of(context).passwordRecovery), + title: Text(L10n.of(context).changeEmail), + // Pangea# actions: [ IconButton( icon: const Icon(Icons.add_outlined), @@ -66,9 +69,13 @@ class Settings3PidView extends StatelessWidget { ), title: Text( identifier.isEmpty - ? L10n.of(context).noPasswordRecoveryDescription - : L10n.of(context) - .withTheseAddressesRecoveryDescription, + // #Pangea + // ? L10n.of(context).noPasswordRecoveryDescription + // : L10n.of(context) + // .withTheseAddressesRecoveryDescription,isEmpty + ? L10n.of(context).noAddressDescription + : L10n.of(context).withTheseAddressesDescription, + // Pangea# ), ), const Divider(), diff --git a/lib/pages/settings_password/settings_password_view.dart b/lib/pages/settings_password/settings_password_view.dart index b52a22340..ceb9f9186 100644 --- a/lib/pages/settings_password/settings_password_view.dart +++ b/lib/pages/settings_password/settings_password_view.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/settings_password/settings_password.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; @@ -76,11 +74,13 @@ class SettingsPasswordView extends StatelessWidget { : Text(L10n.of(context).changePassword), ), ), - const SizedBox(height: 16), - TextButton( - child: Text(L10n.of(context).passwordRecoverySettings), - onPressed: () => context.go('/rooms/settings/security/3pid'), - ), + // #Pangea + // const SizedBox(height: 16), + // TextButton( + // child: Text(L10n.of(context).passwordRecoverySettings), + // onPressed: () => context.go('/rooms/settings/security/3pid'), + // ), + // Pangea# ], ), ), diff --git a/lib/pages/settings_security/settings_security_view.dart b/lib/pages/settings_security/settings_security_view.dart index b181c9915..d2715fa6c 100644 --- a/lib/pages/settings_security/settings_security_view.dart +++ b/lib/pages/settings_security/settings_security_view.dart @@ -147,6 +147,16 @@ class SettingsSecurityView extends StatelessWidget { style: const TextStyle(fontFamily: 'RobotoMono'), ), ), + // #Pangea + if (capabilities?.m3pidChanges?.enabled != false || + error != null) + ListTile( + leading: const Icon(Icons.mail_outline_rounded), + trailing: const Icon(Icons.chevron_right_outlined), + title: Text(L10n.of(context).changeEmail), + onTap: () => context.go('/rooms/settings/security/3pid'), + ), + // Pangea# if (capabilities?.mChangePassword?.enabled != false || error != null) ListTile( From 53091cf4a610c80eb1a7637f0beef40fcb37b00e Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:14:01 -0500 Subject: [PATCH 08/12] 5515 add more to user profile (#5525) * feat: add country and about to public profile model * display about me and country * translations --- lib/l10n/intl_ar.arb | 22 +++- lib/l10n/intl_be.arb | 22 +++- lib/l10n/intl_bn.arb | 22 +++- lib/l10n/intl_bo.arb | 22 +++- lib/l10n/intl_ca.arb | 22 +++- lib/l10n/intl_cs.arb | 22 +++- lib/l10n/intl_da.arb | 22 +++- lib/l10n/intl_de.arb | 22 +++- lib/l10n/intl_el.arb | 22 +++- lib/l10n/intl_en.arb | 1 + lib/l10n/intl_eo.arb | 22 +++- lib/l10n/intl_es.arb | 22 +++- lib/l10n/intl_et.arb | 22 +++- lib/l10n/intl_eu.arb | 22 +++- lib/l10n/intl_fa.arb | 22 +++- lib/l10n/intl_fi.arb | 22 +++- lib/l10n/intl_fil.arb | 22 +++- lib/l10n/intl_fr.arb | 22 +++- lib/l10n/intl_ga.arb | 22 +++- lib/l10n/intl_gl.arb | 22 +++- lib/l10n/intl_he.arb | 22 +++- lib/l10n/intl_hi.arb | 22 +++- lib/l10n/intl_hr.arb | 22 +++- lib/l10n/intl_hu.arb | 22 +++- lib/l10n/intl_ia.arb | 22 +++- lib/l10n/intl_id.arb | 22 +++- lib/l10n/intl_ie.arb | 22 +++- lib/l10n/intl_it.arb | 22 +++- lib/l10n/intl_ja.arb | 22 +++- lib/l10n/intl_ka.arb | 22 +++- lib/l10n/intl_ko.arb | 22 +++- lib/l10n/intl_lt.arb | 22 +++- lib/l10n/intl_lv.arb | 22 +++- lib/l10n/intl_nb.arb | 22 +++- lib/l10n/intl_nl.arb | 22 +++- lib/l10n/intl_pl.arb | 22 +++- lib/l10n/intl_pt.arb | 22 +++- lib/l10n/intl_pt_BR.arb | 22 +++- lib/l10n/intl_pt_PT.arb | 22 +++- lib/l10n/intl_ro.arb | 22 +++- lib/l10n/intl_ru.arb | 22 +++- lib/l10n/intl_sk.arb | 22 +++- lib/l10n/intl_sl.arb | 22 +++- lib/l10n/intl_sr.arb | 22 +++- lib/l10n/intl_sv.arb | 22 +++- lib/l10n/intl_ta.arb | 22 +++- lib/l10n/intl_te.arb | 22 +++- lib/l10n/intl_th.arb | 22 +++- lib/l10n/intl_tr.arb | 22 +++- lib/l10n/intl_uk.arb | 22 +++- lib/l10n/intl_vi.arb | 22 +++- lib/l10n/intl_yue.arb | 22 +++- lib/l10n/intl_zh.arb | 22 +++- lib/l10n/intl_zh_Hant.arb | 22 +++- .../analytics_data_service.dart | 3 +- .../analytics_misc/level_display_name.dart | 36 ++++-- lib/pangea/common/constants/model_keys.dart | 1 + .../common/controllers/pangea_controller.dart | 11 +- .../country_picker_tile.dart | 2 +- .../learning_settings/settings_learning.dart | 18 ++- .../settings_learning_view.dart | 10 ++ lib/pangea/user/about_me_display.dart | 38 ++++++ lib/pangea/user/public_profile_model.dart | 61 ++++++++++ lib/pangea/user/user_controller.dart | 113 ++++++++++++++---- lib/pangea/user/user_model.dart | 7 ++ lib/widgets/adaptive_dialogs/user_dialog.dart | 5 +- .../member_actions_popup_menu_button.dart | 91 +++++++------- 67 files changed, 1422 insertions(+), 141 deletions(-) create mode 100644 lib/pangea/user/about_me_display.dart create mode 100644 lib/pangea/user/public_profile_model.dart diff --git a/lib/l10n/intl_ar.arb b/lib/l10n/intl_ar.arb index cb7e976f9..2ec0e9c04 100644 --- a/lib/l10n/intl_ar.arb +++ b/lib/l10n/intl_ar.arb @@ -1,6 +1,6 @@ { "@@locale": "ar", - "@@last_modified": "2026-02-09 10:31:06.969392", + "@@last_modified": "2026-02-09 11:09:32.801033", "about": "حول", "@about": { "type": "String", @@ -11177,6 +11177,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "عنّي", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "تعبير اصطلاحي", "grammarCopyPOSphrasalv": "فعل مركب", "grammarCopyPOScompn": "مركب", @@ -11211,5 +11216,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "تغيير البريد الإلكتروني", + "withTheseAddressesDescription": "باستخدام هذه العناوين البريدية يمكنك تسجيل الدخول، واستعادة كلمة المرور، وإدارة الاشتراكات.", + "noAddressDescription": "لم تقم بإضافة أي عناوين بريد إلكتروني بعد.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_be.arb b/lib/l10n/intl_be.arb index 9f56ebca9..2754a34fb 100644 --- a/lib/l10n/intl_be.arb +++ b/lib/l10n/intl_be.arb @@ -1908,7 +1908,7 @@ "playWithAI": "Пакуль гуляйце з ШІ", "courseStartDesc": "Pangea Bot гатовы да працы ў любы час!\n\n...але навучанне лепш з сябрамі!", "@@locale": "be", - "@@last_modified": "2026-02-09 10:30:56.849128", + "@@last_modified": "2026-02-09 11:09:23.199652", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12059,6 +12059,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Пра мяне", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Ідыём", "grammarCopyPOSphrasalv": "Фразавы дзеяслоў", "grammarCopyPOScompn": "Складаны", @@ -12093,5 +12098,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Змяніць электронную пошту", + "withTheseAddressesDescription": "З гэтымі электроннымі адрасамі вы можаце ўвайсці, аднавіць свой пароль і кіраваць падпіскамі.", + "noAddressDescription": "Вы яшчэ не дабавілі ніводнага электроннага адрасу.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_bn.arb b/lib/l10n/intl_bn.arb index fb33c73ee..61e5ab945 100644 --- a/lib/l10n/intl_bn.arb +++ b/lib/l10n/intl_bn.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:31:21.327976", + "@@last_modified": "2026-02-09 11:09:46.816075", "about": "সম্পর্কে", "@about": { "type": "String", @@ -12064,6 +12064,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "আমার সম্পর্কে", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "বাগধারা", "grammarCopyPOSphrasalv": "ফ্রেজাল ক্রিয়া", "grammarCopyPOScompn": "যুগ্ম", @@ -12098,5 +12103,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "ইমেইল পরিবর্তন করুন", + "withTheseAddressesDescription": "এই ইমেইল ঠিকানাগুলির মাধ্যমে আপনি লগ ইন করতে, আপনার পাসওয়ার্ড পুনরুদ্ধার করতে এবং সাবস্ক্রিপশন পরিচালনা করতে পারেন।", + "noAddressDescription": "আপনি এখনও কোন ইমেইল ঠিকানা যোগ করেননি।", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_bo.arb b/lib/l10n/intl_bo.arb index c43b02482..4b558844a 100644 --- a/lib/l10n/intl_bo.arb +++ b/lib/l10n/intl_bo.arb @@ -4276,7 +4276,7 @@ "joinPublicTrip": "མི་ཚེས་ལ་ལོག་འབད།", "startOwnTrip": "ངེད་རང་གི་ལོག་ལ་སྦྱོར་བཅོས།", "@@locale": "bo", - "@@last_modified": "2026-02-09 10:31:18.713273", + "@@last_modified": "2026-02-09 11:09:44.069009", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -10714,6 +10714,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Bok o meni", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Phrasal Verb", "grammarCopyPOScompn": "Compound", @@ -10748,5 +10753,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Bohor email", + "withTheseAddressesDescription": "S dengan alamat email ini, Anda dapat masuk, memulihkan kata sandi Anda, dan mengelola langganan.", + "noAddressDescription": "Anda belum menambahkan alamat email apa pun.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index 6567634fd..9670f68b1 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:30:58.877498", + "@@last_modified": "2026-02-09 11:09:24.612806", "about": "Quant a", "@about": { "type": "String", @@ -10984,6 +10984,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Sobre mi", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Verb Phrasal", "grammarCopyPOScompn": "Compost", @@ -11018,5 +11023,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Canvia l'email", + "withTheseAddressesDescription": "Amb aquestes adreces de correu electrònic pots iniciar sessió, recuperar la teva contrasenya i gestionar les subscripcions.", + "noAddressDescription": "Encara no has afegit cap adreça de correu electrònic.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 2f0de87e6..67c47b042 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -1,6 +1,6 @@ { "@@locale": "cs", - "@@last_modified": "2026-02-09 10:30:53.085602", + "@@last_modified": "2026-02-09 11:09:20.002945", "about": "O aplikaci", "@about": { "type": "String", @@ -11567,6 +11567,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "O mně", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Frázové sloveso", "grammarCopyPOScompn": "Složenina", @@ -11601,5 +11606,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Změnit e-mail", + "withTheseAddressesDescription": "S těmito e-mailovými adresami se můžete přihlásit, obnovit své heslo a spravovat předplatné.", + "noAddressDescription": "Zatím jste nepřidali žádné e-mailové adresy.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_da.arb b/lib/l10n/intl_da.arb index cb13a9d91..c0c309d49 100644 --- a/lib/l10n/intl_da.arb +++ b/lib/l10n/intl_da.arb @@ -1927,7 +1927,7 @@ "playWithAI": "Leg med AI for nu", "courseStartDesc": "Pangea Bot er klar til at starte når som helst!\n\n...men læring er bedre med venner!", "@@locale": "da", - "@@last_modified": "2026-02-09 10:30:26.547080", + "@@last_modified": "2026-02-09 11:08:45.510364", "@aboutHomeserver": { "type": "String", "placeholders": { @@ -12021,6 +12021,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Om mig", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Phrasal Verb", "grammarCopyPOScompn": "Sammensat", @@ -12055,5 +12060,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Ændre email", + "withTheseAddressesDescription": "Med disse emailadresser kan du logge ind, gendanne din adgangskode og administrere abonnementer.", + "noAddressDescription": "Du har endnu ikke tilføjet nogen emailadresser.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index c85543eca..32b58e7cf 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1,6 +1,6 @@ { "@@locale": "de", - "@@last_modified": "2026-02-09 10:30:45.424459", + "@@last_modified": "2026-02-09 11:09:13.137048", "alwaysUse24HourFormat": "true", "@alwaysUse24HourFormat": { "description": "Set to true to always display time of day in 24 hour format." @@ -10967,6 +10967,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Über mich", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Phrasal Verb", "grammarCopyPOScompn": "Zusammengesetztes Wort", @@ -11001,5 +11006,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "E-Mail ändern", + "withTheseAddressesDescription": "Mit diesen E-Mail-Adressen können Sie sich anmelden, Ihr Passwort wiederherstellen und Abonnements verwalten.", + "noAddressDescription": "Sie haben noch keine E-Mail-Adressen hinzugefügt.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_el.arb b/lib/l10n/intl_el.arb index 41cbb375a..4ea104e49 100644 --- a/lib/l10n/intl_el.arb +++ b/lib/l10n/intl_el.arb @@ -4453,7 +4453,7 @@ "playWithAI": "Παίξτε με την Τεχνητή Νοημοσύνη προς το παρόν", "courseStartDesc": "Ο Pangea Bot είναι έτοιμος να ξεκινήσει οποιαδήποτε στιγμή!\n\n...αλλά η μάθηση είναι καλύτερη με φίλους!", "@@locale": "el", - "@@last_modified": "2026-02-09 10:31:27.836094", + "@@last_modified": "2026-02-09 11:09:52.785391", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12018,6 +12018,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Σχετικά με εμένα", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Ιδιωματισμός", "grammarCopyPOSphrasalv": "Φραστικό Ρήμα", "grammarCopyPOScompn": "Σύνθετο", @@ -12052,5 +12057,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Αλλαγή email", + "withTheseAddressesDescription": "Με αυτές τις διευθύνσεις email μπορείτε να συνδεθείτε, να ανακτήσετε τον κωδικό πρόσβασής σας και να διαχειριστείτε τις συνδρομές.", + "noAddressDescription": "Δεν έχετε προσθέσει καμία διεύθυνση email μέχρι στιγμής.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 8008ab3d0..8cbb0a2fc 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5072,6 +5072,7 @@ "autoIGCToolName": "Enable writing assistance", "autoIGCToolDescription": "Automatically run Pangea Chat tools to correct sent messages to target language.", "emptyAudioError": "Recording failed. Please check your audio permissions and try again.", + "aboutMeHint": "About me", "changeEmail": "Change email", "withTheseAddressesDescription": "With these email addresses you can log in, recover your password, and manage subscriptions.", "noAddressDescription": "You have not added any email addresses yet.", diff --git a/lib/l10n/intl_eo.arb b/lib/l10n/intl_eo.arb index 1f4a4fc1d..946d9ffc6 100644 --- a/lib/l10n/intl_eo.arb +++ b/lib/l10n/intl_eo.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:31:32.343732", + "@@last_modified": "2026-02-09 11:09:57.785827", "about": "Prio", "@about": { "type": "String", @@ -12049,6 +12049,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Pri mi", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Phrasal Verb", "grammarCopyPOScompn": "Kunmetita", @@ -12083,5 +12088,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Ŝanĝi retpoŝton", + "withTheseAddressesDescription": "Kun ĉi tiuj retpoŝtaj adresoj vi povas ensaluti, rekuperi vian pasvorton, kaj administri abonojn.", + "noAddressDescription": "Vi ankoraŭ ne aldonis iujn retpoŝtajn adresojn.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index aa2560f1b..5040071ef 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1,6 +1,6 @@ { "@@locale": "es", - "@@last_modified": "2026-02-09 10:30:21.560804", + "@@last_modified": "2026-02-09 11:08:40.981454", "about": "Acerca de", "@about": { "type": "String", @@ -8202,6 +8202,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Acerca de mí", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Modismo", "grammarCopyPOSphrasalv": "Verbo Frasal", "grammarCopyPOScompn": "Compuesto", @@ -8236,5 +8241,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Cambiar correo electrónico", + "withTheseAddressesDescription": "Con estas direcciones de correo electrónico puedes iniciar sesión, recuperar tu contraseña y gestionar suscripciones.", + "noAddressDescription": "Aún no has añadido ninguna dirección de correo electrónico.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_et.arb b/lib/l10n/intl_et.arb index b331e9a9f..c621e5a6d 100644 --- a/lib/l10n/intl_et.arb +++ b/lib/l10n/intl_et.arb @@ -1,6 +1,6 @@ { "@@locale": "et", - "@@last_modified": "2026-02-09 10:30:44.206560", + "@@last_modified": "2026-02-09 11:09:05.357413", "about": "Rakenduse teave", "@about": { "type": "String", @@ -11231,6 +11231,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Minust", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idioom", "grammarCopyPOSphrasalv": "Fraasi Verb", "grammarCopyPOScompn": "Kompleks", @@ -11265,5 +11270,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Muuda e-posti", + "withTheseAddressesDescription": "Nende e-posti aadressidega saad sisse logida, oma parooli taastada ja tellimusi hallata.", + "noAddressDescription": "Sa ei ole veel ühtegi e-posti aadressi lisanud.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_eu.arb b/lib/l10n/intl_eu.arb index 6cde5e0a0..568a778ed 100644 --- a/lib/l10n/intl_eu.arb +++ b/lib/l10n/intl_eu.arb @@ -1,6 +1,6 @@ { "@@locale": "eu", - "@@last_modified": "2026-02-09 10:30:41.236742", + "@@last_modified": "2026-02-09 11:09:01.052613", "about": "Honi buruz", "@about": { "type": "String", @@ -10960,6 +10960,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Niri buruz", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Phrasal Verb", "grammarCopyPOScompn": "Konposatu", @@ -10994,5 +10999,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Aldatu posta elektronikoa", + "withTheseAddressesDescription": "Posta elektroniko helbide hauekin saioa hasi dezakezu, zure pasahitza berreskuratu eta harpidetzak kudeatu.", + "noAddressDescription": "Oraindik ez duzu posta elektroniko helbiderik gehitu.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fa.arb b/lib/l10n/intl_fa.arb index be63f02dd..75cd90f6d 100644 --- a/lib/l10n/intl_fa.arb +++ b/lib/l10n/intl_fa.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:31:23.313797", + "@@last_modified": "2026-02-09 11:09:48.180617", "repeatPassword": "تکرار رمزعبور", "@repeatPassword": {}, "about": "درباره", @@ -11692,6 +11692,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "درباره من", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "اصطلاح", "grammarCopyPOSphrasalv": "فعل عبارتی", "grammarCopyPOScompn": "ترکیب", @@ -11726,5 +11731,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "تغییر ایمیل", + "withTheseAddressesDescription": "با این آدرس‌های ایمیل می‌توانید وارد شوید، رمز عبور خود را بازیابی کنید و اشتراک‌ها را مدیریت کنید.", + "noAddressDescription": "شما هنوز هیچ آدرس ایمیلی اضافه نکرده‌اید.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fi.arb b/lib/l10n/intl_fi.arb index c65b2dc8f..6d16ae0bf 100644 --- a/lib/l10n/intl_fi.arb +++ b/lib/l10n/intl_fi.arb @@ -4006,7 +4006,7 @@ "playWithAI": "Leiki tekoälyn kanssa nyt", "courseStartDesc": "Pangea Bot on valmis milloin tahansa!\n\n...mutta oppiminen on parempaa ystävien kanssa!", "@@locale": "fi", - "@@last_modified": "2026-02-09 10:30:24.862038", + "@@last_modified": "2026-02-09 11:08:44.050102", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11583,6 +11583,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Tietoa minusta", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idioomi", "grammarCopyPOSphrasalv": "Fraasiverbi", "grammarCopyPOScompn": "Yhdistelmä", @@ -11617,5 +11622,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Vaihda sähköpostiosoite", + "withTheseAddressesDescription": "Näiden sähköpostiosoitteiden avulla voit kirjautua sisään, palauttaa salasanasi ja hallita tilauksia.", + "noAddressDescription": "Et ole vielä lisännyt mitään sähköpostiosoitteita.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fil.arb b/lib/l10n/intl_fil.arb index 6b8fc6e95..14e084989 100644 --- a/lib/l10n/intl_fil.arb +++ b/lib/l10n/intl_fil.arb @@ -2784,7 +2784,7 @@ "selectAll": "Piliin lahat", "deselectAll": "Huwag piliin lahat", "@@locale": "fil", - "@@last_modified": "2026-02-09 10:31:04.579384", + "@@last_modified": "2026-02-09 11:09:29.936599", "@setCustomPermissionLevel": { "type": "String", "placeholders": {} @@ -11936,6 +11936,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Tungkol sa akin", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idyoma", "grammarCopyPOSphrasalv": "Phrasal Verb", "grammarCopyPOScompn": "Pinagsama", @@ -11970,5 +11975,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Palitan ang email", + "withTheseAddressesDescription": "Sa mga email address na ito, maaari kang mag-log in, i-recover ang iyong password, at pamahalaan ang mga subscription.", + "noAddressDescription": "Wala ka pang naidagdag na anumang email address.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 7a95f7509..ba626d21f 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1,6 +1,6 @@ { "@@locale": "fr", - "@@last_modified": "2026-02-09 10:31:39.092623", + "@@last_modified": "2026-02-09 11:10:07.003221", "about": "À propos", "@about": { "type": "String", @@ -11284,6 +11284,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "À propos de moi", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Verbe à particule", "grammarCopyPOScompn": "Composé", @@ -11318,5 +11323,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Changer l'email", + "withTheseAddressesDescription": "Avec ces adresses email, vous pouvez vous connecter, récupérer votre mot de passe et gérer vos abonnements.", + "noAddressDescription": "Vous n'avez pas encore ajouté d'adresses email.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ga.arb b/lib/l10n/intl_ga.arb index dbc6a0884..bdc067a78 100644 --- a/lib/l10n/intl_ga.arb +++ b/lib/l10n/intl_ga.arb @@ -4514,7 +4514,7 @@ "playWithAI": "Imir le AI faoi láthair", "courseStartDesc": "Tá Bot Pangea réidh chun dul am ar bith!\n\n...ach is fearr foghlaim le cairde!", "@@locale": "ga", - "@@last_modified": "2026-02-09 10:31:37.968813", + "@@last_modified": "2026-02-09 11:10:05.570216", "@customReaction": { "type": "String", "placeholders": {} @@ -10958,6 +10958,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Fúm", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Frása", "grammarCopyPOSphrasalv": "Gníomhhacht Phrásúil", "grammarCopyPOScompn": "Comhoibriú", @@ -10992,5 +10997,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Athraigh an ríomhphost", + "withTheseAddressesDescription": "Leis na seoltaí ríomhphoist seo, is féidir leat logáil isteach, do phasfhocal a chur ar ais, agus síntiúis a bhainistiú.", + "noAddressDescription": "Níl aon seoltaí ríomhphoist curtha leis fós.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_gl.arb b/lib/l10n/intl_gl.arb index 91c508254..f598911a2 100644 --- a/lib/l10n/intl_gl.arb +++ b/lib/l10n/intl_gl.arb @@ -1,6 +1,6 @@ { "@@locale": "gl", - "@@last_modified": "2026-02-09 10:30:23.363980", + "@@last_modified": "2026-02-09 11:08:42.508158", "about": "Acerca de", "@about": { "type": "String", @@ -10957,6 +10957,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Sobre min", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Verbo Frasal", "grammarCopyPOScompn": "Composto", @@ -10991,5 +10996,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Cambiar correo electrónico", + "withTheseAddressesDescription": "Con estes enderezos de correo electrónico podes iniciar sesión, recuperar a túa contrasinal e xestionar subscricións.", + "noAddressDescription": "Non engadiches ningún enderezo de correo electrónico aínda.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_he.arb b/lib/l10n/intl_he.arb index 344403974..c385f170e 100644 --- a/lib/l10n/intl_he.arb +++ b/lib/l10n/intl_he.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:30:36.836306", + "@@last_modified": "2026-02-09 11:08:56.858820", "about": "אודות", "@about": { "type": "String", @@ -12009,6 +12009,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "עליי", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "ביטוי", "grammarCopyPOSphrasalv": "פועל פיזי", "grammarCopyPOScompn": "מורכב", @@ -12043,5 +12048,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "שנה דוא\"ל", + "withTheseAddressesDescription": "עם כתובות הדוא\"ל הללו אתה יכול להתחבר, לשחזר את הסיסמה שלך ולנהל מנויים.", + "noAddressDescription": "עדיין לא הוספת כתובות דוא\"ל.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_hi.arb b/lib/l10n/intl_hi.arb index 61a8e2885..301f9fe14 100644 --- a/lib/l10n/intl_hi.arb +++ b/lib/l10n/intl_hi.arb @@ -4480,7 +4480,7 @@ "playWithAI": "अभी के लिए एआई के साथ खेलें", "courseStartDesc": "पैंजिया बॉट कभी भी जाने के लिए तैयार है!\n\n...लेकिन दोस्तों के साथ सीखना बेहतर है!", "@@locale": "hi", - "@@last_modified": "2026-02-09 10:31:30.698303", + "@@last_modified": "2026-02-09 11:09:56.145817", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12045,6 +12045,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "मेरे बारे में", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "मुहावरा", "grammarCopyPOSphrasalv": "फ्रेज़ल वर्ब", "grammarCopyPOScompn": "संयुक्त", @@ -12079,5 +12084,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "ईमेल बदलें", + "withTheseAddressesDescription": "इन ईमेल पतों के साथ आप लॉग इन कर सकते हैं, अपना पासवर्ड पुनर्प्राप्त कर सकते हैं, और सब्सक्रिप्शन प्रबंधित कर सकते हैं।", + "noAddressDescription": "आपने अभी तक कोई ईमेल पता नहीं जोड़ा है।", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_hr.arb b/lib/l10n/intl_hr.arb index d827d59b3..4a1ad631d 100644 --- a/lib/l10n/intl_hr.arb +++ b/lib/l10n/intl_hr.arb @@ -1,6 +1,6 @@ { "@@locale": "hr", - "@@last_modified": "2026-02-09 10:30:35.547762", + "@@last_modified": "2026-02-09 11:08:55.423013", "about": "Informacije", "@about": { "type": "String", @@ -11332,6 +11332,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "O meni", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Phrasalni Glagol", "grammarCopyPOScompn": "Složenica", @@ -11366,5 +11371,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Promijeni email", + "withTheseAddressesDescription": "S ovim email adresama možete se prijaviti, oporaviti svoju lozinku i upravljati pretplatama.", + "noAddressDescription": "Još niste dodali nijednu email adresu.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_hu.arb b/lib/l10n/intl_hu.arb index ae2ab1174..9ec22c848 100644 --- a/lib/l10n/intl_hu.arb +++ b/lib/l10n/intl_hu.arb @@ -1,6 +1,6 @@ { "@@locale": "hu", - "@@last_modified": "2026-02-09 10:30:28.032874", + "@@last_modified": "2026-02-09 11:08:47.162355", "about": "Névjegy", "@about": { "type": "String", @@ -10961,6 +10961,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Rólam", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idióma", "grammarCopyPOSphrasalv": "Frazális ige", "grammarCopyPOScompn": "Összetett", @@ -10995,5 +11000,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Email cím módosítása", + "withTheseAddressesDescription": "Ezekkel az email címekkel be tudsz jelentkezni, vissza tudod állítani a jelszavadat, és kezelni tudod az előfizetéseket.", + "noAddressDescription": "Még nem adtál hozzá email címeket.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ia.arb b/lib/l10n/intl_ia.arb index 1b5f6a205..d5ba26248 100644 --- a/lib/l10n/intl_ia.arb +++ b/lib/l10n/intl_ia.arb @@ -1955,7 +1955,7 @@ "playWithAI": "Joca con le IA pro ora", "courseStartDesc": "Pangea Bot es preste a comenzar a qualunque momento!\n\n...ma apprender es melior con amicos!", "@@locale": "ia", - "@@last_modified": "2026-02-09 10:30:38.575679", + "@@last_modified": "2026-02-09 11:08:57.966375", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12038,6 +12038,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Despre mine", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Verbo Phrasal", "grammarCopyPOScompn": "Compuesto", @@ -12072,5 +12077,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Cambia email", + "withTheseAddressesDescription": "Con estas direcciones de correo electrónico puedes iniciar sesión, recuperar tu contraseña y gestionar suscripciones.", + "noAddressDescription": "Aún no has añadido ninguna dirección de correo electrónico.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_id.arb b/lib/l10n/intl_id.arb index e773a3d57..8503ecfad 100644 --- a/lib/l10n/intl_id.arb +++ b/lib/l10n/intl_id.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:30:29.458472", + "@@last_modified": "2026-02-09 11:08:48.564116", "setAsCanonicalAlias": "Atur sebagai alias utama", "@setAsCanonicalAlias": { "type": "String", @@ -10951,6 +10951,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Tentang saya", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Kata Kerja Phrasal", "grammarCopyPOScompn": "Kombinasi", @@ -10985,5 +10990,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Ubah email", + "withTheseAddressesDescription": "Dengan alamat email ini, Anda dapat masuk, memulihkan kata sandi Anda, dan mengelola langganan.", + "noAddressDescription": "Anda belum menambahkan alamat email apapun.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ie.arb b/lib/l10n/intl_ie.arb index 1ea252757..33e01880d 100644 --- a/lib/l10n/intl_ie.arb +++ b/lib/l10n/intl_ie.arb @@ -4369,7 +4369,7 @@ "playWithAI": "Joca con AI pro ora", "courseStartDesc": "Pangea Bot es preste a partir a qualunque momento!\n\n...ma apprender es melior con amicos!", "@@locale": "ie", - "@@last_modified": "2026-02-09 10:30:34.375227", + "@@last_modified": "2026-02-09 11:08:53.872484", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11934,6 +11934,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Faoi m'ainm", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Phrasal Verb", "grammarCopyPOScompn": "Composé", @@ -11968,5 +11973,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Mudara e-posta", + "withTheseAddressesDescription": "Izi e-posta adresleri ile giriş yapabilir, şifrenizi kurtarabilir ve aboneliklerinizi yönetebilirsiniz.", + "noAddressDescription": "Henüz herhangi bir e-posta adresi eklemediniz.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 7d131fbd0..4cedeee53 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:30:49.194487", + "@@last_modified": "2026-02-09 11:09:17.051102", "about": "Informazioni", "@about": { "type": "String", @@ -10963,6 +10963,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Informazioni su di me", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Verbo Frazionale", "grammarCopyPOScompn": "Composto", @@ -10997,5 +11002,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Cambia email", + "withTheseAddressesDescription": "Con queste email puoi accedere, recuperare la tua password e gestire gli abbonamenti.", + "noAddressDescription": "Non hai ancora aggiunto alcun indirizzo email.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ja.arb b/lib/l10n/intl_ja.arb index ac6b45a6e..5c7d5e600 100644 --- a/lib/l10n/intl_ja.arb +++ b/lib/l10n/intl_ja.arb @@ -1,6 +1,6 @@ { "@@locale": "ja", - "@@last_modified": "2026-02-09 10:31:29.266500", + "@@last_modified": "2026-02-09 11:09:54.188816", "about": "このアプリについて", "@about": { "type": "String", @@ -11750,6 +11750,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "私について", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "イディオム", "grammarCopyPOSphrasalv": "句動詞", "grammarCopyPOScompn": "複合語", @@ -11784,5 +11789,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "メールアドレスを変更", + "withTheseAddressesDescription": "これらのメールアドレスを使用して、ログイン、パスワードの回復、サブスクリプションの管理ができます。", + "noAddressDescription": "まだメールアドレスを追加していません。", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ka.arb b/lib/l10n/intl_ka.arb index 876451d36..a12c57a10 100644 --- a/lib/l10n/intl_ka.arb +++ b/lib/l10n/intl_ka.arb @@ -2591,7 +2591,7 @@ "playWithAI": "ამ დროისთვის ითამაშეთ AI-თან", "courseStartDesc": "Pangea Bot მზადაა ნებისმიერ დროს გასასვლელად!\n\n...მაგრამ სწავლა უკეთესია მეგობრებთან ერთად!", "@@locale": "ka", - "@@last_modified": "2026-02-09 10:31:35.201910", + "@@last_modified": "2026-02-09 11:10:02.292014", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11990,6 +11990,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "ჩემზე", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "იდიომი", "grammarCopyPOSphrasalv": "ფრაზული ზმნა", "grammarCopyPOScompn": "კომპლექსური", @@ -12024,5 +12029,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "ელ. ფოსტის შეცვლა", + "withTheseAddressesDescription": "ამ ელ. ფოსტის მისამართების საშუალებით შეგიძლიათ შეხვიდეთ, აღადგინოთ თქვენი პაროლი და მართოთ გამოწერები.", + "noAddressDescription": "თქვენ ჯერ არ გაქვთ დამატებული ელ. ფოსტის მისამართები.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ko.arb b/lib/l10n/intl_ko.arb index f79ecf8f8..79f66f7f7 100644 --- a/lib/l10n/intl_ko.arb +++ b/lib/l10n/intl_ko.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:30:18.919050", + "@@last_modified": "2026-02-09 11:08:39.290513", "about": "소개", "@about": { "type": "String", @@ -11068,6 +11068,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "내 소개", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "관용구", "grammarCopyPOSphrasalv": "구동사", "grammarCopyPOScompn": "복합어", @@ -11102,5 +11107,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "이메일 변경", + "withTheseAddressesDescription": "이 이메일 주소로 로그인하고, 비밀번호를 복구하며, 구독을 관리할 수 있습니다.", + "noAddressDescription": "아직 이메일 주소를 추가하지 않았습니다.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_lt.arb b/lib/l10n/intl_lt.arb index 280b130fb..17df2b9d3 100644 --- a/lib/l10n/intl_lt.arb +++ b/lib/l10n/intl_lt.arb @@ -3858,7 +3858,7 @@ "playWithAI": "Žaiskite su dirbtiniu intelektu dabar", "courseStartDesc": "Pangea botas pasiruošęs bet kada pradėti!\n\n...bet mokymasis yra geresnis su draugais!", "@@locale": "lt", - "@@last_modified": "2026-02-09 10:31:13.179961", + "@@last_modified": "2026-02-09 11:09:38.541694", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11765,6 +11765,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Apie mane", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Phrasal Verb", "grammarCopyPOScompn": "Sudėtinis", @@ -11799,5 +11804,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Keisti el. paštą", + "withTheseAddressesDescription": "Su šiais el. pašto adresais galite prisijungti, atkurti slaptažodį ir valdyti prenumeratas.", + "noAddressDescription": "Jūs dar nepridėjote jokių el. pašto adresų.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_lv.arb b/lib/l10n/intl_lv.arb index d51702338..fd473b660 100644 --- a/lib/l10n/intl_lv.arb +++ b/lib/l10n/intl_lv.arb @@ -4480,7 +4480,7 @@ "playWithAI": "Tagad spēlējiet ar AI", "courseStartDesc": "Pangea bots ir gatavs jebkurā laikā!\n\n...bet mācīties ir labāk ar draugiem!", "@@locale": "lv", - "@@last_modified": "2026-02-09 10:31:05.908661", + "@@last_modified": "2026-02-09 11:09:31.557778", "analyticsInactiveTitle": "Pieprasījumi neaktīviem lietotājiem nevar tikt nosūtīti", "analyticsInactiveDesc": "Neaktīvi lietotāji, kuri nav pieteikušies kopš šīs funkcijas ieviešanas, neredzēs jūsu pieprasījumu.\n\nPieprasījuma poga parādīsies, kad viņi atgriezīsies. Jūs varat atkārtoti nosūtīt pieprasījumu vēlāk, noklikšķinot uz pieprasījuma pogas viņu vārdā, kad tā būs pieejama.", "accessRequestedTitle": "Pieprasījums piekļūt analītikai", @@ -10946,6 +10946,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Par mani", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Frazēts darbības vārds", "grammarCopyPOScompn": "Savienojums", @@ -10980,5 +10985,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Mainīt e-pastu", + "withTheseAddressesDescription": "Ar šiem e-pasta adresēm jūs varat pieteikties, atjaunot savu paroli un pārvaldīt abonementus.", + "noAddressDescription": "Jūs vēl neesat pievienojis nevienu e-pasta adresi.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_nb.arb b/lib/l10n/intl_nb.arb index faf5dc164..33d24b295 100644 --- a/lib/l10n/intl_nb.arb +++ b/lib/l10n/intl_nb.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:30:55.280815", + "@@last_modified": "2026-02-09 11:09:21.418237", "about": "Om", "@about": { "type": "String", @@ -12053,6 +12053,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Om meg", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Phrasal Verb", "grammarCopyPOScompn": "Sammensatt", @@ -12087,5 +12092,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Endre e-post", + "withTheseAddressesDescription": "Med disse e-postadressene kan du logge inn, gjenopprette passordet ditt og administrere abonnementer.", + "noAddressDescription": "Du har ikke lagt til noen e-postadresser ennå.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_nl.arb b/lib/l10n/intl_nl.arb index 981a2e752..8b2e372a2 100644 --- a/lib/l10n/intl_nl.arb +++ b/lib/l10n/intl_nl.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:31:17.188799", + "@@last_modified": "2026-02-09 11:09:42.630335", "about": "Over ons", "@about": { "type": "String", @@ -10960,6 +10960,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Over mij", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idioom", "grammarCopyPOSphrasalv": "Frazal Werkwoord", "grammarCopyPOScompn": "Samenstelling", @@ -10994,5 +10999,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Wijzig e-mailadres", + "withTheseAddressesDescription": "Met deze e-mailadressen kun je inloggen, je wachtwoord herstellen en abonnementen beheren.", + "noAddressDescription": "Je hebt nog geen e-mailadressen toegevoegd.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index 2bdb85519..fb697743b 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -1,6 +1,6 @@ { "@@locale": "pl", - "@@last_modified": "2026-02-09 10:31:24.557539", + "@@last_modified": "2026-02-09 11:09:49.851439", "about": "O aplikacji", "@about": { "type": "String", @@ -10958,6 +10958,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "O mnie", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Czasownik frazowy", "grammarCopyPOScompn": "Złożony", @@ -10992,5 +10997,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Zmień adres e-mail", + "withTheseAddressesDescription": "Dzięki tym adresom e-mail możesz się zalogować, odzyskać hasło i zarządzać subskrypcjami.", + "noAddressDescription": "Nie dodałeś jeszcze żadnych adresów e-mail.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 6f537dcd1..368846890 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:30:42.465226", + "@@last_modified": "2026-02-09 11:09:03.927804", "copiedToClipboard": "Copiada para a área de transferência", "@copiedToClipboard": { "type": "String", @@ -12060,6 +12060,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Sobre mim", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Verbo Frasal", "grammarCopyPOScompn": "Composto", @@ -12094,5 +12099,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Alterar e-mail", + "withTheseAddressesDescription": "Com esses endereços de e-mail, você pode fazer login, recuperar sua senha e gerenciar assinaturas.", + "noAddressDescription": "Você ainda não adicionou nenhum endereço de e-mail.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pt_BR.arb b/lib/l10n/intl_pt_BR.arb index 6fd6bd30c..231e186b8 100644 --- a/lib/l10n/intl_pt_BR.arb +++ b/lib/l10n/intl_pt_BR.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:30:39.705540", + "@@last_modified": "2026-02-09 11:08:59.417144", "about": "Sobre", "@about": { "type": "String", @@ -11318,6 +11318,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Sobre mim", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Verbo Frasal", "grammarCopyPOScompn": "Composto", @@ -11352,5 +11357,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Alterar e-mail", + "withTheseAddressesDescription": "Com esses endereços de e-mail, você pode fazer login, recuperar sua senha e gerenciar assinaturas.", + "noAddressDescription": "Você ainda não adicionou nenhum endereço de e-mail.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pt_PT.arb b/lib/l10n/intl_pt_PT.arb index 2e2dad6cf..77a475da6 100644 --- a/lib/l10n/intl_pt_PT.arb +++ b/lib/l10n/intl_pt_PT.arb @@ -3328,7 +3328,7 @@ "selectAll": "Selecionar tudo", "deselectAll": "Desmarcar tudo", "@@locale": "pt_PT", - "@@last_modified": "2026-02-09 10:31:01.370163", + "@@last_modified": "2026-02-09 11:09:27.266695", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11989,6 +11989,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Sobre mim", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Verbo Frasal", "grammarCopyPOScompn": "Composto", @@ -12023,5 +12028,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Alterar e-mail", + "withTheseAddressesDescription": "Com esses endereços de e-mail, você pode fazer login, recuperar sua senha e gerenciar assinaturas.", + "noAddressDescription": "Você ainda não adicionou nenhum endereço de e-mail.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ro.arb b/lib/l10n/intl_ro.arb index b63507f52..318b79fc2 100644 --- a/lib/l10n/intl_ro.arb +++ b/lib/l10n/intl_ro.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:30:31.024987", + "@@last_modified": "2026-02-09 11:08:50.792922", "about": "Despre", "@about": { "type": "String", @@ -11695,6 +11695,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Despre mine", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Verb Phrastic", "grammarCopyPOScompn": "Compus", @@ -11729,5 +11734,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Schimbă emailul", + "withTheseAddressesDescription": "Cu aceste adrese de email poți să te conectezi, să îți recuperezi parola și să gestionezi abonamentele.", + "noAddressDescription": "Nu ai adăugat încă nicio adresă de email.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index acd67ec0d..75a4b00f1 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -1,6 +1,6 @@ { "@@locale": "ru", - "@@last_modified": "2026-02-09 10:31:33.668055", + "@@last_modified": "2026-02-09 11:09:59.118469", "about": "О проекте", "@about": { "type": "String", @@ -11068,6 +11068,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Обо мне", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Идиома", "grammarCopyPOSphrasalv": "Фразовый глагол", "grammarCopyPOScompn": "Составное", @@ -11102,5 +11107,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Сменить электронную почту", + "withTheseAddressesDescription": "С помощью этих адресов электронной почты вы можете войти в систему, восстановить пароль и управлять подписками.", + "noAddressDescription": "Вы еще не добавили ни одного адреса электронной почты.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sk.arb b/lib/l10n/intl_sk.arb index f372fb272..e3b50a52c 100644 --- a/lib/l10n/intl_sk.arb +++ b/lib/l10n/intl_sk.arb @@ -1,6 +1,6 @@ { "@@locale": "sk", - "@@last_modified": "2026-02-09 10:30:32.743132", + "@@last_modified": "2026-02-09 11:08:52.351401", "about": "O aplikácii", "@about": { "type": "String", @@ -12044,6 +12044,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "O mne", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idióm", "grammarCopyPOSphrasalv": "Frázové sloveso", "grammarCopyPOScompn": "Zložené", @@ -12078,5 +12083,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Zmeniť e-mail", + "withTheseAddressesDescription": "S týmito e-mailovými adresami sa môžete prihlásiť, obnoviť svoje heslo a spravovať predplatné.", + "noAddressDescription": "Ešte ste nepridali žiadne e-mailové adresy.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sl.arb b/lib/l10n/intl_sl.arb index 3a79289b9..c62e362d3 100644 --- a/lib/l10n/intl_sl.arb +++ b/lib/l10n/intl_sl.arb @@ -2461,7 +2461,7 @@ "playWithAI": "Za zdaj igrajte z AI-jem", "courseStartDesc": "Pangea Bot je pripravljen kadarkoli!\n\n...ampak je bolje učiti se s prijatelji!", "@@locale": "sl", - "@@last_modified": "2026-02-09 10:30:46.615986", + "@@last_modified": "2026-02-09 11:09:14.553223", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12041,6 +12041,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "O meni", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Phrasalni glagol", "grammarCopyPOScompn": "Sestavljenka", @@ -12075,5 +12080,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Spremeni e-pošto", + "withTheseAddressesDescription": "S temi e-poštnimi naslovi se lahko prijavite, obnovite geslo in upravljate z naročninami.", + "noAddressDescription": "Še niste dodali nobenih e-poštnih naslovov.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sr.arb b/lib/l10n/intl_sr.arb index b064171a5..47cc65f3a 100644 --- a/lib/l10n/intl_sr.arb +++ b/lib/l10n/intl_sr.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:31:36.571438", + "@@last_modified": "2026-02-09 11:10:03.632765", "about": "О програму", "@about": { "type": "String", @@ -12062,6 +12062,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "О мени", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Phrasal Verb", "grammarCopyPOScompn": "Kombinacija", @@ -12096,5 +12101,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Promeni email", + "withTheseAddressesDescription": "Sa ovim email adresama možete se prijaviti, povratiti svoju lozinku i upravljati pretplatama.", + "noAddressDescription": "Još niste dodali nijednu email adresu.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sv.arb b/lib/l10n/intl_sv.arb index c5a281579..787d57133 100644 --- a/lib/l10n/intl_sv.arb +++ b/lib/l10n/intl_sv.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:31:25.986941", + "@@last_modified": "2026-02-09 11:09:51.437329", "about": "Om", "@about": { "type": "String", @@ -11438,6 +11438,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Om mig", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Idiom", "grammarCopyPOSphrasalv": "Phrasverb", "grammarCopyPOScompn": "Sammansatt", @@ -11472,5 +11477,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Ändra e-postadress", + "withTheseAddressesDescription": "Med dessa e-postadresser kan du logga in, återställa ditt lösenord och hantera prenumerationer.", + "noAddressDescription": "Du har ännu inte lagt till några e-postadresser.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ta.arb b/lib/l10n/intl_ta.arb index 82c5efb6a..77850c249 100644 --- a/lib/l10n/intl_ta.arb +++ b/lib/l10n/intl_ta.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:31:16.047418", + "@@last_modified": "2026-02-09 11:09:41.597668", "acceptedTheInvitation": "👍 {username} அழைப்பை ஏற்றுக்கொண்டது", "@acceptedTheInvitation": { "type": "String", @@ -11184,6 +11184,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "என்னைப் பற்றி", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "விளக்கம்", "grammarCopyPOSphrasalv": "பொருள் வினை", "grammarCopyPOScompn": "சேர்க்கை", @@ -11218,5 +11223,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "மின்னஞ்சலை மாற்றவும்", + "withTheseAddressesDescription": "இந்த மின்னஞ்சல் முகவரிகளுடன் நீங்கள் உள்நுழைந்து, உங்கள் கடவுச்சொல்லை மீட்டெடுக்கவும், சந்தாக்களை நிர்வகிக்கவும் முடியும்.", + "noAddressDescription": "நீங்கள் இன்னும் எந்த மின்னஞ்சல் முகவரிகளையும் சேர்க்கவில்லை.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_te.arb b/lib/l10n/intl_te.arb index 5dbeb11ec..544df2529 100644 --- a/lib/l10n/intl_te.arb +++ b/lib/l10n/intl_te.arb @@ -1917,7 +1917,7 @@ "playWithAI": "ఇప్పుడే AI తో ఆడండి", "courseStartDesc": "పాంజియా బాట్ ఎప్పుడైనా సిద్ధంగా ఉంటుంది!\n\n...కానీ స్నేహితులతో నేర్చుకోవడం మెరుగైనది!", "@@locale": "te", - "@@last_modified": "2026-02-09 10:31:11.484072", + "@@last_modified": "2026-02-09 11:09:36.591690", "@setCustomPermissionLevel": { "type": "String", "placeholders": {} @@ -12049,6 +12049,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "నా గురించి", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "సామెత", "grammarCopyPOSphrasalv": "పదబంధ క్రియ", "grammarCopyPOScompn": "సంకలనం", @@ -12083,5 +12088,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "ఇమెయిల్ మార్చండి", + "withTheseAddressesDescription": "ఈ ఇమెయిల్ చిరునామాలతో మీరు లాగిన్ అవ్వవచ్చు, మీ పాస్వర్డ్‌ను పునరుద్ధరించవచ్చు మరియు సబ్‌స్క్రిప్షన్లను నిర్వహించవచ్చు.", + "noAddressDescription": "మీరు ఇంకా ఎలాంటి ఇమెయిల్ చిరునామాలను జోడించలేదు.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_th.arb b/lib/l10n/intl_th.arb index d048c59c9..e50e12ac3 100644 --- a/lib/l10n/intl_th.arb +++ b/lib/l10n/intl_th.arb @@ -4453,7 +4453,7 @@ "playWithAI": "เล่นกับ AI ชั่วคราว", "courseStartDesc": "Pangea Bot พร้อมที่จะเริ่มต้นได้ทุกเมื่อ!\n\n...แต่การเรียนรู้ดีกว่ากับเพื่อน!", "@@locale": "th", - "@@last_modified": "2026-02-09 10:31:00.286726", + "@@last_modified": "2026-02-09 11:09:25.941768", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12018,6 +12018,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "เกี่ยวกับฉัน", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "สำนวน", "grammarCopyPOSphrasalv": "กริยาวลี", "grammarCopyPOScompn": "คำผสม", @@ -12052,5 +12057,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "เปลี่ยนอีเมล", + "withTheseAddressesDescription": "ด้วยที่อยู่อีเมลเหล่านี้ คุณสามารถเข้าสู่ระบบ กู้คืนรหัสผ่าน และจัดการการสมัครสมาชิกได้", + "noAddressDescription": "คุณยังไม่ได้เพิ่มที่อยู่อีเมลใด ๆ", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index 0048a617e..e90077aae 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -1,6 +1,6 @@ { "@@locale": "tr", - "@@last_modified": "2026-02-09 10:31:10.118107", + "@@last_modified": "2026-02-09 11:09:34.545567", "about": "Hakkında", "@about": { "type": "String", @@ -11182,6 +11182,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Hakkımda", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Deyim", "grammarCopyPOSphrasalv": "Deyim Fiili", "grammarCopyPOScompn": "Bileşik", @@ -11216,5 +11221,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "E-posta değiştir", + "withTheseAddressesDescription": "Bu e-posta adresleriyle oturum açabilir, şifrenizi kurtarabilir ve aboneliklerinizi yönetebilirsiniz.", + "noAddressDescription": "Henüz herhangi bir e-posta adresi eklemediniz.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_uk.arb b/lib/l10n/intl_uk.arb index dbfcea87b..e43890fed 100644 --- a/lib/l10n/intl_uk.arb +++ b/lib/l10n/intl_uk.arb @@ -1,6 +1,6 @@ { "@@locale": "uk", - "@@last_modified": "2026-02-09 10:30:50.645170", + "@@last_modified": "2026-02-09 11:09:18.460030", "about": "Про застосунок", "@about": { "type": "String", @@ -10954,6 +10954,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Про мене", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Ідіома", "grammarCopyPOSphrasalv": "Фразове дієслово", "grammarCopyPOScompn": "Складене", @@ -10988,5 +10993,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Змінити електронну пошту", + "withTheseAddressesDescription": "З цими електронними адресами ви можете увійти, відновити свій пароль і керувати підписками.", + "noAddressDescription": "Ви ще не додали жодних електронних адрес.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_vi.arb b/lib/l10n/intl_vi.arb index a8b484fc8..97dc307cd 100644 --- a/lib/l10n/intl_vi.arb +++ b/lib/l10n/intl_vi.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:31:14.436728", + "@@last_modified": "2026-02-09 11:09:39.961686", "about": "Giới thiệu", "@about": { "type": "String", @@ -6538,6 +6538,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "Về tôi", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "Thành ngữ", "grammarCopyPOSphrasalv": "Động từ cụm", "grammarCopyPOScompn": "Hợp chất", @@ -6572,5 +6577,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "Thay đổi email", + "withTheseAddressesDescription": "Với những địa chỉ email này, bạn có thể đăng nhập, khôi phục mật khẩu và quản lý đăng ký.", + "noAddressDescription": "Bạn chưa thêm địa chỉ email nào.", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_yue.arb b/lib/l10n/intl_yue.arb index b796030d0..dab04d273 100644 --- a/lib/l10n/intl_yue.arb +++ b/lib/l10n/intl_yue.arb @@ -1853,7 +1853,7 @@ "selectAll": "全選", "deselectAll": "取消全選", "@@locale": "yue", - "@@last_modified": "2026-02-09 10:30:47.898227", + "@@last_modified": "2026-02-09 11:09:15.805629", "@ignoreUser": { "type": "String", "placeholders": {} @@ -12051,6 +12051,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "關於我", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "成語", "grammarCopyPOSphrasalv": "短語動詞", "grammarCopyPOScompn": "複合詞", @@ -12085,5 +12090,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "更改電郵", + "withTheseAddressesDescription": "使用這些電郵地址您可以登錄、恢復密碼和管理訂閱。", + "noAddressDescription": "您尚未添加任何電郵地址。", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index 50917324d..0fe08595b 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -1,6 +1,6 @@ { "@@locale": "zh", - "@@last_modified": "2026-02-09 10:31:19.946784", + "@@last_modified": "2026-02-09 11:09:45.374826", "about": "关于", "@about": { "type": "String", @@ -10951,6 +10951,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "关于我", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "成语", "grammarCopyPOSphrasalv": "短语动词", "grammarCopyPOScompn": "复合词", @@ -10985,5 +10990,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "更改电子邮件", + "withTheseAddressesDescription": "使用这些电子邮件地址,您可以登录、恢复密码和管理订阅。", + "noAddressDescription": "您尚未添加任何电子邮件地址。", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_zh_Hant.arb b/lib/l10n/intl_zh_Hant.arb index 6180510c5..e39d78cdc 100644 --- a/lib/l10n/intl_zh_Hant.arb +++ b/lib/l10n/intl_zh_Hant.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 10:31:02.904070", + "@@last_modified": "2026-02-09 11:09:28.599644", "about": "關於", "@about": { "type": "String", @@ -10958,6 +10958,11 @@ "type": "String", "placeholders": {} }, + "aboutMeHint": "關於我", + "@aboutMeHint": { + "type": "String", + "placeholders": {} + }, "grammarCopyPOSidiom": "成語", "grammarCopyPOSphrasalv": "片語動詞", "grammarCopyPOScompn": "合成詞", @@ -10992,5 +10997,20 @@ "@youveCompletedPractice": { "type": "String", "placeholders": {} + }, + "changeEmail": "更改電子郵件", + "withTheseAddressesDescription": "使用這些電子郵件地址,您可以登錄、恢復密碼和管理訂閱。", + "noAddressDescription": "您尚未添加任何電子郵件地址。", + "@changeEmail": { + "type": "String", + "placeholders": {} + }, + "@withTheseAddressesDescription": { + "type": "String", + "placeholders": {} + }, + "@noAddressDescription": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart index 4c703aacb..5e4a4f79b 100644 --- a/lib/pangea/analytics_data/analytics_data_service.dart +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -442,7 +442,8 @@ class AnalyticsDataService { final offset = lowerLevelXP - newData.totalXP; await MatrixState.pangeaController.userController.addXPOffset(offset); await updateXPOffset( - MatrixState.pangeaController.userController.analyticsProfile!.xpOffset!, + MatrixState + .pangeaController.userController.publicProfile!.analytics.xpOffset!, ); } diff --git a/lib/pangea/analytics_misc/level_display_name.dart b/lib/pangea/analytics_misc/level_display_name.dart index e5903aa38..7fbe00d42 100644 --- a/lib/pangea/analytics_misc/level_display_name.dart +++ b/lib/pangea/analytics_misc/level_display_name.dart @@ -23,8 +23,9 @@ class LevelDisplayName extends StatelessWidget { ), child: FutureBuilder( future: MatrixState.pangeaController.userController - .getPublicAnalyticsProfile(userId), + .getPublicProfile(userId), builder: (context, snapshot) { + final analytics = snapshot.data?.analytics; return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -42,27 +43,36 @@ class LevelDisplayName extends StatelessWidget { else Row( children: [ - if (snapshot.data?.baseLanguage != null && - snapshot.data?.targetLanguage != null) + if (snapshot.data?.countryEmoji != null) + Padding( + padding: const EdgeInsets.only(right: 4.0), + child: Text( + snapshot.data!.countryEmoji!, + style: textStyle ?? + const TextStyle( + fontSize: 16.0, + ), + ), + ), + if (analytics?.baseLanguage != null && + analytics?.targetLanguage != null) Text( - snapshot.data!.baseLanguage!.langCodeShort - .toUpperCase(), + analytics!.baseLanguage!.langCodeShort.toUpperCase(), style: textStyle ?? TextStyle( fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary, ), ), - if (snapshot.data?.baseLanguage != null && - snapshot.data?.targetLanguage != null) + if (analytics?.baseLanguage != null && + analytics?.targetLanguage != null) Icon( Icons.chevron_right_outlined, size: iconSize ?? 16.0, ), - if (snapshot.data?.targetLanguage != null) + if (analytics?.targetLanguage != null) Text( - snapshot.data!.targetLanguage!.langCodeShort - .toUpperCase(), + analytics!.targetLanguage!.langCodeShort.toUpperCase(), style: textStyle ?? TextStyle( fontWeight: FontWeight.bold, @@ -70,14 +80,14 @@ class LevelDisplayName extends StatelessWidget { ), ), const SizedBox(width: 4.0), - if (snapshot.data?.level != null) + if (analytics?.level != null) Text( "⭐", style: textStyle, ), - if (snapshot.data?.level != null) + if (analytics?.level != null) Text( - "${snapshot.data!.level!}", + "${analytics!.level!}", style: textStyle ?? TextStyle( fontWeight: FontWeight.bold, diff --git a/lib/pangea/common/constants/model_keys.dart b/lib/pangea/common/constants/model_keys.dart index 84b69e97b..445c5b8ff 100644 --- a/lib/pangea/common/constants/model_keys.dart +++ b/lib/pangea/common/constants/model_keys.dart @@ -9,6 +9,7 @@ class ModelKey { static const String userDateOfBirth = 'date_of_birth'; static const String userSpeaks = 'speaks'; static const String userCountry = 'country'; + static const String userAbout = 'about'; static const String hasJoinedHelpSpace = 'has_joined_help_space'; static const String userInterests = 'interests'; static const String publicProfile = 'public_profile'; diff --git a/lib/pangea/common/controllers/pangea_controller.dart b/lib/pangea/common/controllers/pangea_controller.dart index 71a4a5987..969dcb522 100644 --- a/lib/pangea/common/controllers/pangea_controller.dart +++ b/lib/pangea/common/controllers/pangea_controller.dart @@ -120,7 +120,10 @@ class PangeaController { _settingsSubscription?.cancel(); _settingsSubscription = userController.settingsUpdateStream.stream.listen( - (update) => matrixState.client.updateBotOptions(update.userSettings), + (update) async { + await matrixState.client.updateBotOptions(update.userSettings); + await userController.updatePublicProfile(); + }, ); _joinSpaceSubscription?.cancel(); @@ -178,8 +181,10 @@ class PangeaController { ]); } - _clearCache(exclude: exclude); - matrixState.client.updateBotOptions(userController.profile.userSettings); + await _clearCache(exclude: exclude); + await matrixState.client + .updateBotOptions(userController.profile.userSettings); + await userController.updatePublicProfile(); } static final List _storageKeys = [ diff --git a/lib/pangea/learning_settings/country_picker_tile.dart b/lib/pangea/learning_settings/country_picker_tile.dart index 250b4e6ba..5391d4164 100644 --- a/lib/pangea/learning_settings/country_picker_tile.dart +++ b/lib/pangea/learning_settings/country_picker_tile.dart @@ -74,7 +74,7 @@ class CountryPickerDropdownState extends State { ), ), ], - onChanged: widget.learningController.changeCountry, + onChanged: widget.learningController.setCountry, value: widget.learningController.country, dropdownSearchData: DropdownSearchData( searchController: _searchController, diff --git a/lib/pangea/learning_settings/settings_learning.dart b/lib/pangea/learning_settings/settings_learning.dart index 64986f00b..a874c3629 100644 --- a/lib/pangea/learning_settings/settings_learning.dart +++ b/lib/pangea/learning_settings/settings_learning.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer'; import 'dart:io'; @@ -44,11 +45,14 @@ class SettingsLearningController extends State { String? languageMatchError; final ScrollController scrollController = ScrollController(); + final TextEditingController aboutTextController = TextEditingController(); + Timer? _textDebounce; @override void initState() { super.initState(); _profile = pangeaController.userController.profile.copy(); + aboutTextController.text = _profile.userSettings.about ?? ''; TtsController.setAvailableLanguages().then((_) => setState(() {})); } @@ -56,6 +60,8 @@ class SettingsLearningController extends State { void dispose() { TtsController.stop(); scrollController.dispose(); + aboutTextController.dispose(); + _textDebounce?.cancel(); super.dispose(); } @@ -183,11 +189,19 @@ class SettingsLearningController extends State { if (mounted) setState(() {}); } - void changeCountry(Country? country) { + void setCountry(Country? country) { _profile.userSettings.country = country?.name; if (mounted) setState(() {}); } + void setAbout(String about) { + _profile.userSettings.about = about; + _textDebounce?.cancel(); + _textDebounce = Timer(const Duration(milliseconds: 500), () { + if (mounted) setState(() {}); + }); + } + void updateToolSetting(ToolSetting toolSetting, bool value) { switch (toolSetting) { case ToolSetting.interactiveTranslator: @@ -350,6 +364,8 @@ class SettingsLearningController extends State { Country? get country => CountryService().findByName(_profile.userSettings.country); + String? get about => _profile.userSettings.about; + @override Widget build(BuildContext context) { return SettingsLearningView(this); diff --git a/lib/pangea/learning_settings/settings_learning_view.dart b/lib/pangea/learning_settings/settings_learning_view.dart index 1f11f2872..fd12bd93a 100644 --- a/lib/pangea/learning_settings/settings_learning_view.dart +++ b/lib/pangea/learning_settings/settings_learning_view.dart @@ -144,6 +144,16 @@ class SettingsLearningView extends StatelessWidget { initialGender: controller.gender, onChanged: controller.setGender, ), + TextField( + controller: controller.aboutTextController, + decoration: InputDecoration( + hintText: L10n.of(context).aboutMeHint, + ), + onChanged: (val) => + controller.setAbout(val), + minLines: 1, + maxLines: 3, + ), ], ), ), diff --git a/lib/pangea/user/about_me_display.dart b/lib/pangea/user/about_me_display.dart new file mode 100644 index 000000000..0a3cf5c12 --- /dev/null +++ b/lib/pangea/user/about_me_display.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/widgets/matrix.dart'; + +class AboutMeDisplay extends StatelessWidget { + final String userId; + final double maxWidth; + final double textSize; + + const AboutMeDisplay({ + super.key, + required this.userId, + this.maxWidth = 200, + this.textSize = 12, + }); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: FutureBuilder( + future: MatrixState.pangeaController.userController + .getPublicProfile(userId), + builder: (context, snapshot) => snapshot.data?.about == null + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + snapshot.data!.about!, + style: TextStyle(fontSize: textSize), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ); + } +} diff --git a/lib/pangea/user/public_profile_model.dart b/lib/pangea/user/public_profile_model.dart new file mode 100644 index 000000000..9f4ed71d9 --- /dev/null +++ b/lib/pangea/user/public_profile_model.dart @@ -0,0 +1,61 @@ +import 'package:country_picker/country_picker.dart'; + +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/user/analytics_profile_model.dart'; + +class PublicProfileModel { + final AnalyticsProfileModel analytics; + final String? country; + final String? about; + + const PublicProfileModel({ + required this.analytics, + this.country, + this.about, + }); + + String? get countryEmoji => + country != null ? CountryService().findByName(country!)?.flagEmoji : null; + + Map toJson() { + final json = analytics.toJson(); + + if (country != null) { + json['country'] = country; + } + + if (about != null) { + json['about'] = about; + } + + return json; + } + + factory PublicProfileModel.fromJson(Map json) { + final analytics = AnalyticsProfileModel.fromJson(json); + + final profileJson = + json[PangeaEventTypes.profileAnalytics] as Map?; + + final String? country = profileJson != null ? profileJson['country'] : null; + final String? about = profileJson != null ? profileJson['about'] : null; + + return PublicProfileModel( + analytics: analytics, + country: country, + about: about, + ); + } + + PublicProfileModel copyWith({ + AnalyticsProfileModel? analytics, + String? country, + String? about, + }) { + return PublicProfileModel( + analytics: analytics ?? this.analytics, + country: country ?? this.country, + about: about ?? this.about, + ); + } +} diff --git a/lib/pangea/user/user_controller.dart b/lib/pangea/user/user_controller.dart index 2e3be4226..79c9d7b70 100644 --- a/lib/pangea/user/user_controller.dart +++ b/lib/pangea/user/user_controller.dart @@ -15,6 +15,7 @@ import 'package:fluffychat/pangea/languages/language_service.dart'; import 'package:fluffychat/pangea/languages/p_language_store.dart'; import 'package:fluffychat/pangea/learning_settings/tool_settings_enum.dart'; import 'package:fluffychat/pangea/user/analytics_profile_model.dart'; +import 'package:fluffychat/pangea/user/public_profile_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'user_model.dart'; @@ -43,7 +44,7 @@ class UserController { /// to be read in from client's account data each time it is accessed. Profile? _cachedProfile; - AnalyticsProfileModel? analyticsProfile; + PublicProfileModel? publicProfile; /// Listens for account updates and updates the cached profile StreamSubscription? _profileListener; @@ -174,18 +175,21 @@ class UserController { if (client.userID == null) return; try { final resp = await client.getUserProfile(client.userID!); - analyticsProfile = - AnalyticsProfileModel.fromJson(resp.additionalProperties); + publicProfile = PublicProfileModel.fromJson(resp.additionalProperties); } catch (e) { // getting a 404 error for some users without pre-existing profile // still want to set other properties, so catch this error - analyticsProfile = AnalyticsProfileModel(); + publicProfile = PublicProfileModel( + analytics: AnalyticsProfileModel(), + ); } + await updatePublicProfile(); + // Do not await. This function pulls level from analytics, // so it waits for analytics to finish initializing. Analytics waits for user controller to // finish initializing, so this would cause a deadlock. - if (analyticsProfile!.isEmpty) { + if (publicProfile!.analytics.isEmpty) { final analyticsService = MatrixState.pangeaController.matrixState.analyticsDataService; @@ -272,12 +276,24 @@ class UserController { Future _savePublicProfileUpdate( String type, Map content, - ) async => - client.setUserProfile( + ) async { + try { + await client.setUserProfile( client.userID!, type, content, ); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'type': type, + 'content': content, + }, + ); + } + } Future updateAnalyticsProfile({ required int level, @@ -286,75 +302,98 @@ class UserController { }) async { targetLanguage ??= userL2; baseLanguage ??= userL1; - if (targetLanguage == null || analyticsProfile == null) return; + if (targetLanguage == null || publicProfile == null) return; final analyticsRoom = client.analyticsRoomLocal(targetLanguage); - if (analyticsProfile!.targetLanguage == targetLanguage && - analyticsProfile!.baseLanguage == baseLanguage && - analyticsProfile!.languageAnalytics?[targetLanguage]?.level == level && - analyticsProfile!.analyticsRoomIdByLanguage(targetLanguage) == + if (publicProfile!.analytics.targetLanguage == targetLanguage && + publicProfile!.analytics.baseLanguage == baseLanguage && + publicProfile!.analytics.languageAnalytics?[targetLanguage]?.level == + level && + publicProfile!.analytics.analyticsRoomIdByLanguage(targetLanguage) == analyticsRoom?.id) { return; } - analyticsProfile!.baseLanguage = baseLanguage; - analyticsProfile!.targetLanguage = targetLanguage; - analyticsProfile!.setLanguageInfo( + publicProfile!.analytics.baseLanguage = baseLanguage; + publicProfile!.analytics.targetLanguage = targetLanguage; + publicProfile!.analytics.setLanguageInfo( targetLanguage, level, analyticsRoom?.id, ); + await _savePublicProfileUpdate( PangeaEventTypes.profileAnalytics, - analyticsProfile!.toJson(), + publicProfile!.toJson(), ); } Future _addAnalyticsRoomIdsToPublicProfile() async { - if (analyticsProfile?.languageAnalytics == null) return; + if (publicProfile?.analytics.languageAnalytics == null) return; final analyticsRooms = client.allMyAnalyticsRooms; if (analyticsRooms.isEmpty) return; for (final analyticsRoom in analyticsRooms) { final lang = analyticsRoom.madeForLang?.split("-").first; - if (lang == null || analyticsProfile?.languageAnalytics == null) continue; + if (lang == null || publicProfile?.analytics.languageAnalytics == null) { + continue; + } final langKey = - analyticsProfile!.languageAnalytics!.keys.firstWhereOrNull( + publicProfile!.analytics.languageAnalytics!.keys.firstWhereOrNull( (l) => l.langCodeShort == lang, ); if (langKey == null) continue; - if (analyticsProfile!.languageAnalytics![langKey]!.analyticsRoomId == + if (publicProfile! + .analytics.languageAnalytics![langKey]!.analyticsRoomId == analyticsRoom.id) { continue; } - analyticsProfile!.setLanguageInfo( + publicProfile!.analytics.setLanguageInfo( langKey, - analyticsProfile!.languageAnalytics![langKey]!.level, + publicProfile!.analytics.languageAnalytics![langKey]!.level, analyticsRoom.id, ); } await _savePublicProfileUpdate( PangeaEventTypes.profileAnalytics, - analyticsProfile!.toJson(), + publicProfile!.toJson(), ); } Future addXPOffset(int offset) async { final targetLanguage = userL2; - if (targetLanguage == null || analyticsProfile == null) return; + if (targetLanguage == null || publicProfile == null) return; - analyticsProfile!.addXPOffset( + publicProfile!.analytics.addXPOffset( targetLanguage, offset, client.analyticsRoomLocal(targetLanguage)?.id, ); await _savePublicProfileUpdate( PangeaEventTypes.profileAnalytics, - analyticsProfile!.toJson(), + publicProfile!.toJson(), + ); + } + + Future updatePublicProfile() async { + if (publicProfile == null || + (publicProfile!.country == profile.userSettings.country && + publicProfile!.about == profile.userSettings.about)) { + return; + } + + publicProfile = publicProfile!.copyWith( + country: profile.userSettings.country, + about: profile.userSettings.about, + ); + + await _savePublicProfileUpdate( + PangeaEventTypes.profileAnalytics, + publicProfile!.toJson(), ); } @@ -380,6 +419,28 @@ class UserController { } } + Future getPublicProfile(String userId) async { + try { + if (userId == BotName.byEnvironment) { + return PublicProfileModel( + analytics: AnalyticsProfileModel(), + ); + } + + final resp = await client.getUserProfile(userId); + return PublicProfileModel.fromJson(resp.additionalProperties); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + userId: userId, + }, + ); + return null; + } + } + bool isToolEnabled(ToolSetting setting) { return userToolSetting(setting); } diff --git a/lib/pangea/user/user_model.dart b/lib/pangea/user/user_model.dart index ff0184627..8f7cc1976 100644 --- a/lib/pangea/user/user_model.dart +++ b/lib/pangea/user/user_model.dart @@ -18,6 +18,7 @@ class UserSettings { String? sourceLanguage; GenderEnum gender; String? country; + String? about; LanguageLevelTypeEnum cefrLevel; String? voice; @@ -29,6 +30,7 @@ class UserSettings { this.sourceLanguage, this.gender = GenderEnum.unselected, this.country, + this.about, this.cefrLevel = LanguageLevelTypeEnum.a1, this.voice, }); @@ -49,6 +51,7 @@ class UserSettings { ) : GenderEnum.unselected, country: json[ModelKey.userCountry], + about: json[ModelKey.userAbout], cefrLevel: json[ModelKey.cefrLevel] is String ? LanguageLevelTypeEnum.fromString( json[ModelKey.cefrLevel], @@ -66,6 +69,7 @@ class UserSettings { data[ModelKey.sourceLanguage] = sourceLanguage; data[ModelKey.userGender] = gender.string; data[ModelKey.userCountry] = country; + data[ModelKey.userAbout] = about; data[ModelKey.cefrLevel] = cefrLevel.string; data[ModelKey.voice] = voice; return data; @@ -126,6 +130,7 @@ class UserSettings { sourceLanguage: sourceLanguage, gender: gender, country: country, + about: about, cefrLevel: cefrLevel, voice: voice, ); @@ -143,6 +148,7 @@ class UserSettings { other.sourceLanguage == sourceLanguage && other.gender == gender && other.country == country && + other.about == about && other.cefrLevel == cefrLevel && other.voice == voice; } @@ -156,6 +162,7 @@ class UserSettings { sourceLanguage.hashCode, gender.hashCode, country.hashCode, + about.hashCode, cefrLevel.hashCode, voice.hashCode, ]); diff --git a/lib/widgets/adaptive_dialogs/user_dialog.dart b/lib/widgets/adaptive_dialogs/user_dialog.dart index 102101336..866f53bcb 100644 --- a/lib/widgets/adaptive_dialogs/user_dialog.dart +++ b/lib/widgets/adaptive_dialogs/user_dialog.dart @@ -7,6 +7,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics_misc/level_display_name.dart'; +import 'package:fluffychat/pangea/user/about_me_display.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/adaptive_dialog_action.dart'; import 'package:fluffychat/widgets/avatar.dart'; @@ -163,10 +164,10 @@ class UserDialog extends StatelessWidget { // ), Padding( padding: const EdgeInsets.all(4.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, + child: Column( children: [ LevelDisplayName(userId: profile.userId), + AboutMeDisplay(userId: profile.userId), ], ), ), diff --git a/lib/widgets/member_actions_popup_menu_button.dart b/lib/widgets/member_actions_popup_menu_button.dart index 75376e351..f555a192f 100644 --- a/lib/widgets/member_actions_popup_menu_button.dart +++ b/lib/widgets/member_actions_popup_menu_button.dart @@ -8,6 +8,7 @@ import 'package:fluffychat/pangea/analytics_misc/level_display_name.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/bot/widgets/bot_chat_settings_dialog.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/user/about_me_display.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/permission_slider_dialog.dart'; import 'adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; @@ -58,53 +59,61 @@ void showMemberActionsPopupMenu({ items: >[ PopupMenuItem( value: _MemberActions.info, - child: Row( - // Pangea# - spacing: 12.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Avatar( - name: displayname, - mxContent: user.avatarUrl, - presenceUserId: user.id, - presenceBackgroundColor: theme.colorScheme.surfaceContainer, - ), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + Row( + // Pangea# + spacing: 12.0, children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 128), - child: Text( - displayname, - textAlign: TextAlign.center, - style: theme.textTheme.labelLarge, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + Avatar( + name: displayname, + mxContent: user.avatarUrl, + presenceUserId: user.id, + presenceBackgroundColor: theme.colorScheme.surfaceContainer, ), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 128), - child: Text( - user.id, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 10), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 128), + child: Text( + displayname, + textAlign: TextAlign.center, + style: theme.textTheme.labelLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 128), + child: Text( + user.id, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 10), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + // #Pangea + Padding( + padding: const EdgeInsets.all(4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LevelDisplayName(userId: user.id), + ], + ), + ), + // Pangea# + ], ), - // #Pangea - Padding( - padding: const EdgeInsets.all(4.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - LevelDisplayName(userId: user.id), - ], - ), - ), - // Pangea# ], ), + // #Pangea + AboutMeDisplay(userId: user.id), + // Pangea# ], ), ), From 8088b6a605e964594bc25dfb4049e5c4cf03870c Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:18:47 -0500 Subject: [PATCH 09/12] fix: attach room / event metadata to new token click construct uses on messages (#5607) --- .../lemma_use_example_messages.dart | 7 +------ lib/pangea/toolbar/message_selection_overlay.dart | 2 ++ 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/pangea/analytics_details_popup/lemma_use_example_messages.dart b/lib/pangea/analytics_details_popup/lemma_use_example_messages.dart index 86dbc3364..f18e9aeda 100644 --- a/lib/pangea/analytics_details_popup/lemma_use_example_messages.dart +++ b/lib/pangea/analytics_details_popup/lemma_use_example_messages.dart @@ -7,9 +7,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.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_misc/learning_skills_enum.dart'; import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; @@ -26,10 +24,7 @@ class LemmaUseExampleMessages extends StatelessWidget { Future> _getExampleMessages() async { final List examples = []; for (final OneConstructUse use in construct.cappedUses) { - if (use.useType.skillsEnumType != LearningSkillsEnum.writing || - use.metadata.eventId == null || - use.form == null || - use.xp <= 0) { + if (use.metadata.eventId == null || use.form == null || use.xp <= 0) { continue; } diff --git a/lib/pangea/toolbar/message_selection_overlay.dart b/lib/pangea/toolbar/message_selection_overlay.dart index ed66f5c79..3d0769fe8 100644 --- a/lib/pangea/toolbar/message_selection_overlay.dart +++ b/lib/pangea/toolbar/message_selection_overlay.dart @@ -222,6 +222,8 @@ class MessageOverlayController extends State "word-zoom-card-${token.text.uniqueKey}", token, Matrix.of(context).analyticsDataService, + roomId: event.room.id, + eventId: event.eventId, ).then((_) { if (mounted) setState(() {}); }); From 507fee84fe9ac454562cc1c8f318ba46b1a784ed Mon Sep 17 00:00:00 2001 From: avashilling <165050625+avashilling@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:42:01 -0500 Subject: [PATCH 10/12] 5327 audio activities (#5596) * feat: sentence based audio activities WIP * chore: fix up audio activities - add continue button and show example message upon selecting correct answers - token and lemma fixes - fully fetch audio before displaying activity - don't repeat messages in the same session * chore: cleanup unnecessary widget * merge conflict --- lib/l10n/intl_en.arb | 1 + .../analytics_misc/example_message_util.dart | 137 ++++++-- .../analytics_practice_page.dart | 127 ++++++- .../analytics_practice_session_model.dart | 60 +++- .../analytics_practice_session_repo.dart | 105 +++++- .../analytics_practice_view.dart | 310 +++++++++++++++--- .../choice_cards/game_choice_card.dart | 15 +- .../morph_category_activity_generator.dart | 4 +- .../vocab_audio_activity_generator.dart | 54 ++- .../activity_type_enum.dart | 2 +- .../lemma_activity_generator.dart | 9 +- .../message_activity_request.dart | 6 +- .../practice_activity_model.dart | 33 +- 13 files changed, 724 insertions(+), 139 deletions(-) diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 8cbb0a2fc..433c92433 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5072,6 +5072,7 @@ "autoIGCToolName": "Enable writing assistance", "autoIGCToolDescription": "Automatically run Pangea Chat tools to correct sent messages to target language.", "emptyAudioError": "Recording failed. Please check your audio permissions and try again.", + "selectAllWords": "Select all the words you hear in the audio", "aboutMeHint": "About me", "changeEmail": "Change email", "withTheseAddressesDescription": "With these email addresses you can log in, recover your password, and manage subscriptions.", diff --git a/lib/pangea/analytics_misc/example_message_util.dart b/lib/pangea/analytics_misc/example_message_util.dart index f9c5713aa..6f7c73e2f 100644 --- a/lib/pangea/analytics_misc/example_message_util.dart +++ b/lib/pangea/analytics_misc/example_message_util.dart @@ -4,14 +4,93 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +/// Internal result class that holds all computed data from building an example message. +class _ExampleMessageResult { + final List displaySpans; + final List includedTokens; + final String text; + final int adjustedTargetIndex; + final String? eventId; + final String? roomId; + + _ExampleMessageResult({ + required this.displaySpans, + required this.includedTokens, + required this.text, + required this.adjustedTargetIndex, + this.eventId, + this.roomId, + }); + + List toSpans() => displaySpans; + AudioExampleMessage toAudioExampleMessage() => AudioExampleMessage( + tokens: includedTokens, + eventId: eventId, + roomId: roomId, + exampleMessage: ExampleMessageInfo(exampleMessage: displaySpans), + ); +} + class ExampleMessageUtil { static Future?> getExampleMessage( ConstructUses construct, Client client, { String? form, + bool noBold = false, + }) async { + final result = await _getExampleMessageResult( + construct, + client, + form: form, + noBold: noBold, + ); + return result?.toSpans(); + } + + static Future getAudioExampleMessage( + ConstructUses construct, + Client client, { + String? form, + bool noBold = false, + }) async { + final result = await _getExampleMessageResult( + construct, + client, + form: form, + noBold: noBold, + ); + return result?.toAudioExampleMessage(); + } + + static Future>> getExampleMessages( + ConstructUses construct, + Client client, + int maxMessages, { + bool noBold = false, + }) async { + final List> allSpans = []; + for (final use in construct.cappedUses) { + if (allSpans.length >= maxMessages) break; + final event = await client.getEventByConstructUse(use); + if (event == null) continue; + + final result = _buildExampleMessage(use.form, event, noBold: noBold); + if (result != null) { + allSpans.add(result.toSpans()); + } + } + return allSpans; + } + + static Future<_ExampleMessageResult?> _getExampleMessageResult( + ConstructUses construct, + Client client, { + String? form, + bool noBold = false, }) async { for (final use in construct.cappedUses) { if (form != null && use.form != form) continue; @@ -19,36 +98,17 @@ class ExampleMessageUtil { final event = await client.getEventByConstructUse(use); if (event == null) continue; - final spans = _buildExampleMessage(use.form, event); - if (spans != null) return spans; + final result = _buildExampleMessage(use.form, event, noBold: noBold); + if (result != null) return result; } - return null; } - static Future>> getExampleMessages( - ConstructUses construct, - Client client, - int maxMessages, - ) async { - final List> allSpans = []; - for (final use in construct.cappedUses) { - if (allSpans.length >= maxMessages) break; - final event = await client.getEventByConstructUse(use); - if (event == null) continue; - - final spans = _buildExampleMessage(use.form, event); - if (spans != null) { - allSpans.add(spans); - } - } - return allSpans; - } - - static List? _buildExampleMessage( + static _ExampleMessageResult? _buildExampleMessage( String? form, - PangeaMessageEvent messageEvent, - ) { + PangeaMessageEvent messageEvent, { + bool noBold = false, + }) { String? text; List? tokens; int targetTokenIndex = -1; @@ -99,6 +159,7 @@ class ExampleMessageUtil { // ---------- BEFORE ---------- int beforeStartOffset = 0; bool trimmedBefore = false; + int firstIncludedTokenIndex = 0; if (beforeAvailable > beforeBudget) { final desiredStart = targetStart - beforeBudget; @@ -110,6 +171,7 @@ class ExampleMessageUtil { if (tokenEnd > desiredStart) { beforeStartOffset = token.text.offset; + firstIncludedTokenIndex = i; trimmedBefore = true; break; } @@ -124,6 +186,7 @@ class ExampleMessageUtil { // ---------- AFTER ---------- int afterEndOffset = totalChars; bool trimmedAfter = false; + int lastIncludedTokenIndex = tokens.length - 1; if (afterAvailable > afterBudget) { final desiredEnd = targetEnd + afterBudget; @@ -132,6 +195,7 @@ class ExampleMessageUtil { final token = tokens[i]; if (token.text.offset >= desiredEnd) { afterEndOffset = token.text.offset; + lastIncludedTokenIndex = i - 1; trimmedAfter = true; break; } @@ -144,15 +208,34 @@ class ExampleMessageUtil { .toString() .trimRight(); - return [ + final displaySpans = [ if (trimmedBefore) const TextSpan(text: '… '), TextSpan(text: before), TextSpan( text: targetToken.text.content, - style: const TextStyle(fontWeight: FontWeight.bold), + style: noBold ? null : const TextStyle(fontWeight: FontWeight.bold), ), TextSpan(text: after), if (trimmedAfter) const TextSpan(text: '…'), ]; + + // Extract only the tokens that are included in the displayed text + final includedTokens = + tokens.sublist(firstIncludedTokenIndex, lastIncludedTokenIndex + 1); + + // Adjust target token index relative to the included tokens + final adjustedTargetIndex = targetTokenIndex - firstIncludedTokenIndex; + + return _ExampleMessageResult( + displaySpans: displaySpans, + includedTokens: includedTokens, + text: text.characters + .skip(beforeStartOffset) + .take(afterEndOffset - beforeStartOffset) + .toString(), + adjustedTargetIndex: adjustedTargetIndex, + eventId: messageEvent.eventId, + roomId: messageEvent.room.id, + ); } } diff --git a/lib/pangea/analytics_practice/analytics_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart index b64b363f1..478861e84 100644 --- a/lib/pangea/analytics_practice/analytics_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -1,10 +1,7 @@ import 'dart:async'; import 'dart:collection'; -import 'package:flutter/material.dart'; - import 'package:collection/collection.dart'; - import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.dart'; @@ -19,15 +16,19 @@ import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_ import 'package:fluffychat/pangea/analytics_practice/analytics_practice_view.dart'; import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_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/text_to_speech/tts_controller.dart'; +import 'package:fluffychat/pangea/toolbar/message_practice/message_audio_card.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/practice_record_controller.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; class SelectedMorphChoice { final MorphFeaturesEnum feature; @@ -102,11 +103,17 @@ class AnalyticsPracticeState extends State ValueNotifier(null); final ValueNotifier hintPressedNotifier = ValueNotifier(false); + + final Set _selectedCorrectAnswers = {}; + + // Track if we're showing the completion message for audio activities + final ValueNotifier showingAudioCompletion = ValueNotifier(false); final ValueNotifier hintsUsedNotifier = ValueNotifier(0); static const int maxHints = 5; final Map> _choiceTexts = {}; final Map> _choiceEmojis = {}; + final Map _audioFiles = {}; StreamSubscription? _languageStreamSubscription; @@ -130,6 +137,7 @@ class AnalyticsPracticeState extends State enableChoicesNotifier.dispose(); selectedMorphChoice.dispose(); hintPressedNotifier.dispose(); + showingAudioCompletion.dispose(); hintsUsedNotifier.dispose(); super.dispose(); } @@ -220,6 +228,7 @@ class AnalyticsPracticeState extends State hintsUsedNotifier.value = 0; enableChoicesNotifier.value = true; progressNotifier.value = 0.0; + showingAudioCompletion.value = false; _queue.clear(); _choiceTexts.clear(); _choiceEmojis.clear(); @@ -236,7 +245,11 @@ class AnalyticsPracticeState extends State void _playAudio() { if (activityTarget.value == null) return; - if (widget.type != ConstructTypeEnum.vocab) return; + if (widget.type == ConstructTypeEnum.vocab && + _currentActivity is VocabMeaningPracticeActivityModel) { + } else { + return; + } TtsController.tryToSpeak( activityTarget.value!.target.tokens.first.vocabConstructID.lemma, langCode: MatrixState.pangeaController.userController.userL2!.langCode, @@ -323,6 +336,7 @@ class AnalyticsPracticeState extends State if (_continuing) return; _continuing = true; enableChoicesNotifier.value = true; + showingAudioCompletion.value = false; try { if (activityState.value @@ -334,6 +348,7 @@ class AnalyticsPracticeState extends State activityState.value = const AsyncState.loading(); selectedMorphChoice.value = null; hintPressedNotifier.value = false; + _selectedCorrectAnswers.clear(); final nextActivityCompleter = _queue.removeFirst(); try { @@ -435,9 +450,57 @@ class AnalyticsPracticeState extends State await _fetchLemmaInfo(activityModel.storageKey, choices); } + // Prefetch audio for audio activities before marking ready + if (activityModel is VocabAudioPracticeActivityModel) { + await _loadAudioForActivity(activityModel); + } + return activityModel; } + Future _loadAudioForActivity( + VocabAudioPracticeActivityModel activity, + ) async { + final eventId = activity.eventId; + final roomId = activity.roomId; + + if (eventId == null || roomId == null) { + throw L10n.of(context).oopsSomethingWentWrong; + } + + final client = MatrixState.pangeaController.matrixState.client; + final room = client.getRoomById(roomId); + + if (room == null) { + throw L10n.of(context).oopsSomethingWentWrong; + } + + final event = await room.getEventById(eventId); + if (event == null) { + throw L10n.of(context).oopsSomethingWentWrong; + } + + final pangeaEvent = PangeaMessageEvent( + event: event, + timeline: await room.getTimeline(), + ownMessage: event.senderId == client.userID, + ); + + // Prefetch the audio file + final audioFile = await pangeaEvent.requestTextToSpeech( + activity.langCode, + MatrixState.pangeaController.userController.voice, + ); + + // Store the audio file with the eventId as key + _audioFiles[eventId] = audioFile; + } + + PangeaAudioFile? getAudioFile(String? eventId) { + if (eventId == null) return null; + return _audioFiles[eventId]; + } + Future _fetchLemmaInfo( String requestKey, List choiceIds, @@ -495,6 +558,22 @@ class AnalyticsPracticeState extends State hintPressedNotifier.value = true; } + Future onAudioContinuePressed() async { + showingAudioCompletion.value = false; + + //Mark this activity as completed, and either load the next or complete the session + _sessionLoader.value!.completeActivity(); + progressNotifier.value = _sessionLoader.value!.progress; + + if (_queue.isEmpty) { + await _completeSession(); + } else if (_isComplete) { + await _completeSession(); + } else { + await _continueSession(); + } + } + Future onSelectChoice( String choiceContent, ) async { @@ -508,8 +587,17 @@ class AnalyticsPracticeState extends State tag: choiceContent, ); } + final isCorrect = activity.multipleChoiceContent.isCorrect(choiceContent); - if (isCorrect) { + + final isAudioActivity = + activity.activityType == ActivityTypeEnum.lemmaAudio; + if (isAudioActivity && isCorrect) { + _selectedCorrectAnswers.add(choiceContent); + } + + if (isCorrect && !isAudioActivity) { + // Non-audio activities disable choices after first correct answer enableChoicesNotifier.value = false; } @@ -525,7 +613,24 @@ class AnalyticsPracticeState extends State await _analyticsService.updateService .addAnalytics(choiceTargetId(choiceContent), [use]); - if (!activity.multipleChoiceContent.isCorrect(choiceContent)) return; + if (!isCorrect) return; + + // For audio activities, check if all answers have been selected + if (isAudioActivity) { + final allAnswers = activity.multipleChoiceContent.answers; + final allSelected = allAnswers + .every((answer) => _selectedCorrectAnswers.contains(answer)); + + if (!allSelected) { + return; + } + + // All answers selected, disable choices and show completion message + enableChoicesNotifier.value = false; + await Future.delayed(const Duration(milliseconds: 1000)); + showingAudioCompletion.value = true; + return; + } _playAudio(); @@ -553,7 +658,7 @@ class AnalyticsPracticeState extends State final construct = target.targetTokenConstructID(token); if (widget.type == ConstructTypeEnum.morph) { - return activityRequest.morphExampleInfo?.exampleMessage; + return activityRequest.exampleMessage?.exampleMessage; } return ExampleMessageUtil.getExampleMessage( @@ -562,6 +667,14 @@ class AnalyticsPracticeState extends State ); } + List? getAudioExampleMessage() { + final activity = _currentActivity; + if (activity is VocabAudioPracticeActivityModel) { + return activity.exampleMessage.exampleMessage; + } + return null; + } + Future get derivedAnalyticsData => _analyticsService.derivedData; diff --git a/lib/pangea/analytics_practice/analytics_practice_session_model.dart b/lib/pangea/analytics_practice/analytics_practice_session_model.dart index ebeccdce0..9e6057b63 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_model.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_model.dart @@ -3,13 +3,14 @@ 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/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; -class MorphExampleInfo { +class ExampleMessageInfo { final List exampleMessage; - const MorphExampleInfo({ + const ExampleMessageInfo({ required this.exampleMessage, }); @@ -30,7 +31,7 @@ class MorphExampleInfo { }; } - factory MorphExampleInfo.fromJson(Map json) { + factory ExampleMessageInfo.fromJson(Map json) { final segments = json['segments'] as List? ?? []; final spans = []; @@ -46,25 +47,60 @@ class MorphExampleInfo { ); } - return MorphExampleInfo(exampleMessage: spans); + return ExampleMessageInfo(exampleMessage: spans); + } +} + +/// An extended example message that includes both formatted display spans and tokens to generate audio practice activities. +/// eventId/roomId are needed for audio playback. +class AudioExampleMessage { + final List tokens; + final String? eventId; + final String? roomId; + final ExampleMessageInfo exampleMessage; + + const AudioExampleMessage({ + required this.tokens, + this.eventId, + this.roomId, + required this.exampleMessage, + }); + + Map toJson() { + return { + 'eventId': eventId, + 'roomId': roomId, + }; + } + + factory AudioExampleMessage.fromJson(Map json) { + return AudioExampleMessage( + tokens: const [], + eventId: json['eventId'] as String?, + roomId: json['roomId'] as String?, + exampleMessage: const ExampleMessageInfo(exampleMessage: []), + ); } } class AnalyticsActivityTarget { final PracticeTarget target; final GrammarErrorRequestInfo? grammarErrorInfo; - final MorphExampleInfo? morphExampleInfo; + final ExampleMessageInfo? exampleMessage; + final AudioExampleMessage? audioExampleMessage; AnalyticsActivityTarget({ required this.target, this.grammarErrorInfo, - this.morphExampleInfo, + this.exampleMessage, + this.audioExampleMessage, }); Map toJson() => { 'target': target.toJson(), 'grammarErrorInfo': grammarErrorInfo?.toJson(), - 'morphExampleInfo': morphExampleInfo?.toJson(), + 'exampleMessage': exampleMessage?.toJson(), + 'audioExampleMessage': audioExampleMessage?.toJson(), }; factory AnalyticsActivityTarget.fromJson(Map json) => @@ -73,8 +109,11 @@ class AnalyticsActivityTarget { grammarErrorInfo: json['grammarErrorInfo'] != null ? GrammarErrorRequestInfo.fromJson(json['grammarErrorInfo']) : null, - morphExampleInfo: json['morphExampleInfo'] != null - ? MorphExampleInfo.fromJson(json['morphExampleInfo']) + exampleMessage: json['exampleMessage'] != null + ? ExampleMessageInfo.fromJson(json['exampleMessage']) + : null, + audioExampleMessage: json['audioExampleMessage'] != null + ? AudioExampleMessage.fromJson(json['audioExampleMessage']) : null, ); } @@ -131,7 +170,8 @@ class AnalyticsPracticeSessionModel { activityQualityFeedback: null, target: target.target, grammarErrorInfo: target.grammarErrorInfo, - morphExampleInfo: target.morphExampleInfo, + exampleMessage: target.exampleMessage, + audioExampleMessage: target.audioExampleMessage, ); }).toList(); } diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index abf2b47b0..d50154085 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -34,29 +34,44 @@ class AnalyticsPracticeSessionRepo { throw UnsubscribedException(); } - final r = Random(); - final activityTypes = ActivityTypeEnum.analyticsPracticeTypes(type); - - final types = List.generate( - AnalyticsPracticeConstants.practiceGroupSize + - AnalyticsPracticeConstants.errorBufferSize, - (_) => activityTypes[r.nextInt(activityTypes.length)], - ); - final List targets = []; if (type == ConstructTypeEnum.vocab) { - final constructs = await _fetchVocab(); - final targetCount = min(constructs.length, types.length); - targets.addAll([ - for (var i = 0; i < targetCount; i++) + const totalNeeded = AnalyticsPracticeConstants.practiceGroupSize + + AnalyticsPracticeConstants.errorBufferSize; + final halfNeeded = (totalNeeded / 2).ceil(); + + // Fetch audio constructs (with example messages) + final audioMap = await _fetchAudio(); + final audioCount = min(audioMap.length, halfNeeded); + + // Fetch vocab constructs to fill the rest + final vocabNeeded = totalNeeded - audioCount; + final vocabConstructs = await _fetchVocab(); + final vocabCount = min(vocabConstructs.length, vocabNeeded); + + for (final entry in audioMap.entries.take(audioCount)) { + targets.add( AnalyticsActivityTarget( target: PracticeTarget( - tokens: [constructs[i].asToken], - activityType: types[i], + tokens: [entry.key.asToken], + activityType: ActivityTypeEnum.lemmaAudio, + ), + audioExampleMessage: entry.value, + ), + ); + } + for (var i = 0; i < vocabCount; i++) { + targets.add( + AnalyticsActivityTarget( + target: PracticeTarget( + tokens: [vocabConstructs[i].asToken], + activityType: ActivityTypeEnum.lemmaMeaning, ), ), - ]); + ); + } + targets.shuffle(); } else { final errorTargets = await _fetchErrors(); targets.addAll(errorTargets); @@ -77,7 +92,7 @@ class AnalyticsPracticeSessionRepo { activityType: ActivityTypeEnum.grammarCategory, morphFeature: entry.feature, ), - morphExampleInfo: MorphExampleInfo( + exampleMessage: ExampleMessageInfo( exampleMessage: entry.exampleMessage, ), ), @@ -132,6 +147,62 @@ class AnalyticsPracticeSessionRepo { return targets; } + static Future> + _fetchAudio() async { + final constructs = await MatrixState + .pangeaController.matrixState.analyticsDataService + .getAggregatedConstructs(ConstructTypeEnum.vocab) + .then((map) => map.values.toList()); + + // sort by last used descending, nulls first + constructs.sort((a, b) { + final dateA = a.lastUsed; + final dateB = b.lastUsed; + if (dateA == null && dateB == null) return 0; + if (dateA == null) return -1; + if (dateB == null) return 1; + return dateA.compareTo(dateB); + }); + + final Set seenLemmas = {}; + final Set seenEventIds = {}; + final targets = {}; + + for (final construct in constructs) { + if (targets.length >= + (AnalyticsPracticeConstants.practiceGroupSize + + AnalyticsPracticeConstants.errorBufferSize)) { + break; + } + + if (seenLemmas.contains(construct.lemma)) continue; + + // Try to get an audio example message with token data for this lemma + final audioExampleMessage = + await ExampleMessageUtil.getAudioExampleMessage( + await MatrixState.pangeaController.matrixState.analyticsDataService + .getConstructUse(construct.id), + MatrixState.pangeaController.matrixState.client, + noBold: true, + ); + + // Only add to targets if we found an example message AND its eventId hasn't been used + if (audioExampleMessage != null) { + final eventId = audioExampleMessage.eventId; + if (eventId != null && seenEventIds.contains(eventId)) { + continue; + } + + seenLemmas.add(construct.lemma); + if (eventId != null) { + seenEventIds.add(eventId); + } + targets[construct.id] = audioExampleMessage; + } + } + return targets; + } + static Future> _fetchMorphs() async { final constructs = await MatrixState .pangeaController.matrixState.analyticsDataService diff --git a/lib/pangea/analytics_practice/analytics_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart index e425c7495..2874f2ae6 100644 --- a/lib/pangea/analytics_practice/analytics_practice_view.dart +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -1,13 +1,11 @@ -import 'package:flutter/material.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/chat/events/audio_player.dart'; import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart'; import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; -import 'package:fluffychat/pangea/analytics_practice/choice_cards/audio_choice_card.dart'; import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart'; import 'package:fluffychat/pangea/analytics_practice/choice_cards/grammar_choice_card.dart'; import 'package:fluffychat/pangea/analytics_practice/choice_cards/meaning_choice_card.dart'; @@ -25,6 +23,7 @@ import 'package:fluffychat/pangea/practice_activities/practice_activity_model.da import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; class AnalyticsPracticeView extends StatelessWidget { final AnalyticsPracticeState controller; @@ -119,60 +118,79 @@ class _AnalyticsActivityView extends StatelessWidget { : Theme.of(context).textTheme.titleMedium; titleStyle = titleStyle?.copyWith(fontWeight: FontWeight.bold); - return ListView( + return Column( children: [ - //Hints counter bar for grammar activities only - if (controller.widget.type == ConstructTypeEnum.morph) - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: _HintsCounterBar(controller: controller), - ), - //per-activity instructions, add switch statement once there are more types - const InstructionsInlineTooltip( - instructionsEnum: InstructionsEnum.selectMeaning, - padding: EdgeInsets.symmetric( - vertical: 8.0, - ), - ), - SizedBox( - height: 75.0, - child: ValueListenableBuilder( - valueListenable: controller.activityTarget, - builder: (context, target, __) => target != null - ? Column( - children: [ - Text( - target.promptText(context), - textAlign: TextAlign.center, - style: titleStyle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (controller.widget.type == ConstructTypeEnum.vocab) - PhoneticTranscriptionWidget( - text: - target.target.tokens.first.vocabConstructID.lemma, - textLanguage: MatrixState - .pangeaController.userController.userL2!, - style: const TextStyle(fontSize: 14.0), + Expanded( + child: ListView( + children: [ + //Hints counter bar for grammar activities only + if (controller.widget.type == ConstructTypeEnum.morph) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _HintsCounterBar(controller: controller), + ), + //per-activity instructions, add switch statement once there are more types + const InstructionsInlineTooltip( + instructionsEnum: InstructionsEnum.selectMeaning, + padding: EdgeInsets.symmetric( + vertical: 8.0, + ), + ), + SizedBox( + height: 75.0, + child: ValueListenableBuilder( + valueListenable: controller.activityTarget, + builder: (context, target, __) { + if (target == null) return const SizedBox.shrink(); + + final isAudioActivity = target.target.activityType == + ActivityTypeEnum.lemmaAudio; + final isVocabType = + controller.widget.type == ConstructTypeEnum.vocab; + + return Column( + children: [ + Text( + isAudioActivity && isVocabType + ? L10n.of(context).selectAllWords + : target.promptText(context), + textAlign: TextAlign.center, + style: titleStyle, + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - ], - ) - : const SizedBox.shrink(), + if (isVocabType && !isAudioActivity) + PhoneticTranscriptionWidget( + text: target + .target.tokens.first.vocabConstructID.lemma, + textLanguage: MatrixState + .pangeaController.userController.userL2!, + style: const TextStyle(fontSize: 14.0), + ), + ], + ); + }, + ), + ), + const SizedBox(height: 16.0), + Center( + child: _AnalyticsPracticeCenterContent(controller: controller), + ), + const SizedBox(height: 16.0), + (controller.widget.type == ConstructTypeEnum.morph) + ? Center(child: _HintSection(controller: controller)) + : const SizedBox.shrink(), + const SizedBox(height: 16.0), + _ActivityChoicesWidget(controller), + const SizedBox(height: 16.0), + _WrongAnswerFeedback(controller: controller), + ], ), ), - const SizedBox(height: 16.0), - Center( - child: _AnalyticsPracticeCenterContent(controller: controller), + Container( + alignment: Alignment.bottomCenter, + child: _AudioContinueButton(controller: controller), ), - const SizedBox(height: 16.0), - (controller.widget.type == ConstructTypeEnum.morph) - ? Center(child: _HintSection(controller: controller)) - : const SizedBox.shrink(), - const SizedBox(height: 16.0), - _ActivityChoicesWidget(controller), - const SizedBox(height: 16.0), - _WrongAnswerFeedback(controller: controller), ], ); } @@ -222,6 +240,32 @@ class _AnalyticsPracticeCenterContent extends StatelessWidget { controller.getExampleMessage(target!), ), ), + ActivityTypeEnum.lemmaAudio => ValueListenableBuilder( + valueListenable: controller.activityState, + builder: (context, state, __) => switch (state) { + AsyncLoaded( + value: final VocabAudioPracticeActivityModel activity + ) => + SizedBox( + height: 100.0, + child: Center( + child: AudioPlayerWidget( + null, + color: Theme.of(context).colorScheme.primary, + linkColor: Theme.of(context).colorScheme.secondary, + fontSize: + AppConfig.fontSizeFactor * AppConfig.messageFontSize, + eventId: '${activity.eventId}_practice', + roomId: activity.roomId!, + senderId: Matrix.of(context).client.userID!, + matrixFile: controller.getAudioFile(activity.eventId)!, + autoplay: true, + ), + ), + ), + _ => const SizedBox(height: 100.0), + }, + ), _ => SizedBox( height: 100.0, child: Center( @@ -235,6 +279,50 @@ class _AnalyticsPracticeCenterContent extends StatelessWidget { } } +class _AudioCompletionWidget extends StatelessWidget { + final AnalyticsPracticeState controller; + + const _AudioCompletionWidget({ + super.key, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + final exampleMessage = controller.getAudioExampleMessage(); + + if (exampleMessage == null || exampleMessage.isEmpty) { + return const SizedBox(height: 100.0); + } + + return Padding( + padding: const EdgeInsets.all(16.0), + 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: RichText( + text: TextSpan( + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryFixed, + fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize, + ), + children: exampleMessage, + ), + ), + ), + ); + } +} + class _ExampleMessageWidget extends StatelessWidget { final Future?> future; @@ -629,6 +717,63 @@ class _ActivityChoicesWidget extends StatelessWidget { valueListenable: controller.enableChoicesNotifier, builder: (context, enabled, __) { final choices = controller.filteredChoices(value); + final isAudioActivity = + value.activityType == ActivityTypeEnum.lemmaAudio; + + if (isAudioActivity) { + // For audio activities, use AnimatedSwitcher to fade between choices and example message + return ValueListenableBuilder( + valueListenable: controller.showingAudioCompletion, + builder: (context, showingCompletion, __) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + layoutBuilder: (currentChild, previousChildren) { + return Stack( + alignment: Alignment.topCenter, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + child: showingCompletion + ? _AudioCompletionWidget( + key: const ValueKey('completion'), + controller: controller, + ) + : Padding( + key: const ValueKey('choices'), + padding: const EdgeInsets.all(16.0), + child: Wrap( + alignment: WrapAlignment.center, + spacing: 8.0, + runSpacing: 8.0, + children: choices + .map( + (choice) => _ChoiceCard( + activity: value, + targetId: controller + .choiceTargetId(choice.choiceId), + choiceId: choice.choiceId, + onPressed: () => + controller.onSelectChoice( + choice.choiceId, + ), + cardHeight: 48.0, + choiceText: choice.choiceText, + choiceEmoji: choice.choiceEmoji, + enabled: enabled, + shrinkWrap: true, + ), + ) + .toList(), + ), + ), + ); + }, + ); + } + return Column( spacing: 8.0, mainAxisAlignment: MainAxisAlignment.center, @@ -663,6 +808,56 @@ class _ActivityChoicesWidget extends StatelessWidget { } } +class _AudioContinueButton extends StatelessWidget { + final AnalyticsPracticeState controller; + + const _AudioContinueButton({ + required this.controller, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller.activityState, + builder: (context, state, __) { + // Only show for audio activities + if (state is! AsyncLoaded) { + return const SizedBox.shrink(); + } + + final activity = state.value; + if (activity.activityType != ActivityTypeEnum.lemmaAudio) { + return const SizedBox.shrink(); + } + + return ValueListenableBuilder( + valueListenable: controller.showingAudioCompletion, + builder: (context, showingCompletion, __) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: ElevatedButton( + onPressed: showingCompletion + ? controller.onAudioContinuePressed + : null, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 48.0, + vertical: 16.0, + ), + ), + child: Text( + L10n.of(context).continueText, + style: const TextStyle(fontSize: 18.0), + ), + ), + ); + }, + ); + }, + ); + } +} + class _ChoiceCard extends StatelessWidget { final MultipleChoicePracticeActivityModel activity; final String choiceId; @@ -673,6 +868,7 @@ class _ChoiceCard extends StatelessWidget { final String choiceText; final String? choiceEmoji; final bool enabled; + final bool shrinkWrap; const _ChoiceCard({ required this.activity, @@ -683,6 +879,7 @@ class _ChoiceCard extends StatelessWidget { required this.choiceText, required this.choiceEmoji, this.enabled = true, + this.shrinkWrap = false, }); @override @@ -708,16 +905,21 @@ class _ChoiceCard extends StatelessWidget { ); case ActivityTypeEnum.lemmaAudio: - return AudioChoiceCard( + return GameChoiceCard( key: ValueKey( '${constructId.string}_${activityType.name}_audio_$choiceId', ), - text: choiceId, + shouldFlip: false, targetId: targetId, onPressed: onPressed, isCorrect: isCorrect, height: cardHeight, isEnabled: enabled, + shrinkWrap: shrinkWrap, + child: Text( + choiceText, + textAlign: TextAlign.center, + ), ); case ActivityTypeEnum.grammarCategory: diff --git a/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart index 88ee9b7f9..3082e88b1 100644 --- a/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart +++ b/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart @@ -14,6 +14,7 @@ class GameChoiceCard extends StatefulWidget { final bool shouldFlip; final String targetId; final bool isEnabled; + final bool shrinkWrap; const GameChoiceCard({ required this.child, @@ -24,6 +25,7 @@ class GameChoiceCard extends StatefulWidget { this.height = 72.0, this.shouldFlip = false, this.isEnabled = true, + this.shrinkWrap = false, super.key, }); @@ -90,7 +92,7 @@ class _GameChoiceCardState extends State link: MatrixState.pAnyState.layerLinkAndKey(widget.targetId).link, child: HoverBuilder( builder: (context, hovered) => SizedBox( - width: double.infinity, + width: widget.shrinkWrap ? null : double.infinity, height: widget.height, child: GestureDetector( onTap: _handleTap, @@ -109,6 +111,7 @@ class _GameChoiceCardState extends State overlayColor: _revealed ? tintColor : (hovered ? hoverColor : Colors.transparent), + shrinkWrap: widget.shrinkWrap, child: Opacity( opacity: showContent ? 1 : 0, child: _revealed ? widget.altChild! : widget.child, @@ -123,6 +126,7 @@ class _GameChoiceCardState extends State overlayColor: _clicked ? tintColor : (hovered ? hoverColor : Colors.transparent), + shrinkWrap: widget.shrinkWrap, child: widget.child, ), ), @@ -137,19 +141,24 @@ class _CardContainer extends StatelessWidget { final Color baseColor; final Color overlayColor; final Widget child; + final bool shrinkWrap; const _CardContainer({ required this.height, required this.baseColor, required this.overlayColor, required this.child, + this.shrinkWrap = false, }); @override Widget build(BuildContext context) { return Container( - height: height, - alignment: Alignment.center, + height: shrinkWrap ? null : height, + padding: shrinkWrap + ? const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0) + : null, + alignment: shrinkWrap ? null : Alignment.center, decoration: BoxDecoration( color: baseColor, borderRadius: BorderRadius.circular(16), diff --git a/lib/pangea/analytics_practice/morph_category_activity_generator.dart b/lib/pangea/analytics_practice/morph_category_activity_generator.dart index 78ef8ba78..1a7069c8a 100644 --- a/lib/pangea/analytics_practice/morph_category_activity_generator.dart +++ b/lib/pangea/analytics_practice/morph_category_activity_generator.dart @@ -59,8 +59,8 @@ class MorphCategoryActivityGenerator { choices: choices.toSet(), answers: {morphTag}, ), - morphExampleInfo: - req.morphExampleInfo ?? const MorphExampleInfo(exampleMessage: []), + exampleMessageInfo: + req.exampleMessage ?? const ExampleMessageInfo(exampleMessage: []), ), ); } diff --git a/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart b/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart index 7b2954f51..bf3ee7770 100644 --- a/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart +++ b/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart @@ -1,3 +1,4 @@ +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; import 'package:fluffychat/pangea/practice_activities/lemma_activity_generator.dart'; import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; @@ -8,20 +9,61 @@ class VocabAudioActivityGenerator { MessageActivityRequest req, ) async { final token = req.target.tokens.first; - final choices = - await LemmaActivityGenerator.lemmaActivityDistractors(token); + final audioExample = req.audioExampleMessage; - final choicesList = choices.map((c) => c.lemma).toList(); - choicesList.shuffle(); + final Set answers = {token.text.content.toLowerCase()}; + final Set wordsInMessage = {}; + if (audioExample != null) { + for (final t in audioExample.tokens) { + wordsInMessage.add(t.text.content.toLowerCase()); + } + + // Extract up to 3 additional words as answers + final otherWords = audioExample.tokens + .where( + (t) => + t.lemma.saveVocab && + t.text.content.toLowerCase() != + token.text.content.toLowerCase() && + t.text.content.trim().isNotEmpty, + ) + .take(3) + .map((t) => t.text.content.toLowerCase()) + .toList(); + + answers.addAll(otherWords); + } + + // Generate distractors, filtering out anything in the message or answers + final choices = await LemmaActivityGenerator.lemmaActivityDistractors( + token, + maxChoices: 20, + ); + final choicesList = choices + .map((c) => c.lemma) + .where( + (lemma) => + !answers.contains(lemma.toLowerCase()) && + !wordsInMessage.contains(lemma.toLowerCase()), + ) + .take(4) + .toList(); + + final allChoices = [...choicesList, ...answers]; + allChoices.shuffle(); return MessageActivityResponse( activity: VocabAudioPracticeActivityModel( tokens: req.target.tokens, langCode: req.userL2, multipleChoiceContent: MultipleChoiceActivity( - choices: choicesList.toSet(), - answers: {token.lemma.text}, + choices: allChoices.toSet(), + answers: answers, ), + roomId: audioExample?.roomId, + eventId: audioExample?.eventId, + exampleMessage: audioExample?.exampleMessage ?? + const ExampleMessageInfo(exampleMessage: []), ), ); } diff --git a/lib/pangea/practice_activities/activity_type_enum.dart b/lib/pangea/practice_activities/activity_type_enum.dart index b8e493d9d..50b91c348 100644 --- a/lib/pangea/practice_activities/activity_type_enum.dart +++ b/lib/pangea/practice_activities/activity_type_enum.dart @@ -246,7 +246,7 @@ enum ActivityTypeEnum { static List get _vocabPracticeTypes => [ ActivityTypeEnum.lemmaMeaning, - // ActivityTypeEnum.lemmaAudio, + ActivityTypeEnum.lemmaAudio, ]; static List get _grammarPracticeTypes => [ diff --git a/lib/pangea/practice_activities/lemma_activity_generator.dart b/lib/pangea/practice_activities/lemma_activity_generator.dart index 83ff095de..79d9d2e2f 100644 --- a/lib/pangea/practice_activities/lemma_activity_generator.dart +++ b/lib/pangea/practice_activities/lemma_activity_generator.dart @@ -33,8 +33,9 @@ class LemmaActivityGenerator { } static Future> lemmaActivityDistractors( - PangeaToken token, - ) async { + PangeaToken token, { + int? maxChoices = 4, + }) async { final constructs = await MatrixState .pangeaController.matrixState.analyticsDataService .getAggregatedConstructs(ConstructTypeEnum.vocab); @@ -54,13 +55,13 @@ class LemmaActivityGenerator { // Skip the first 7 lemmas (to avoid very similar and conjugated forms of verbs) if we have enough lemmas final int startIndex = sortedLemmas.length > 11 ? 7 : 0; - // Take up to 4 lemmas ensuring uniqueness by lemma text + // Take up to 4 (or maxChoices) lemmas ensuring uniqueness by lemma text final List uniqueByLemma = []; for (int i = startIndex; i < sortedLemmas.length; i++) { final cid = sortedLemmas[i]; if (!uniqueByLemma.any((c) => c.lemma == cid.lemma)) { uniqueByLemma.add(cid); - if (uniqueByLemma.length == 4) break; + if (uniqueByLemma.length == maxChoices) break; } } diff --git a/lib/pangea/practice_activities/message_activity_request.dart b/lib/pangea/practice_activities/message_activity_request.dart index ec6a9e491..d0bdc5e1c 100644 --- a/lib/pangea/practice_activities/message_activity_request.dart +++ b/lib/pangea/practice_activities/message_activity_request.dart @@ -80,7 +80,8 @@ class MessageActivityRequest { final PracticeTarget target; final ActivityQualityFeedback? activityQualityFeedback; final GrammarErrorRequestInfo? grammarErrorInfo; - final MorphExampleInfo? morphExampleInfo; + final ExampleMessageInfo? exampleMessage; + final AudioExampleMessage? audioExampleMessage; MessageActivityRequest({ required this.userL1, @@ -88,7 +89,8 @@ class MessageActivityRequest { required this.activityQualityFeedback, required this.target, this.grammarErrorInfo, - this.morphExampleInfo, + this.exampleMessage, + this.audioExampleMessage, }) { if (target.tokens.isEmpty) { throw Exception('Target tokens must not be empty'); diff --git a/lib/pangea/practice_activities/practice_activity_model.dart b/lib/pangea/practice_activities/practice_activity_model.dart index eddbdacf1..24f9caa55 100644 --- a/lib/pangea/practice_activities/practice_activity_model.dart +++ b/lib/pangea/practice_activities/practice_activity_model.dart @@ -112,9 +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: []), + exampleMessageInfo: json['example_message_info'] != null + ? ExampleMessageInfo.fromJson(json['example_message_info']) + : const ExampleMessageInfo(exampleMessage: []), ); case ActivityTypeEnum.lemmaAudio: assert( @@ -125,6 +125,11 @@ sealed class PracticeActivityModel { langCode: langCode, tokens: tokens, multipleChoiceContent: multipleChoiceContent!, + roomId: json['room_id'] as String?, + eventId: json['event_id'] as String?, + exampleMessage: json['example_message'] != null + ? ExampleMessageInfo.fromJson(json['example_message']) + : const ExampleMessageInfo(exampleMessage: []), ); case ActivityTypeEnum.lemmaMeaning: assert( @@ -306,13 +311,13 @@ sealed class MorphPracticeActivityModel } class MorphCategoryPracticeActivityModel extends MorphPracticeActivityModel { - final MorphExampleInfo morphExampleInfo; + final ExampleMessageInfo exampleMessageInfo; MorphCategoryPracticeActivityModel({ required super.tokens, required super.langCode, required super.morphFeature, required super.multipleChoiceContent, - required this.morphExampleInfo, + required this.exampleMessageInfo, }); @override @@ -340,7 +345,7 @@ class MorphCategoryPracticeActivityModel extends MorphPracticeActivityModel { @override Map toJson() { final json = super.toJson(); - json['morph_example_info'] = morphExampleInfo.toJson(); + json['example_message_info'] = exampleMessageInfo.toJson(); return json; } } @@ -356,11 +361,27 @@ class MorphMatchPracticeActivityModel extends MorphPracticeActivityModel { class VocabAudioPracticeActivityModel extends MultipleChoicePracticeActivityModel { + final String? roomId; + final String? eventId; + final ExampleMessageInfo exampleMessage; + VocabAudioPracticeActivityModel({ required super.tokens, required super.langCode, required super.multipleChoiceContent, + this.roomId, + this.eventId, + required this.exampleMessage, }); + + @override + Map toJson() { + final json = super.toJson(); + json['room_id'] = roomId; + json['event_id'] = eventId; + json['example_message'] = exampleMessage.toJson(); + return json; + } } class VocabMeaningPracticeActivityModel From dec473d579024e20eec6a472f8efa04fc15d0c4f Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:55:18 -0500 Subject: [PATCH 11/12] Writing assistance (#5598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: wa working full stack * feat: writing assistance made anew * docs: migrate copilot docs to .github/instructions/ format - Create choreographer.instructions.md (applyTo: lib/pangea/choreographer/**) - Create events-and-tokens.instructions.md (applyTo: lib/pangea/events/**,lib/pangea/extensions/**) - Create modules.instructions.md (applyTo: lib/pangea/**) — full module map - Track copilot-instructions.md (remove .gitignore rule) - Add documentation reference table to copilot-instructions.md Content sourced from docs/copilot/ on writing-assistance branch. * docs: remove old docs/copilot/ (migrated to .github/instructions/) * docs: update choreographer + modules docs for writing-assistance audit - Mark IT (Interactive Translation) as deprecated throughout - Document new ReplacementTypeEnum taxonomy (grammar, surface, word-choice categories) - Add AssistanceStateEnum, AutocorrectPopup, feedback rerun flow - Mark SpanDataRepo/span_details as dead code - Mark SpanChoiceTypeEnum.bestCorrection/bestAnswer as deprecated - Add new files to modules listing (autocorrect_popup, start_igc_button, etc.) - Update API endpoints table with active/deprecated/dead status * formatting, replace deprecated withOpacity calls * fix linter issues from deprecated types * use better error color * move cloing of overlays into choreographer * reduce duplicate code on igc_controller, update UI on feedback * couple of adjustments * display prompt in span card by type * fix error in tests * translations * simplify span card feedback --------- Co-authored-by: ggurdin --- .github/copilot-instructions.md | 47 ++ .../choreographer.instructions.md | 161 +++++ .../events-and-tokens.instructions.md | 174 ++++++ .github/instructions/modules.instructions.md | 127 ++++ .gitignore | 4 +- lib/l10n/intl_ar.arb | 52 +- lib/l10n/intl_be.arb | 52 +- lib/l10n/intl_bn.arb | 52 +- lib/l10n/intl_bo.arb | 52 +- lib/l10n/intl_ca.arb | 52 +- lib/l10n/intl_cs.arb | 52 +- lib/l10n/intl_da.arb | 52 +- lib/l10n/intl_de.arb | 52 +- lib/l10n/intl_el.arb | 52 +- lib/l10n/intl_en.arb | 10 + lib/l10n/intl_eo.arb | 52 +- lib/l10n/intl_es.arb | 52 +- lib/l10n/intl_et.arb | 52 +- lib/l10n/intl_eu.arb | 52 +- lib/l10n/intl_fa.arb | 52 +- lib/l10n/intl_fi.arb | 52 +- lib/l10n/intl_fil.arb | 52 +- lib/l10n/intl_fr.arb | 52 +- lib/l10n/intl_ga.arb | 52 +- lib/l10n/intl_gl.arb | 52 +- lib/l10n/intl_he.arb | 52 +- lib/l10n/intl_hi.arb | 52 +- lib/l10n/intl_hr.arb | 52 +- lib/l10n/intl_hu.arb | 52 +- lib/l10n/intl_ia.arb | 52 +- lib/l10n/intl_id.arb | 52 +- lib/l10n/intl_ie.arb | 52 +- lib/l10n/intl_it.arb | 52 +- lib/l10n/intl_ja.arb | 52 +- lib/l10n/intl_ka.arb | 52 +- lib/l10n/intl_ko.arb | 52 +- lib/l10n/intl_lt.arb | 52 +- lib/l10n/intl_lv.arb | 52 +- lib/l10n/intl_nb.arb | 52 +- lib/l10n/intl_nl.arb | 52 +- lib/l10n/intl_pl.arb | 52 +- lib/l10n/intl_pt.arb | 52 +- lib/l10n/intl_pt_BR.arb | 52 +- lib/l10n/intl_pt_PT.arb | 52 +- lib/l10n/intl_ro.arb | 52 +- lib/l10n/intl_ru.arb | 52 +- lib/l10n/intl_sk.arb | 52 +- lib/l10n/intl_sl.arb | 52 +- lib/l10n/intl_sr.arb | 52 +- lib/l10n/intl_sv.arb | 52 +- lib/l10n/intl_ta.arb | 52 +- lib/l10n/intl_te.arb | 52 +- lib/l10n/intl_th.arb | 52 +- lib/l10n/intl_tr.arb | 52 +- lib/l10n/intl_uk.arb | 52 +- lib/l10n/intl_vi.arb | 52 +- lib/l10n/intl_yue.arb | 52 +- lib/l10n/intl_zh.arb | 52 +- lib/l10n/intl_zh_Hant.arb | 52 +- lib/pages/chat/chat.dart | 7 +- lib/pages/chat/input_bar.dart | 9 +- .../chat/widgets/pangea_chat_input_row.dart | 2 + lib/pangea/choreographer/choreographer.dart | 18 +- .../choreographer/edit_type_auto_apply.md | 97 +++ .../igc/SPAN_CARD_REDESIGN_FINALIZED.md | 269 ++++++++ .../igc/SPAN_CARD_REDESIGN_Q_AND_A.md | 574 ++++++++++++++++++ .../choreographer/igc/igc_controller.dart | 131 ++-- lib/pangea/choreographer/igc/igc_repo.dart | 5 + .../choreographer/igc/igc_request_model.dart | 69 ++- .../choreographer/igc/igc_response_model.dart | 28 +- .../choreographer/igc/pangea_match_model.dart | 32 +- .../igc/replacement_type_enum.dart | 314 ++++++++++ lib/pangea/choreographer/igc/span_card.dart | 184 +++--- .../igc/span_choice_type_enum.dart | 45 +- .../choreographer/igc/span_data_model.dart | 97 ++- .../igc/span_data_type_enum.dart | 43 -- .../text_editing/pangea_text_controller.dart | 17 +- lib/pangea/common/constants/model_keys.dart | 2 + .../common/models/base_request_model.dart | 41 ++ .../common/models/llm_feedback_model.dart | 25 + lib/pangea/common/network/requests.dart | 11 +- lib/pangea/common/network/urls.dart | 2 +- lib/pangea/common/utils/overlay.dart | 2 + test/pangea/igc_response_model_test.dart | 246 ++++++++ test/pangea/pangea_match_model_test.dart | 179 ++++++ test/pangea/span_data_model_test.dart | 178 ++++++ 86 files changed, 5514 insertions(+), 392 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/instructions/choreographer.instructions.md create mode 100644 .github/instructions/events-and-tokens.instructions.md create mode 100644 .github/instructions/modules.instructions.md create mode 100644 lib/pangea/choreographer/edit_type_auto_apply.md create mode 100644 lib/pangea/choreographer/igc/SPAN_CARD_REDESIGN_FINALIZED.md create mode 100644 lib/pangea/choreographer/igc/SPAN_CARD_REDESIGN_Q_AND_A.md create mode 100644 lib/pangea/choreographer/igc/replacement_type_enum.dart delete mode 100644 lib/pangea/choreographer/igc/span_data_type_enum.dart create mode 100644 lib/pangea/common/models/base_request_model.dart create mode 100644 lib/pangea/common/models/llm_feedback_model.dart create mode 100644 test/pangea/igc_response_model_test.dart create mode 100644 test/pangea/pangea_match_model_test.dart create mode 100644 test/pangea/span_data_model_test.dart diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..174cd77c6 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,47 @@ +You own the docs. Three sources of truth must agree: **docs**, **code**, and **prior user guidance**. When they don't, resolve it. Update `.github/instructions/` docs when your changes shift conventions. Fix obvious factual errors (paths, class names) without asking. Flag ambiguity when sources contradict. + +# client - Flutter/Dart Language Learning Chat App + +## Tech Stack +- **Framework**: Flutter (SDK ≥3.0), Dart +- **Base**: Fork of FluffyChat (package name `fluffychat`) +- **Protocol**: Matrix Client-Server API via `matrix` Dart SDK +- **Subscriptions**: RevenueCat +- **Backend**: 2-step-choreographer (FastAPI) via `PApiUrls` +- **Error Tracking**: Sentry +- **Routing**: GoRouter + +## Quick Reference + +### Project Structure +- `lib/pages/`, `lib/widgets/`, `lib/utils/`, `lib/config/` — FluffyChat base code +- `lib/pangea/` — **All Pangea language-learning code** (~30 feature modules) +- `pangea_packages/` — Shared isolate packages +- Pangea modifications in FluffyChat files marked with `// #Pangea` ... `// Pangea#` + +### Key Files +- **Entry point**: `lib/main.dart` +- **Root state**: `lib/widgets/matrix.dart` (`MatrixState`) +- **Pangea controller**: `lib/pangea/common/controllers/pangea_controller.dart` +- **Routes**: `lib/config/routes.dart` +- **API URLs**: `lib/pangea/common/network/urls.dart` +- **HTTP client**: `lib/pangea/common/network/requests.dart` +- **Environment**: `lib/pangea/common/config/environment.dart` (reads `.env` / `config.sample.json`) +- **Event types**: `lib/pangea/events/constants/pangea_event_types.dart` +- **Choreographer**: `lib/pangea/choreographer/choreographer.dart` + +### Conventions +- Package imports use `package:fluffychat/...` +- Feature modules follow pattern: `models/`, `repo/` (API calls), `widgets/`, `utils/`, `constants/` +- API repo files pair with request/response models (e.g., `igc_repo.dart` + `igc_request_model.dart` + `igc_response_model.dart`) +- Controllers extend `ChangeNotifier` or use `BaseController` (stream-based) + +## Documentation + +Detailed guides auto-load from `.github/instructions/` when editing matching files: + +| File | Applies To | Content | +|------|-----------|---------| +| `modules.instructions.md` | `lib/pangea/**` | Full map of ~30 feature modules | +| `choreographer.instructions.md` | `lib/pangea/choreographer/**` | Writing assistance flow (IGC, IT, text editing) | +| `events-and-tokens.instructions.md` | `lib/pangea/events/**,lib/pangea/extensions/**` | Custom Matrix events, token model, event wrappers | \ No newline at end of file diff --git a/.github/instructions/choreographer.instructions.md b/.github/instructions/choreographer.instructions.md new file mode 100644 index 000000000..3dcebbf2f --- /dev/null +++ b/.github/instructions/choreographer.instructions.md @@ -0,0 +1,161 @@ +--- +applyTo: "lib/pangea/choreographer/**" +--- + +# Choreographer — Writing Assistance Flow + +The choreographer is the client-side orchestrator for real-time writing assistance. It coordinates user text input, API calls to `/grammar_v2`, match display, and the creation of choreo records saved with sent messages. + +> **⚠️ IT (Interactive Translation) is deprecated.** The `it/` directory, `ITController`, and `/it_initialstep` endpoint are still wired into the choreographer but are being phased out. IT will become just another match type returned by IGC. Do not add new IT functionality. + +## Architecture + +``` +Choreographer (ChangeNotifier) +├── PangeaTextController ← Extended TextEditingController (tracks edit types) +├── IgcController ← Grammar check matches (primary flow) +├── ITController ← ⚠️ DEPRECATED — Translation step-by-step flow +├── ChoreographerErrorController ← Error state + backoff +└── ChoreographerStateExtension ← AssistanceStateEnum derivation +``` + +### Key files + +| File | Purpose | +|---|---| +| `choreographer.dart` | Main orchestrator (ChangeNotifier) | +| `choreographer_state_extension.dart` | Derives `AssistanceStateEnum` from current state | +| `assistance_state_enum.dart` | UI states: noSub, noMessage, notFetched, fetching, fetched, complete, error | +| `choreo_mode_enum.dart` | `igc` (active) or `it` (⚠️ deprecated) | +| `choreo_record_model.dart` | Record of edits saved with message | +| `igc/igc_controller.dart` | IGC state management (437 lines) | +| `igc/replacement_type_enum.dart` | Granular match type taxonomy (grammar, surface, word-choice, etc.) | +| `igc/autocorrect_popup.dart` | Undo popup for auto-applied corrections | +| `text_editing/pangea_text_controller.dart` | Text controller with edit type tracking | +| `it/it_controller.dart` | ⚠️ DEPRECATED — IT state | + +## Flow + +### 1. User types → debounce → IGC request + +1. User types in chat input. `PangeaTextController` fires `_onChange`. +2. After debounce (`ChoreoConstants.msBeforeIGCStart`), `requestWritingAssistance()` is called. +3. `IgcController.getIGCTextData()` calls `/grammar_v2` via `IgcRepo`. +4. Response contains a list of `SpanData` (matches) — grammar errors, out-of-target markers, normalization fixes. +5. Auto-apply matches (punct, diacritics, spell, cap) are accepted automatically via `acceptNormalizationMatches()`. Grammar/word-choice matches become `openMatches`. + +### 2. Matches displayed → Span cards + +1. Open matches render as colored underlines in the text field (colors set by `ReplacementTypeEnum.underlineColor()`). +2. Tapping a match opens a **span card** overlay (`span_card.dart`) showing the error category (`ReplacementTypeEnum.displayName()`), choices, and the error message. +3. Auto-applied corrections show an `AutocorrectPopup` with undo capability. + +### 3. User resolves matches + +Each match goes through `PangeaMatchState` with status transitions: + +- `open` → `accepted` (user chose a replacement) +- `open` → `ignored` (user dismissed) +- `open` → `automatic` (auto-apply correction) +- Any → `undo` (user reverted) + +When a match is accepted/ignored, the `IgcController` fires `matchUpdateStream`. The `Choreographer` listens and: + +- Updates the text via `textController.setSystemText()` +- Records the step in `ChoreoRecordModel` + +### 4. Feedback rerun + +If the user is unsatisfied with results, `rerunWithFeedback(feedbackText)` re-calls IGC with user feedback and the previous request/response context (`_lastRequest`, `_lastResponse`). + +### 5. Sending + +On send, `Choreographer.getMessageContent()`: + +1. Calls `/tokenize` to get `PangeaToken` data for the final text (with exponential backoff on errors). +2. Builds `PangeaMessageContentModel` containing: + - The final message text + - `ChoreoRecordModel` (full editing history) + - `PangeaRepresentation` for original written text (if IT was used) + - `PangeaMessageTokens` (token/lemma/morph data) + +## AssistanceStateEnum + +Derived in `choreographer_state_extension.dart`. Drives the send-button color and UI hints: + +| State | Meaning | +|---|---| +| `noSub` | User has no active subscription | +| `noMessage` | Text field is empty | +| `notFetched` | Text entered but IGC hasn't run yet | +| `fetching` | IGC request in flight | +| `fetched` | Matches present — user needs to resolve them | +| `complete` | All matches resolved, ready to send | +| `error` | IGC error (backoff active) | + +## ReplacementTypeEnum — Match Type Taxonomy + +Defined in `igc/replacement_type_enum.dart`. Categories returned by `/grammar_v2`: + +| Category | Types | Behavior | +|---|---|---| +| **Client-only** | `definition`, `practice`, `itStart` | Not from server; `itStart` triggers deprecated IT flow | +| **Grammar** (~21 types) | `verbConjugation`, `verbTense`, `verbMood`, `subjectVerbAgreement`, `genderAgreement`, `numberAgreement`, `caseError`, `article`, `preposition`, `pronoun`, `wordOrder`, `negation`, `questionFormation`, `relativeClause`, `connector`, `possessive`, `comparative`, `passiveVoice`, `conditional`, `infinitiveGerund`, `modal` | Orange underline, user must accept/ignore | +| **Surface corrections** | `punct`, `diacritics`, `spell`, `cap` | Auto-applied (no user interaction), undo via `AutocorrectPopup` | +| **Word choice** | `falseCognate`, `l1Interference`, `collocation`, `semanticConfusion` | Blue underline, user must accept/ignore | +| **Higher-level** | `transcription`, `style`, `fluency`, `didYouMean`, `translation`, `other` | Teal (style/fluency) or error color | + +Key extension helpers: `isAutoApply`, `isGrammarType`, `isWordChoiceType`, `underlineColor()`, `displayName()`, `fromString()` (handles legacy snake_case and old type names like `grammar` → `subjectVerbAgreement`). + +## Key Models + +| Model | File | Purpose | +|---|---|---| +| `SpanData` | `igc/span_data_model.dart` | A match span (offset, length, choices, message, rule, `ReplacementTypeEnum`) | +| `PangeaMatch` | `igc/pangea_match_model.dart` | SpanData + status | +| `PangeaMatchState` | `igc/pangea_match_state_model.dart` | Mutable wrapper tracking original vs updated match state | +| `ChoreoRecordModel` | `choreo_record_model.dart` | Full editing history: steps, open matches, original text | +| `ChoreoRecordStepModel` | `choreo_edit_model.dart` | Single edit step (text before/after, accepted match) | +| `IGCRequestModel` | `igc/igc_request_model.dart` | Request to `/grammar_v2` | +| `IGCResponseModel` | `igc/igc_response_model.dart` | Response from `/grammar_v2` | +| `MatchRuleIdModel` | `igc/match_rule_id_model.dart` | Rule ID constants (⚠️ `tokenNeedsTranslation`, `tokenSpanNeedsTranslation`, `l1SpanAndGrammar` — not currently sent by server) | +| `AutocorrectPopup` | `igc/autocorrect_popup.dart` | Undo widget for auto-applied corrections | + +## API Endpoints + +| Endpoint | Repo File | Status | +|---|---|---| +| `/choreo/grammar_v2` | `igc/igc_repo.dart` | ✅ Active — primary IGC endpoint | +| `/choreo/tokenize` | `events/repo/tokens_repo.dart` | ✅ Active — tokenizes final text on send | +| `/choreo/span_details` | `igc/span_data_repo.dart` | ❌ Dead code — `SpanDataRepo` class is defined but never imported anywhere | +| `/choreo/it_initialstep` | `it/it_repo.dart` | ⚠️ Deprecated — IT flow | +| `/choreo/contextual_definition` | `contextual_definition_repo.dart` | ⚠️ Deprecated — only used by IT's `word_data_card.dart` | + +## Edit Types (`EditTypeEnum`) + +- `keyboard` — User typing +- `igc` — System applying IGC match +- `it` — ⚠️ Deprecated — System applying IT continuance +- `itDismissed` — ⚠️ Deprecated — IT dismissed, restoring source text + +## Deprecated: SpanChoiceTypeEnum + +In `igc/span_choice_type_enum.dart`: +- `bestCorrection` — `@Deprecated('Use suggestion instead')` +- `bestAnswer` — `@Deprecated('Use suggestion instead')` +- `suggestion` — Active replacement + +## Error Handling + +- IGC and token errors trigger exponential backoff (`_igcErrorBackoff *= 2`, `_tokenErrorBackoff *= 2`) +- Backoff resets on next successful request +- Errors surfaced via `ChoreographerErrorController` +- Error state exposed in `AssistanceStateEnum.error` + +## ⚠️ Deprecated: Interactive Translation (IT) + +> **Do not extend.** IT is being deprecated. Translation will become a match type within IGC. + +The `it/` directory still contains `ITController`, `ITRepo`, `ITStepModel`, `CompletedITStepModel`, `GoldRouteTrackerModel`, `it_bar.dart`, `it_feedback_card.dart`, and `word_data_card.dart`. The choreographer still wires up IT via `_onOpenIT()` / `_onCloseIT()` / `_onAcceptContinuance()`, triggered when an `itStart` match is found. The `it_bar.dart` widget is still imported by `chat_input_bar.dart`. + +This entire flow will be removed once testing confirms IT is no longer needed as a separate mode. diff --git a/.github/instructions/events-and-tokens.instructions.md b/.github/instructions/events-and-tokens.instructions.md new file mode 100644 index 000000000..95c0d0ed8 --- /dev/null +++ b/.github/instructions/events-and-tokens.instructions.md @@ -0,0 +1,174 @@ +--- +applyTo: "lib/pangea/events/**,lib/pangea/extensions/**" +--- + +# Events & Tokens — Matrix Event Data Model + +Messages in Pangea carry rich metadata stored as Matrix events related to the main message. This doc covers custom event types, the token model, event wrappers, and how they connect. + +## Event Hierarchy for a Message + +When a user sends a message, the client creates a tree of related Matrix events: + +``` +m.room.message (the chat message) +├── pangea.representation ← PangeaRepresentation (sent text + lang) +│ ├── pangea.tokens ← PangeaMessageTokens (tokenized text) +│ └── pangea.record ← ChoreoRecordModel (editing history) +├── pangea.representation ← (optional: L1 original if IT was used) +│ └── pangea.tokens +├── pangea.translation ← Full-text translation +├── pangea.activity_req ← Request to generate practice activities +├── pangea.activity_res ← Generated practice activity +├── pangea.activity_completion ← User's activity completion record +└── pangea.stt_translation ← Speech-to-text translation +``` + +## Custom Event Types (`PangeaEventTypes`) + +Defined in `lib/pangea/events/constants/pangea_event_types.dart`: + +### Message-related + +| Type | Constant | Purpose | +|---|---|---| +| `pangea.representation` | `representation` | A text representation with language code | +| `pangea.tokens` | `tokens` | Tokenized text (lemmas, POS, morphology) | +| `pangea.record` | `choreoRecord` | Choreographer editing history | +| `pangea.translation` | `translation` | Full-text translation | +| `pangea.stt_translation` | `sttTranslation` | Speech-to-text translation | + +### Activities + +| Type | Constant | Purpose | +|---|---|---| +| `pangea.activity_req` | `activityRequest` | Request server to generate activities | +| `pangea.activity_res` | `pangeaActivity` | A practice activity for a message | +| `pangea.activity_completion` | `activityRecord` | Per-user activity completion record | +| `pangea.activity_plan` | `activityPlan` | Activity plan definition | +| `pangea.activity_roles` | `activityRole` | Roles in a structured activity | +| `pangea.activity_summary` | `activitySummary` | Post-activity summary | + +### Analytics & Learning + +| Type | Constant | Purpose | +|---|---|---| +| `pangea.construct` | `construct` | A tracked learning construct | +| `pangea.construct_summary` | `constructSummary` | Aggregate construct data | +| `pangea.summaryAnalytics` | `summaryAnalytics` | Summary analytics data | +| `pangea.analytics_profile` | `profileAnalytics` | User analytics profile | +| `pangea.activities_profile` | `profileActivities` | User activities profile | +| `pangea.analytics_settings` | `analyticsSettings` | Analytics display settings | +| `p.user_lemma_info` | `userSetLemmaInfo` | User-customized lemma info | +| `p.emoji` | `userChosenEmoji` | User-chosen emoji for a word | + +### Room/Course Settings + +| Type | Constant | Purpose | +|---|---|---| +| `pangea.class` | `languageSettings` | Room language configuration | +| `p.rules` | `rules` | Room rules | +| `pangea.roomtopic` | `roomInfo` | Room topic info | +| `pangea.bot_options` | `botOptions` | Bot behavior configuration | +| `pangea.capacity` | `capacity` | Room capacity limit | +| `pangea.course_plan` | `coursePlan` | Course plan reference | +| `p.course_user` | `courseUser` | User's course enrollment | +| `pangea.teacher_mode` | `teacherMode` | Teacher mode toggle | +| `pangea.course_chat_list` | `courseChatList` | Course chat list | + +### Audio & Media + +| Type | Constant | Purpose | +|---|---|---| +| `p.audio` | `audio` | Audio attachment | +| `pangea.transcript` | `transcript` | Audio transcript | +| `p.rule.text_to_speech` | `textToSpeechRule` | TTS settings | + +### User & Misc + +| Type | Constant | Purpose | +|---|---|---| +| `pangea.user_age` | `userAge` | User age bracket | +| `m.report` | `report` | Content report | +| `p.rule.analytics_invite` | `analyticsInviteRule` | Analytics sharing rules | +| `p.analytics_request` | `analyticsInviteContent` | Analytics sharing request | +| `pangea.regeneration_request` | `regenerationRequest` | Content regeneration request | +| `pangea.activity_room_ids` | `activityRoomIds` | Activity room references | + +## Core Data Models + +### PangeaToken (`events/models/pangea_token_model.dart`) + +The fundamental unit of linguistic analysis. Each token represents one word/unit. + +``` +PangeaToken +├── text: PangeaTokenText ← {content: "running", offset: 5} +├── lemma: Lemma ← {text: "run", saveVocab: true, form: "run"} +├── pos: String ← "VERB" (Universal Dependencies POS tag) +└── morph: Map ← {Tense: "Pres", VerbForm: "Part"} +``` + +- POS tags follow [Universal Dependencies](https://universaldependencies.org/u/pos/) +- Morph features follow [Universal Dependencies features](https://universaldependencies.org/u/feat/) +- Lemma includes `saveVocab` flag for vocab tracking + +### PangeaMessageTokens (`events/models/tokens_event_content_model.dart`) + +Container for a tokenized message, stored as `pangea.tokens` event: + +- `tokens: List` — tokenized words +- `detections: List?` — per-span language detection + +### PangeaRepresentation (`events/models/representation_content_model.dart`) + +A text representation of a message, stored as `pangea.representation` event: + +- `text` — the text content +- `langCode` — detected language +- `originalSent` — true if this is the text that was actually sent +- `originalWritten` — true if this is what the user originally typed + +Interpretation matrix: + +| `originalSent` | `originalWritten` | Meaning | +|:-:|:-:|---| +| ✓ | ✗ | Text went through IGC/IT before sending | +| ✗ | ✗ | Added by another user (e.g., translation) | +| ✓ | ✓ | User wrote and sent as-is (L1 or perfect L2) | +| ✗ | ✓ | User's original L1 that was then translated via IT | + +## Event Wrappers + +### PangeaMessageEvent (`events/event_wrappers/pangea_message_event.dart`) + +Wraps a Matrix `Event` of type `m.room.message` and provides access to all Pangea child events (representations, tokens, choreo records, translations, activities, etc.). This is the primary object used by the toolbar and reading assistance. + +Key capabilities: + +- Access tokens for the message +- Get translations and representations +- Trigger TTS/STT +- Get associated practice activities + +### PangeaRepresentationEvent (`events/event_wrappers/pangea_representation_event.dart`) + +Wraps a `pangea.representation` event. Provides typed access to `PangeaRepresentation` content. + +### PangeaChoreoEvent (`events/event_wrappers/pangea_choreo_event.dart`) + +Wraps a `pangea.record` event. Provides typed access to `ChoreoRecordModel` (editing history). + +## Room Extensions for Events + +`lib/pangea/extensions/room_events_extension.dart` extends Matrix `Room` with methods to: + +- Query child events by type +- Find representations and tokens for a message +- Access pangea-specific event data + +## Token Flow: Writing → Saving → Reading + +1. **Writing**: Choreographer gets tokens from `/tokenize` on send +2. **Saving**: `PangeaMessageContentModel` bundles tokens + choreo record + representations → saved as Matrix child events +3. **Reading**: `PangeaMessageEvent` loads child events → toolbar uses `PangeaToken` list for word cards, practice activities, analytics diff --git a/.github/instructions/modules.instructions.md b/.github/instructions/modules.instructions.md new file mode 100644 index 000000000..d41444ba3 --- /dev/null +++ b/.github/instructions/modules.instructions.md @@ -0,0 +1,127 @@ +--- +applyTo: "lib/pangea/**" +--- + +# Pangea Feature Modules (`lib/pangea/`) + +Each subdirectory is a self-contained feature module. This doc provides the full map. + +## Core Infrastructure + +| Module | Purpose | Key Files | +|---|---|---| +| `common/controllers/` | Central controllers | `pangea_controller.dart` (owns UserController, SubscriptionController, PLanguageStore), `base_controller.dart` (stream-based generic controller) | +| `common/network/` | API communication | `urls.dart` (all choreo API URLs), `requests.dart` (HTTP client) | +| `common/config/` | Environment config | `environment.dart` (reads `.env` / `config.sample.json` for URLs, homeserver, etc.) | +| `common/constants/` | Shared constants | `local.key.dart` (storage keys), `model_keys.dart` | +| `common/models/` | Base models | `base_request_model.dart`, `llm_feedback_model.dart` | +| `common/utils/` | Shared utilities | `error_handler.dart`, `firebase_analytics.dart`, `overlay.dart`, `p_vguard.dart` (route guards) | +| `common/widgets/` | Shared widgets | `pressable_button.dart`, `overlay_container.dart`, `shimmer_background.dart`, ~20 others | +| `design_system/` | Design tokens | `tokens/` | +| `navigation/` | Navigation | `navigation_util.dart` | + +## Writing Assistance (Choreographer) + +| Module | Purpose | Key Files | +|---|---|---| +| `choreographer/` | Writing flow orchestrator | `choreographer.dart` (ChangeNotifier), `choreographer_state_extension.dart`, `assistance_state_enum.dart`, `choreo_record_model.dart`, `choreo_mode_enum.dart` | +| `choreographer/igc/` | Interactive Grammar Correction | `igc_controller.dart`, `igc_repo.dart`, `replacement_type_enum.dart`, `pangea_match_model.dart`, `span_card.dart`, `span_data_model.dart`, `autocorrect_popup.dart`, `autocorrect_span.dart`, `start_igc_button.dart`, `text_normalization_util.dart` | +| `choreographer/it/` | ⚠️ DEPRECATED — Interactive Translation | `it_controller.dart`, `it_repo.dart`, `it_step_model.dart`, `it_feedback_card.dart`, `word_data_card.dart` | +| `choreographer/text_editing/` | Text controller | `pangea_text_controller.dart`, `edit_type_enum.dart` | + +## Message Toolbar (Reading Assistance) + +| Module | Purpose | Key Files | +|---|---|---| +| `toolbar/layout/` | Overlay positioning | `message_selection_positioner.dart`, `over_message_overlay.dart`, `reading_assistance_mode_enum.dart` | +| `toolbar/reading_assistance/` | Token-level reading UX | `underline_text_widget.dart`, `token_rendering_util.dart`, `select_mode_controller.dart`, `new_word_overlay.dart` | +| `toolbar/word_card/` | Word detail card | `word_card_switcher.dart`, `reading_assistance_content.dart`, `lemma_meaning_display.dart`, `token_feedback_button.dart` | +| `toolbar/message_practice/` | In-message practice | `practice_controller.dart`, `practice_activity_card.dart`, `practice_match_card.dart`, `morph_selection.dart` | + +## Events & Data Model + +| Module | Purpose | Key Files | +|---|---|---| +| `events/constants/` | Event type strings | `pangea_event_types.dart` (~30 custom types) | +| `events/event_wrappers/` | Typed event wrappers | `pangea_message_event.dart`, `pangea_choreo_event.dart`, `pangea_representation_event.dart` | +| `events/models/` | Event content models | `pangea_token_model.dart`, `pangea_token_text_model.dart`, `tokens_event_content_model.dart`, `representation_content_model.dart` | +| `events/repo/` | Token/language API | `tokens_repo.dart`, `token_api_models.dart`, `language_detection_repo.dart` | +| `events/extensions/` | Event helpers | `pangea_event_extension.dart` | +| `extensions/` | Room extensions | `pangea_room_extension.dart`, `room_events_extension.dart`, `room_user_permissions_extension.dart`, etc. | + +## Language & Linguistics + +| Module | Purpose | Key Files | +|---|---|---| +| `languages/` | Language data | `language_model.dart`, `language_repo.dart`, `language_service.dart`, `p_language_store.dart`, `locale_provider.dart` | +| `lemmas/` | Lemma (dictionary form) | `lemma.dart`, `lemma_info_repo.dart`, `user_set_lemma_info.dart` | +| `morphs/` | Morphological analysis | `morph_models.dart`, `morph_repo.dart`, `parts_of_speech_enum.dart`, `morph_features_enum.dart` | +| `constructs/` | Grammar/vocab constructs | `construct_identifier.dart`, `construct_repo.dart`, `construct_form.dart` | +| `translation/` | Full-text translation | `full_text_translation_repo.dart` + request/response models | +| `phonetic_transcription/` | IPA transcriptions | repo + models | + +## Practice & Activities + +| Module | Purpose | Key Files | +|---|---|---| +| `practice_activities/` | Activity generation | `practice_activity_model.dart`, `practice_generation_repo.dart`, `multiple_choice_activity_model.dart`, type-specific generators | +| `activity_sessions/` | Session management | `activity_room_extension.dart`, `activity_session_chat/`, `activity_session_start/` | +| `activity_planner/` | Activity planning UI | `activity_plan_model.dart`, `activity_planner_page.dart` | +| `activity_generator/` | Activity creation | `activity_generator.dart`, `activity_plan_generation_repo.dart` | +| `activity_suggestions/` | Activity suggestions | `activity_suggestion_dialog.dart`, `activity_plan_search_repo.dart` | +| `activity_summary/` | Post-activity summary | `activity_summary_model.dart`, `activity_summary_repo.dart` | +| `activity_feedback/` | Activity feedback | `activity_feedback_repo.dart` + request/response | + +## Analytics + +| Module | Purpose | Key Files | +|---|---|---| +| `analytics_data/` | Local DB & sync | `analytics_data_service.dart`, `analytics_database.dart`, `analytics_sync_controller.dart` | +| `analytics_misc/` | Models & utilities | `construct_use_model.dart`, `constructs_model.dart`, `room_analytics_extension.dart`, `level_up/` | +| `analytics_page/` | Analytics UI | `activity_archive.dart` | +| `analytics_summary/` | Summary views | `level_analytics_details_content.dart` | +| `analytics_practice/` | Practice analytics | `analytics_practice_page.dart` | +| `analytics_details_popup/` | Detail popups | `analytics_details_popup.dart` | +| `analytics_settings/` | Analytics config | settings UI | +| `analytics_downloads/` | Analytics export | download utilities | +| `space_analytics/` | Course-level analytics | `space_analytics.dart` | + +## User & Auth + +| Module | Purpose | Key Files | +|---|---|---| +| `user/` | Profile & settings | `user_controller.dart`, `user_model.dart`, `analytics_profile_model.dart`, `style_settings_repo.dart` | +| `authentication/` | Login/logout | `p_login.dart`, `p_logout.dart` | +| `login/` | Signup flow pages | `pages/` — language selection, course code, signup, find course | +| `subscription/` | RevenueCat | `controllers/subscription_controller.dart`, `pages/`, `repo/` | + +## Courses & Spaces + +| Module | Purpose | Key Files | +|---|---|---| +| `spaces/` | Matrix Spaces extensions | `client_spaces_extension.dart`, `space_navigation_column.dart` | +| `course_creation/` | Browse/join courses | `public_course_preview.dart`, `selected_course_page.dart` | +| `course_plans/` | CMS course data | `courses/`, `course_topics/`, `course_activities/`, `course_locations/`, `course_media/` | +| `course_settings/` | Course config | `course_settings.dart`, `teacher_mode_model.dart` | +| `chat_settings/` | Room bot/language config | `models/bot_options_model.dart`, `utils/bot_client_extension.dart` | +| `chat_list/` | Chat list customization | custom chat list logic | +| `chat/` | In-chat customization | `constants/`, `extensions/`, `utils/`, `widgets/` | +| `join_codes/` | Room code invitations | `join_with_link_page.dart` | + +## Media & I/O + +| Module | Purpose | Key Files | +|---|---|---| +| `speech_to_text/` | STT | `speech_to_text_repo.dart` + models | +| `text_to_speech/` | TTS | `tts_controller.dart`, `text_to_speech_repo.dart` | +| `download/` | Room data export | `download_room_extension.dart`, `download_type_enum.dart` | +| `payload_client/` | CMS API client | `payload_client.dart`, `models/course_plan/` | + +## Misc + +| Module | Purpose | Key Files | +|---|---|---| +| `bot/` | Bot UI & utils | `utils/`, `widgets/` | +| `instructions/` | In-app tutorials | tutorial content | +| `token_info_feedback/` | Token feedback dialog | `token_info_feedback_dialog.dart`, `token_info_feedback_repo.dart` | +| `learning_settings/` | Learning preferences | `settings_learning.dart`, `tool_settings_enum.dart` | diff --git a/.gitignore b/.gitignore index 19794af85..ff817a6d8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ prime keys.json !/public/.env *.env.local_choreo +assets/.env.local_choreo *.env.prod envs.json # libolm package @@ -49,8 +50,7 @@ venv/ .fvm/ .fvmrc -# copilot-instructions.md -.github/copilot-instructions.md + # Web related docs/tailwind.css diff --git a/lib/l10n/intl_ar.arb b/lib/l10n/intl_ar.arb index 2ec0e9c04..2b5f3ca32 100644 --- a/lib/l10n/intl_ar.arb +++ b/lib/l10n/intl_ar.arb @@ -1,6 +1,6 @@ { "@@locale": "ar", - "@@last_modified": "2026-02-09 11:09:32.801033", + "@@last_modified": "2026-02-09 15:31:53.731729", "about": "حول", "@about": { "type": "String", @@ -11231,5 +11231,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "قواعد اللغة", + "spanTypeWordChoice": "اختيار الكلمات", + "spanTypeSpelling": "التهجئة", + "spanTypePunctuation": "علامات الترقيم", + "spanTypeStyle": "الأسلوب", + "spanTypeFluency": "الطلاقة", + "spanTypeAccents": "اللكنات", + "spanTypeCapitalization": "حروف الجر", + "spanTypeCorrection": "تصحيح", + "spanFeedbackTitle": "الإبلاغ عن مشكلة تصحيح", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_be.arb b/lib/l10n/intl_be.arb index 2754a34fb..6e79bd96a 100644 --- a/lib/l10n/intl_be.arb +++ b/lib/l10n/intl_be.arb @@ -1908,7 +1908,7 @@ "playWithAI": "Пакуль гуляйце з ШІ", "courseStartDesc": "Pangea Bot гатовы да працы ў любы час!\n\n...але навучанне лепш з сябрамі!", "@@locale": "be", - "@@last_modified": "2026-02-09 11:09:23.199652", + "@@last_modified": "2026-02-09 15:31:34.877959", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12113,5 +12113,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Граматыка", + "spanTypeWordChoice": "Выбар слоў", + "spanTypeSpelling": "Арфаграфія", + "spanTypePunctuation": "Пунктуацыя", + "spanTypeStyle": "Стыль", + "spanTypeFluency": "Флюентнасць", + "spanTypeAccents": "Акцэнты", + "spanTypeCapitalization": "Капіталізацыя", + "spanTypeCorrection": "Карэкцыя", + "spanFeedbackTitle": "Паведаміць пра праблему з карэкцыяй", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_bn.arb b/lib/l10n/intl_bn.arb index 61e5ab945..d941e4704 100644 --- a/lib/l10n/intl_bn.arb +++ b/lib/l10n/intl_bn.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:46.816075", + "@@last_modified": "2026-02-09 15:32:17.400141", "about": "সম্পর্কে", "@about": { "type": "String", @@ -12118,5 +12118,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "ব্যাকরণ", + "spanTypeWordChoice": "শব্দ নির্বাচন", + "spanTypeSpelling": "বানান", + "spanTypePunctuation": "বিরাম চিহ্ন", + "spanTypeStyle": "শৈলী", + "spanTypeFluency": "প্রবাহ", + "spanTypeAccents": "উচ্চারণ", + "spanTypeCapitalization": "বড় হাতের অক্ষর", + "spanTypeCorrection": "সংশোধন", + "spanFeedbackTitle": "সংশোধন সমস্যা রিপোর্ট করুন", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_bo.arb b/lib/l10n/intl_bo.arb index 4b558844a..d44cbfa2b 100644 --- a/lib/l10n/intl_bo.arb +++ b/lib/l10n/intl_bo.arb @@ -4276,7 +4276,7 @@ "joinPublicTrip": "མི་ཚེས་ལ་ལོག་འབད།", "startOwnTrip": "ངེད་རང་གི་ལོག་ལ་སྦྱོར་བཅོས།", "@@locale": "bo", - "@@last_modified": "2026-02-09 11:09:44.069009", + "@@last_modified": "2026-02-09 15:32:12.071200", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -10768,5 +10768,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Izbor reči", + "spanTypeSpelling": "Pravopis", + "spanTypePunctuation": "Interpunkcija", + "spanTypeStyle": "Stil", + "spanTypeFluency": "Tečnost", + "spanTypeAccents": "Akcenti", + "spanTypeCapitalization": "Velika slova", + "spanTypeCorrection": "Ispravka", + "spanFeedbackTitle": "Prijavi problem sa ispravkom", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index 9670f68b1..c06caf15f 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:24.612806", + "@@last_modified": "2026-02-09 15:31:37.024931", "about": "Quant a", "@about": { "type": "String", @@ -11038,5 +11038,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramàtica", + "spanTypeWordChoice": "Elecció de paraules", + "spanTypeSpelling": "Ortografia", + "spanTypePunctuation": "Puntuació", + "spanTypeStyle": "Estil", + "spanTypeFluency": "Fluïdesa", + "spanTypeAccents": "Accents", + "spanTypeCapitalization": "Majúscules", + "spanTypeCorrection": "Correcció", + "spanFeedbackTitle": "Informar d'un problema de correcció", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 67c47b042..8e963cf26 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -1,6 +1,6 @@ { "@@locale": "cs", - "@@last_modified": "2026-02-09 11:09:20.002945", + "@@last_modified": "2026-02-09 15:31:28.643814", "about": "O aplikaci", "@about": { "type": "String", @@ -11621,5 +11621,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Výběr slov", + "spanTypeSpelling": "Pravopis", + "spanTypePunctuation": "Interpunkce", + "spanTypeStyle": "Styl", + "spanTypeFluency": "Plynulost", + "spanTypeAccents": "Přízvuky", + "spanTypeCapitalization": "Velká písmena", + "spanTypeCorrection": "Oprava", + "spanFeedbackTitle": "Nahlásit problém s opravou", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_da.arb b/lib/l10n/intl_da.arb index c0c309d49..edd3ab152 100644 --- a/lib/l10n/intl_da.arb +++ b/lib/l10n/intl_da.arb @@ -1927,7 +1927,7 @@ "playWithAI": "Leg med AI for nu", "courseStartDesc": "Pangea Bot er klar til at starte når som helst!\n\n...men læring er bedre med venner!", "@@locale": "da", - "@@last_modified": "2026-02-09 11:08:45.510364", + "@@last_modified": "2026-02-09 15:30:41.802679", "@aboutHomeserver": { "type": "String", "placeholders": { @@ -12075,5 +12075,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Grammatik", + "spanTypeWordChoice": "Ordvalg", + "spanTypeSpelling": "Stavning", + "spanTypePunctuation": "Interpunktion", + "spanTypeStyle": "Stil", + "spanTypeFluency": "Flydende", + "spanTypeAccents": "Accenter", + "spanTypeCapitalization": "Versalisering", + "spanTypeCorrection": "Korrektur", + "spanFeedbackTitle": "Rapporter korrektion problem", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 32b58e7cf..009a38d51 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1,6 +1,6 @@ { "@@locale": "de", - "@@last_modified": "2026-02-09 11:09:13.137048", + "@@last_modified": "2026-02-09 15:31:16.861620", "alwaysUse24HourFormat": "true", "@alwaysUse24HourFormat": { "description": "Set to true to always display time of day in 24 hour format." @@ -11021,5 +11021,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Grammatik", + "spanTypeWordChoice": "Wortwahl", + "spanTypeSpelling": "Rechtschreibung", + "spanTypePunctuation": "Interpunktion", + "spanTypeStyle": "Stil", + "spanTypeFluency": "Flüssigkeit", + "spanTypeAccents": "Akzente", + "spanTypeCapitalization": "Großschreibung", + "spanTypeCorrection": "Korrektur", + "spanFeedbackTitle": "Korrekturproblem melden", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_el.arb b/lib/l10n/intl_el.arb index 4ea104e49..fe8f0e20e 100644 --- a/lib/l10n/intl_el.arb +++ b/lib/l10n/intl_el.arb @@ -4453,7 +4453,7 @@ "playWithAI": "Παίξτε με την Τεχνητή Νοημοσύνη προς το παρόν", "courseStartDesc": "Ο Pangea Bot είναι έτοιμος να ξεκινήσει οποιαδήποτε στιγμή!\n\n...αλλά η μάθηση είναι καλύτερη με φίλους!", "@@locale": "el", - "@@last_modified": "2026-02-09 11:09:52.785391", + "@@last_modified": "2026-02-09 15:32:27.325286", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12072,5 +12072,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Γραμματική", + "spanTypeWordChoice": "Επιλογή Λέξεων", + "spanTypeSpelling": "Ορθογραφία", + "spanTypePunctuation": "Σημεία στίξης", + "spanTypeStyle": "Στυλ", + "spanTypeFluency": "Ροή", + "spanTypeAccents": "Τονισμοί", + "spanTypeCapitalization": "Κεφαλαία", + "spanTypeCorrection": "Διόρθωση", + "spanFeedbackTitle": "Αναφορά προβλήματος διόρθωσης", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 433c92433..0940c23c6 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5072,6 +5072,16 @@ "autoIGCToolName": "Enable writing assistance", "autoIGCToolDescription": "Automatically run Pangea Chat tools to correct sent messages to target language.", "emptyAudioError": "Recording failed. Please check your audio permissions and try again.", + "spanTypeGrammar": "Grammar", + "spanTypeWordChoice": "Word Choice", + "spanTypeSpelling": "Spelling", + "spanTypePunctuation": "Punctuation", + "spanTypeStyle": "Style", + "spanTypeFluency": "Fluency", + "spanTypeAccents": "Accents", + "spanTypeCapitalization": "Capitalization", + "spanTypeCorrection": "Correction", + "spanFeedbackTitle": "Report correction issue", "selectAllWords": "Select all the words you hear in the audio", "aboutMeHint": "About me", "changeEmail": "Change email", diff --git a/lib/l10n/intl_eo.arb b/lib/l10n/intl_eo.arb index 946d9ffc6..670765db1 100644 --- a/lib/l10n/intl_eo.arb +++ b/lib/l10n/intl_eo.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:57.785827", + "@@last_modified": "2026-02-09 15:32:34.842172", "about": "Prio", "@about": { "type": "String", @@ -12103,5 +12103,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatiko", + "spanTypeWordChoice": "Vortelekto", + "spanTypeSpelling": "Ortografio", + "spanTypePunctuation": "Punkto", + "spanTypeStyle": "Stilo", + "spanTypeFluency": "Flueco", + "spanTypeAccents": "Akcentoj", + "spanTypeCapitalization": "Kapitaligo", + "spanTypeCorrection": "Korektado", + "spanFeedbackTitle": "Raporti korektadon", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 5040071ef..f9f00d551 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1,6 +1,6 @@ { "@@locale": "es", - "@@last_modified": "2026-02-09 11:08:40.981454", + "@@last_modified": "2026-02-09 15:30:33.438936", "about": "Acerca de", "@about": { "type": "String", @@ -8256,5 +8256,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramática", + "spanTypeWordChoice": "Elección de palabras", + "spanTypeSpelling": "Ortografía", + "spanTypePunctuation": "Puntuación", + "spanTypeStyle": "Estilo", + "spanTypeFluency": "Fluidez", + "spanTypeAccents": "Acentos", + "spanTypeCapitalization": "Capitalización", + "spanTypeCorrection": "Corrección", + "spanFeedbackTitle": "Informar problema de corrección", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_et.arb b/lib/l10n/intl_et.arb index c621e5a6d..8d1fd2966 100644 --- a/lib/l10n/intl_et.arb +++ b/lib/l10n/intl_et.arb @@ -1,6 +1,6 @@ { "@@locale": "et", - "@@last_modified": "2026-02-09 11:09:05.357413", + "@@last_modified": "2026-02-09 15:31:13.687328", "about": "Rakenduse teave", "@about": { "type": "String", @@ -11285,5 +11285,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Grammatika", + "spanTypeWordChoice": "Sõnavalik", + "spanTypeSpelling": "Õigekiri", + "spanTypePunctuation": "Interpunktsioon", + "spanTypeStyle": "Stiil", + "spanTypeFluency": "Sujuvus", + "spanTypeAccents": "Aksendid", + "spanTypeCapitalization": "Suurtähed", + "spanTypeCorrection": "Parandus", + "spanFeedbackTitle": "Teata paranduse probleemist", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_eu.arb b/lib/l10n/intl_eu.arb index 568a778ed..bdec034ad 100644 --- a/lib/l10n/intl_eu.arb +++ b/lib/l10n/intl_eu.arb @@ -1,6 +1,6 @@ { "@@locale": "eu", - "@@last_modified": "2026-02-09 11:09:01.052613", + "@@last_modified": "2026-02-09 15:31:08.773071", "about": "Honi buruz", "@about": { "type": "String", @@ -11014,5 +11014,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Hitz Aukera", + "spanTypeSpelling": "Ortografia", + "spanTypePunctuation": "Puntuazioa", + "spanTypeStyle": "Estiloa", + "spanTypeFluency": "Fluentzia", + "spanTypeAccents": "Azentuak", + "spanTypeCapitalization": "Kapitalizazioa", + "spanTypeCorrection": "Zuzenketa", + "spanFeedbackTitle": "Zuzenketa arazoa txostenatu", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fa.arb b/lib/l10n/intl_fa.arb index 75cd90f6d..f83c4aad3 100644 --- a/lib/l10n/intl_fa.arb +++ b/lib/l10n/intl_fa.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:48.180617", + "@@last_modified": "2026-02-09 15:32:19.749220", "repeatPassword": "تکرار رمزعبور", "@repeatPassword": {}, "about": "درباره", @@ -11746,5 +11746,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "قواعد", + "spanTypeWordChoice": "انتخاب واژه", + "spanTypeSpelling": "هجی", + "spanTypePunctuation": "نقطه‌گذاری", + "spanTypeStyle": "سبک", + "spanTypeFluency": "روانی", + "spanTypeAccents": "لهجه‌ها", + "spanTypeCapitalization": "حروف بزرگ", + "spanTypeCorrection": "تصحیح", + "spanFeedbackTitle": "گزارش مشکل تصحیح", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fi.arb b/lib/l10n/intl_fi.arb index 6d16ae0bf..18002cb55 100644 --- a/lib/l10n/intl_fi.arb +++ b/lib/l10n/intl_fi.arb @@ -4006,7 +4006,7 @@ "playWithAI": "Leiki tekoälyn kanssa nyt", "courseStartDesc": "Pangea Bot on valmis milloin tahansa!\n\n...mutta oppiminen on parempaa ystävien kanssa!", "@@locale": "fi", - "@@last_modified": "2026-02-09 11:08:44.050102", + "@@last_modified": "2026-02-09 15:30:38.959801", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11637,5 +11637,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Kielioppi", + "spanTypeWordChoice": "Sananvalinta", + "spanTypeSpelling": "Oikeinkirjoitus", + "spanTypePunctuation": "Välihuomautukset", + "spanTypeStyle": "Tyyli", + "spanTypeFluency": "Sujuvuus", + "spanTypeAccents": "Aksentit", + "spanTypeCapitalization": "Isot kirjaimet", + "spanTypeCorrection": "Korjaus", + "spanFeedbackTitle": "Ilmoita korjausongelmasta", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fil.arb b/lib/l10n/intl_fil.arb index 14e084989..2a6022855 100644 --- a/lib/l10n/intl_fil.arb +++ b/lib/l10n/intl_fil.arb @@ -2784,7 +2784,7 @@ "selectAll": "Piliin lahat", "deselectAll": "Huwag piliin lahat", "@@locale": "fil", - "@@last_modified": "2026-02-09 11:09:29.936599", + "@@last_modified": "2026-02-09 15:31:47.672143", "@setCustomPermissionLevel": { "type": "String", "placeholders": {} @@ -11990,5 +11990,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Balarila", + "spanTypeWordChoice": "Pagpili ng Salita", + "spanTypeSpelling": "Pagbaybay", + "spanTypePunctuation": "Bantas", + "spanTypeStyle": "Estilo", + "spanTypeFluency": "Kadaluyan", + "spanTypeAccents": "Mga Tono", + "spanTypeCapitalization": "Pagkakapital", + "spanTypeCorrection": "Pagwawasto", + "spanFeedbackTitle": "Iulat ang isyu sa pagwawasto", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index ba626d21f..953cb7541 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1,6 +1,6 @@ { "@@locale": "fr", - "@@last_modified": "2026-02-09 11:10:07.003221", + "@@last_modified": "2026-02-09 15:32:46.273653", "about": "À propos", "@about": { "type": "String", @@ -11338,5 +11338,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Grammaire", + "spanTypeWordChoice": "Choix des mots", + "spanTypeSpelling": "Orthographe", + "spanTypePunctuation": "Ponctuation", + "spanTypeStyle": "Style", + "spanTypeFluency": "Fluidité", + "spanTypeAccents": "Accents", + "spanTypeCapitalization": "Capitalisation", + "spanTypeCorrection": "Correction", + "spanFeedbackTitle": "Signaler un problème de correction", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ga.arb b/lib/l10n/intl_ga.arb index bdc067a78..0f52fbca2 100644 --- a/lib/l10n/intl_ga.arb +++ b/lib/l10n/intl_ga.arb @@ -4514,7 +4514,7 @@ "playWithAI": "Imir le AI faoi láthair", "courseStartDesc": "Tá Bot Pangea réidh chun dul am ar bith!\n\n...ach is fearr foghlaim le cairde!", "@@locale": "ga", - "@@last_modified": "2026-02-09 11:10:05.570216", + "@@last_modified": "2026-02-09 15:32:44.231605", "@customReaction": { "type": "String", "placeholders": {} @@ -11012,5 +11012,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramadach", + "spanTypeWordChoice": "Rogha Focal", + "spanTypeSpelling": "Litriú", + "spanTypePunctuation": "Póntú", + "spanTypeStyle": "Stíl", + "spanTypeFluency": "Sreabhadh", + "spanTypeAccents": "Guthanna", + "spanTypeCapitalization": "Caipitleadh", + "spanTypeCorrection": "Córas", + "spanFeedbackTitle": "Tuairisc a dhéanamh ar fhadhb le ceartú", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_gl.arb b/lib/l10n/intl_gl.arb index f598911a2..791b54790 100644 --- a/lib/l10n/intl_gl.arb +++ b/lib/l10n/intl_gl.arb @@ -1,6 +1,6 @@ { "@@locale": "gl", - "@@last_modified": "2026-02-09 11:08:42.508158", + "@@last_modified": "2026-02-09 15:30:36.091824", "about": "Acerca de", "@about": { "type": "String", @@ -11011,5 +11011,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramática", + "spanTypeWordChoice": "Escolma de Palabras", + "spanTypeSpelling": "Ortografía", + "spanTypePunctuation": "Pontuación", + "spanTypeStyle": "Estilo", + "spanTypeFluency": "Fluidez", + "spanTypeAccents": "Acentos", + "spanTypeCapitalization": "Capitalización", + "spanTypeCorrection": "Corrección", + "spanFeedbackTitle": "Informar de problemas de corrección", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_he.arb b/lib/l10n/intl_he.arb index c385f170e..20c6d0953 100644 --- a/lib/l10n/intl_he.arb +++ b/lib/l10n/intl_he.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:08:56.858820", + "@@last_modified": "2026-02-09 15:31:01.183623", "about": "אודות", "@about": { "type": "String", @@ -12063,5 +12063,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "דקדוק", + "spanTypeWordChoice": "בחירת מילים", + "spanTypeSpelling": "איות", + "spanTypePunctuation": "פיסוק", + "spanTypeStyle": "סגנון", + "spanTypeFluency": "שפה רהוטה", + "spanTypeAccents": "הטעמה", + "spanTypeCapitalization": "הגדלת אותיות", + "spanTypeCorrection": "תיקון", + "spanFeedbackTitle": "דווח על בעיית תיקון", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_hi.arb b/lib/l10n/intl_hi.arb index 301f9fe14..2c51d432c 100644 --- a/lib/l10n/intl_hi.arb +++ b/lib/l10n/intl_hi.arb @@ -4480,7 +4480,7 @@ "playWithAI": "अभी के लिए एआई के साथ खेलें", "courseStartDesc": "पैंजिया बॉट कभी भी जाने के लिए तैयार है!\n\n...लेकिन दोस्तों के साथ सीखना बेहतर है!", "@@locale": "hi", - "@@last_modified": "2026-02-09 11:09:56.145817", + "@@last_modified": "2026-02-09 15:32:32.199640", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12099,5 +12099,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "व्याकरण", + "spanTypeWordChoice": "शब्द चयन", + "spanTypeSpelling": "वर्तनी", + "spanTypePunctuation": "विराम चिह्न", + "spanTypeStyle": "शैली", + "spanTypeFluency": "धाराप्रवाहता", + "spanTypeAccents": "उच्चारण", + "spanTypeCapitalization": "बड़े अक्षर", + "spanTypeCorrection": "सुधार", + "spanFeedbackTitle": "सुधार समस्या की रिपोर्ट करें", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_hr.arb b/lib/l10n/intl_hr.arb index 4a1ad631d..3bba7943d 100644 --- a/lib/l10n/intl_hr.arb +++ b/lib/l10n/intl_hr.arb @@ -1,6 +1,6 @@ { "@@locale": "hr", - "@@last_modified": "2026-02-09 11:08:55.423013", + "@@last_modified": "2026-02-09 15:30:58.744003", "about": "Informacije", "@about": { "type": "String", @@ -11386,5 +11386,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Odabir riječi", + "spanTypeSpelling": "Pravopis", + "spanTypePunctuation": "Interpunkcija", + "spanTypeStyle": "Stil", + "spanTypeFluency": "Tečnost", + "spanTypeAccents": "Naglasci", + "spanTypeCapitalization": "Velika slova", + "spanTypeCorrection": "Ispravak", + "spanFeedbackTitle": "Prijavi problem s ispravkom", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_hu.arb b/lib/l10n/intl_hu.arb index 9ec22c848..e9ada78b8 100644 --- a/lib/l10n/intl_hu.arb +++ b/lib/l10n/intl_hu.arb @@ -1,6 +1,6 @@ { "@@locale": "hu", - "@@last_modified": "2026-02-09 11:08:47.162355", + "@@last_modified": "2026-02-09 15:30:44.809310", "about": "Névjegy", "@about": { "type": "String", @@ -11015,5 +11015,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Nyelvtan", + "spanTypeWordChoice": "Szóválasztás", + "spanTypeSpelling": "Helyesírás", + "spanTypePunctuation": "Írásjelek", + "spanTypeStyle": "Stílus", + "spanTypeFluency": "Folyékonyság", + "spanTypeAccents": "Akcentusok", + "spanTypeCapitalization": "Nagybetűs írás", + "spanTypeCorrection": "Javítás", + "spanFeedbackTitle": "Javítási probléma jelentése", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ia.arb b/lib/l10n/intl_ia.arb index d5ba26248..614a00661 100644 --- a/lib/l10n/intl_ia.arb +++ b/lib/l10n/intl_ia.arb @@ -1955,7 +1955,7 @@ "playWithAI": "Joca con le IA pro ora", "courseStartDesc": "Pangea Bot es preste a comenzar a qualunque momento!\n\n...ma apprender es melior con amicos!", "@@locale": "ia", - "@@last_modified": "2026-02-09 11:08:57.966375", + "@@last_modified": "2026-02-09 15:31:03.556297", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12092,5 +12092,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Escolha de Palavras", + "spanTypeSpelling": "Ortografia", + "spanTypePunctuation": "Pontuação", + "spanTypeStyle": "Estilo", + "spanTypeFluency": "Fluência", + "spanTypeAccents": "Acentos", + "spanTypeCapitalization": "Capitalização", + "spanTypeCorrection": "Correção", + "spanFeedbackTitle": "Relatar problema de correção", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_id.arb b/lib/l10n/intl_id.arb index 8503ecfad..49d95edb0 100644 --- a/lib/l10n/intl_id.arb +++ b/lib/l10n/intl_id.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:08:48.564116", + "@@last_modified": "2026-02-09 15:30:47.056945", "setAsCanonicalAlias": "Atur sebagai alias utama", "@setAsCanonicalAlias": { "type": "String", @@ -11005,5 +11005,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Tata Bahasa", + "spanTypeWordChoice": "Pilihan Kata", + "spanTypeSpelling": "Ejaan", + "spanTypePunctuation": "Tanda Baca", + "spanTypeStyle": "Gaya", + "spanTypeFluency": "Kefasihan", + "spanTypeAccents": "Aksen", + "spanTypeCapitalization": "Kapitalisasi", + "spanTypeCorrection": "Koreksi", + "spanFeedbackTitle": "Laporkan masalah koreksi", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ie.arb b/lib/l10n/intl_ie.arb index 33e01880d..64e46a6fd 100644 --- a/lib/l10n/intl_ie.arb +++ b/lib/l10n/intl_ie.arb @@ -4369,7 +4369,7 @@ "playWithAI": "Joca con AI pro ora", "courseStartDesc": "Pangea Bot es preste a partir a qualunque momento!\n\n...ma apprender es melior con amicos!", "@@locale": "ie", - "@@last_modified": "2026-02-09 11:08:53.872484", + "@@last_modified": "2026-02-09 15:30:55.872849", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11988,5 +11988,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatica", + "spanTypeWordChoice": "Elección de palabras", + "spanTypeSpelling": "Ortografía", + "spanTypePunctuation": "Puntuación", + "spanTypeStyle": "Estilo", + "spanTypeFluency": "Fluidez", + "spanTypeAccents": "Acentos", + "spanTypeCapitalization": "Capitalización", + "spanTypeCorrection": "Corrección", + "spanFeedbackTitle": "Informar problema de corrección", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 4cedeee53..3e3779fd4 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:17.051102", + "@@last_modified": "2026-02-09 15:31:23.943569", "about": "Informazioni", "@about": { "type": "String", @@ -11017,5 +11017,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Grammatica", + "spanTypeWordChoice": "Scelta delle parole", + "spanTypeSpelling": "Ortografia", + "spanTypePunctuation": "Punteggiatura", + "spanTypeStyle": "Stile", + "spanTypeFluency": "Fluidità", + "spanTypeAccents": "Accenti", + "spanTypeCapitalization": "Capitalizzazione", + "spanTypeCorrection": "Correzione", + "spanFeedbackTitle": "Segnala problema di correzione", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ja.arb b/lib/l10n/intl_ja.arb index 5c7d5e600..a3e3a81c8 100644 --- a/lib/l10n/intl_ja.arb +++ b/lib/l10n/intl_ja.arb @@ -1,6 +1,6 @@ { "@@locale": "ja", - "@@last_modified": "2026-02-09 11:09:54.188816", + "@@last_modified": "2026-02-09 15:32:29.891676", "about": "このアプリについて", "@about": { "type": "String", @@ -11804,5 +11804,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "文法", + "spanTypeWordChoice": "単語の選択", + "spanTypeSpelling": "スペル", + "spanTypePunctuation": "句読点", + "spanTypeStyle": "スタイル", + "spanTypeFluency": "流暢さ", + "spanTypeAccents": "アクセント", + "spanTypeCapitalization": "大文字化", + "spanTypeCorrection": "修正", + "spanFeedbackTitle": "修正の問題を報告する", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ka.arb b/lib/l10n/intl_ka.arb index a12c57a10..3f6998afb 100644 --- a/lib/l10n/intl_ka.arb +++ b/lib/l10n/intl_ka.arb @@ -2591,7 +2591,7 @@ "playWithAI": "ამ დროისთვის ითამაშეთ AI-თან", "courseStartDesc": "Pangea Bot მზადაა ნებისმიერ დროს გასასვლელად!\n\n...მაგრამ სწავლა უკეთესია მეგობრებთან ერთად!", "@@locale": "ka", - "@@last_modified": "2026-02-09 11:10:02.292014", + "@@last_modified": "2026-02-09 15:32:39.485983", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12044,5 +12044,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "გრამატიკა", + "spanTypeWordChoice": "სიტყვების არჩევანი", + "spanTypeSpelling": "წერა", + "spanTypePunctuation": "ნიშნის გამოყენება", + "spanTypeStyle": "სტილი", + "spanTypeFluency": "მიმდინარე", + "spanTypeAccents": "აქცენტები", + "spanTypeCapitalization": "დიდი ასოები", + "spanTypeCorrection": "კორექტირება", + "spanFeedbackTitle": "შეტყობინება კორექტირების პრობლემაზე", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ko.arb b/lib/l10n/intl_ko.arb index 79f66f7f7..d59230507 100644 --- a/lib/l10n/intl_ko.arb +++ b/lib/l10n/intl_ko.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:08:39.290513", + "@@last_modified": "2026-02-09 15:30:29.458374", "about": "소개", "@about": { "type": "String", @@ -11122,5 +11122,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "문법", + "spanTypeWordChoice": "단어 선택", + "spanTypeSpelling": "철자", + "spanTypePunctuation": "구두점", + "spanTypeStyle": "스타일", + "spanTypeFluency": "유창성", + "spanTypeAccents": "억양", + "spanTypeCapitalization": "대문자 사용", + "spanTypeCorrection": "수정", + "spanFeedbackTitle": "수정 문제 보고", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_lt.arb b/lib/l10n/intl_lt.arb index 17df2b9d3..ab65c2762 100644 --- a/lib/l10n/intl_lt.arb +++ b/lib/l10n/intl_lt.arb @@ -3858,7 +3858,7 @@ "playWithAI": "Žaiskite su dirbtiniu intelektu dabar", "courseStartDesc": "Pangea botas pasiruošęs bet kada pradėti!\n\n...bet mokymasis yra geresnis su draugais!", "@@locale": "lt", - "@@last_modified": "2026-02-09 11:09:38.541694", + "@@last_modified": "2026-02-09 15:32:01.402371", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11819,5 +11819,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Žodžių pasirinkimas", + "spanTypeSpelling": "Rašyba", + "spanTypePunctuation": "Skyryba", + "spanTypeStyle": "Stilius", + "spanTypeFluency": "Sklandumas", + "spanTypeAccents": "Akcentai", + "spanTypeCapitalization": "Didžiųjų raidžių naudojimas", + "spanTypeCorrection": "Korekcija", + "spanFeedbackTitle": "Pranešti apie korekcijos problemą", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_lv.arb b/lib/l10n/intl_lv.arb index fd473b660..4c5d77857 100644 --- a/lib/l10n/intl_lv.arb +++ b/lib/l10n/intl_lv.arb @@ -4480,7 +4480,7 @@ "playWithAI": "Tagad spēlējiet ar AI", "courseStartDesc": "Pangea bots ir gatavs jebkurā laikā!\n\n...bet mācīties ir labāk ar draugiem!", "@@locale": "lv", - "@@last_modified": "2026-02-09 11:09:31.557778", + "@@last_modified": "2026-02-09 15:31:50.771177", "analyticsInactiveTitle": "Pieprasījumi neaktīviem lietotājiem nevar tikt nosūtīti", "analyticsInactiveDesc": "Neaktīvi lietotāji, kuri nav pieteikušies kopš šīs funkcijas ieviešanas, neredzēs jūsu pieprasījumu.\n\nPieprasījuma poga parādīsies, kad viņi atgriezīsies. Jūs varat atkārtoti nosūtīt pieprasījumu vēlāk, noklikšķinot uz pieprasījuma pogas viņu vārdā, kad tā būs pieejama.", "accessRequestedTitle": "Pieprasījums piekļūt analītikai", @@ -11000,5 +11000,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Vārdu izvēle", + "spanTypeSpelling": "Ortogrāfija", + "spanTypePunctuation": "Interpunkcija", + "spanTypeStyle": "Stils", + "spanTypeFluency": "Plūdums", + "spanTypeAccents": "Akcenti", + "spanTypeCapitalization": "Lielie burti", + "spanTypeCorrection": "Korekcija", + "spanFeedbackTitle": "Ziņot par korekcijas problēmu", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_nb.arb b/lib/l10n/intl_nb.arb index 33d24b295..d5977516d 100644 --- a/lib/l10n/intl_nb.arb +++ b/lib/l10n/intl_nb.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:21.418237", + "@@last_modified": "2026-02-09 15:31:32.332245", "about": "Om", "@about": { "type": "String", @@ -12107,5 +12107,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Grammatikk", + "spanTypeWordChoice": "Ordvalg", + "spanTypeSpelling": "Staving", + "spanTypePunctuation": "Tegnsetting", + "spanTypeStyle": "Stil", + "spanTypeFluency": "Flyt", + "spanTypeAccents": "Aksenter", + "spanTypeCapitalization": "Store bokstaver", + "spanTypeCorrection": "Korrigering", + "spanFeedbackTitle": "Rapporter korrigeringsproblem", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_nl.arb b/lib/l10n/intl_nl.arb index 8b2e372a2..87b5b670e 100644 --- a/lib/l10n/intl_nl.arb +++ b/lib/l10n/intl_nl.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:42.630335", + "@@last_modified": "2026-02-09 15:32:09.504392", "about": "Over ons", "@about": { "type": "String", @@ -11014,5 +11014,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Grammatica", + "spanTypeWordChoice": "Woordkeuze", + "spanTypeSpelling": "Spelling", + "spanTypePunctuation": "Interpunctie", + "spanTypeStyle": "Stijl", + "spanTypeFluency": "Vloeiendheid", + "spanTypeAccents": "Accenten", + "spanTypeCapitalization": "Hoofdletters", + "spanTypeCorrection": "Correctie", + "spanFeedbackTitle": "Rapporteer correctiefout", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index fb697743b..f9efba83e 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -1,6 +1,6 @@ { "@@locale": "pl", - "@@last_modified": "2026-02-09 11:09:49.851439", + "@@last_modified": "2026-02-09 15:32:22.411163", "about": "O aplikacji", "@about": { "type": "String", @@ -11012,5 +11012,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatyka", + "spanTypeWordChoice": "Wybór słów", + "spanTypeSpelling": "Ortografia", + "spanTypePunctuation": "Interpunkcja", + "spanTypeStyle": "Styl", + "spanTypeFluency": "Płynność", + "spanTypeAccents": "Akcenty", + "spanTypeCapitalization": "Kapitalizacja", + "spanTypeCorrection": "Korekta", + "spanFeedbackTitle": "Zgłoś problem z korektą", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 368846890..5de04b43c 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:03.927804", + "@@last_modified": "2026-02-09 15:31:11.061688", "copiedToClipboard": "Copiada para a área de transferência", "@copiedToClipboard": { "type": "String", @@ -12114,5 +12114,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramática", + "spanTypeWordChoice": "Escolha de Palavras", + "spanTypeSpelling": "Ortografia", + "spanTypePunctuation": "Pontuação", + "spanTypeStyle": "Estilo", + "spanTypeFluency": "Fluência", + "spanTypeAccents": "Acentos", + "spanTypeCapitalization": "Capitalização", + "spanTypeCorrection": "Correção", + "spanFeedbackTitle": "Relatar problema de correção", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pt_BR.arb b/lib/l10n/intl_pt_BR.arb index 231e186b8..ca5814119 100644 --- a/lib/l10n/intl_pt_BR.arb +++ b/lib/l10n/intl_pt_BR.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:08:59.417144", + "@@last_modified": "2026-02-09 15:31:06.403516", "about": "Sobre", "@about": { "type": "String", @@ -11372,5 +11372,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramática", + "spanTypeWordChoice": "Escolha de Palavras", + "spanTypeSpelling": "Ortografia", + "spanTypePunctuation": "Pontuação", + "spanTypeStyle": "Estilo", + "spanTypeFluency": "Fluência", + "spanTypeAccents": "Acentos", + "spanTypeCapitalization": "Capitalização", + "spanTypeCorrection": "Correção", + "spanFeedbackTitle": "Relatar problema de correção", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pt_PT.arb b/lib/l10n/intl_pt_PT.arb index 77a475da6..6dfb9eda2 100644 --- a/lib/l10n/intl_pt_PT.arb +++ b/lib/l10n/intl_pt_PT.arb @@ -3328,7 +3328,7 @@ "selectAll": "Selecionar tudo", "deselectAll": "Desmarcar tudo", "@@locale": "pt_PT", - "@@last_modified": "2026-02-09 11:09:27.266695", + "@@last_modified": "2026-02-09 15:31:42.773873", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12043,5 +12043,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramática", + "spanTypeWordChoice": "Escolha de Palavras", + "spanTypeSpelling": "Ortografia", + "spanTypePunctuation": "Pontuação", + "spanTypeStyle": "Estilo", + "spanTypeFluency": "Fluência", + "spanTypeAccents": "Acentos", + "spanTypeCapitalization": "Capitalização", + "spanTypeCorrection": "Correção", + "spanFeedbackTitle": "Relatar problema de correção", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ro.arb b/lib/l10n/intl_ro.arb index 318b79fc2..2b4912913 100644 --- a/lib/l10n/intl_ro.arb +++ b/lib/l10n/intl_ro.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:08:50.792922", + "@@last_modified": "2026-02-09 15:30:49.927369", "about": "Despre", "@about": { "type": "String", @@ -11749,5 +11749,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatica", + "spanTypeWordChoice": "Alegerea cuvintelor", + "spanTypeSpelling": "Ortografie", + "spanTypePunctuation": "Punctuație", + "spanTypeStyle": "Stil", + "spanTypeFluency": "Fluență", + "spanTypeAccents": "Accente", + "spanTypeCapitalization": "Capitalizare", + "spanTypeCorrection": "Corectare", + "spanFeedbackTitle": "Raportează problema de corectare", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 75a4b00f1..da55bcfbc 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -1,6 +1,6 @@ { "@@locale": "ru", - "@@last_modified": "2026-02-09 11:09:59.118469", + "@@last_modified": "2026-02-09 15:32:37.241817", "about": "О проекте", "@about": { "type": "String", @@ -11122,5 +11122,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Грамматика", + "spanTypeWordChoice": "Выбор слов", + "spanTypeSpelling": "Орфография", + "spanTypePunctuation": "Пунктуация", + "spanTypeStyle": "Стиль", + "spanTypeFluency": "Связность", + "spanTypeAccents": "Акценты", + "spanTypeCapitalization": "Капитализация", + "spanTypeCorrection": "Коррекция", + "spanFeedbackTitle": "Сообщить о проблеме с коррекцией", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sk.arb b/lib/l10n/intl_sk.arb index e3b50a52c..42aacdf9a 100644 --- a/lib/l10n/intl_sk.arb +++ b/lib/l10n/intl_sk.arb @@ -1,6 +1,6 @@ { "@@locale": "sk", - "@@last_modified": "2026-02-09 11:08:52.351401", + "@@last_modified": "2026-02-09 15:30:52.656999", "about": "O aplikácii", "@about": { "type": "String", @@ -12098,5 +12098,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Výber slov", + "spanTypeSpelling": "Pravopis", + "spanTypePunctuation": "Interpunkcia", + "spanTypeStyle": "Štýl", + "spanTypeFluency": "Plynulosť", + "spanTypeAccents": "Prízvuky", + "spanTypeCapitalization": "Veľké písmená", + "spanTypeCorrection": "Oprava", + "spanFeedbackTitle": "Nahlásiť problém s opravou", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sl.arb b/lib/l10n/intl_sl.arb index c62e362d3..3e3ea4c3b 100644 --- a/lib/l10n/intl_sl.arb +++ b/lib/l10n/intl_sl.arb @@ -2461,7 +2461,7 @@ "playWithAI": "Za zdaj igrajte z AI-jem", "courseStartDesc": "Pangea Bot je pripravljen kadarkoli!\n\n...ampak je bolje učiti se s prijatelji!", "@@locale": "sl", - "@@last_modified": "2026-02-09 11:09:14.553223", + "@@last_modified": "2026-02-09 15:31:19.119268", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12095,5 +12095,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Izbira besed", + "spanTypeSpelling": "Pravopis", + "spanTypePunctuation": "Interpunkcija", + "spanTypeStyle": "Slog", + "spanTypeFluency": "Sposobnost", + "spanTypeAccents": "Naglaski", + "spanTypeCapitalization": "Velike začetnice", + "spanTypeCorrection": "Popravek", + "spanFeedbackTitle": "Poročilo o težavi s popravkom", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sr.arb b/lib/l10n/intl_sr.arb index 47cc65f3a..a05c0df09 100644 --- a/lib/l10n/intl_sr.arb +++ b/lib/l10n/intl_sr.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:10:03.632765", + "@@last_modified": "2026-02-09 15:32:41.761528", "about": "О програму", "@about": { "type": "String", @@ -12116,5 +12116,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Izbor reči", + "spanTypeSpelling": "Pravopis", + "spanTypePunctuation": "Interpunkcija", + "spanTypeStyle": "Stil", + "spanTypeFluency": "Tečnost", + "spanTypeAccents": "Akcenti", + "spanTypeCapitalization": "Velika slova", + "spanTypeCorrection": "Ispravka", + "spanFeedbackTitle": "Prijavi problem sa ispravkom", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sv.arb b/lib/l10n/intl_sv.arb index 787d57133..7c0c741be 100644 --- a/lib/l10n/intl_sv.arb +++ b/lib/l10n/intl_sv.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:51.437329", + "@@last_modified": "2026-02-09 15:32:24.746342", "about": "Om", "@about": { "type": "String", @@ -11492,5 +11492,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Grammatik", + "spanTypeWordChoice": "Orval", + "spanTypeSpelling": "Stavning", + "spanTypePunctuation": "Interpunktion", + "spanTypeStyle": "Stil", + "spanTypeFluency": "Flyt", + "spanTypeAccents": "Accenter", + "spanTypeCapitalization": "Versalisering", + "spanTypeCorrection": "Korrigering", + "spanFeedbackTitle": "Rapportera korrigeringsproblem", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ta.arb b/lib/l10n/intl_ta.arb index 77850c249..b269b5882 100644 --- a/lib/l10n/intl_ta.arb +++ b/lib/l10n/intl_ta.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:41.597668", + "@@last_modified": "2026-02-09 15:32:06.841503", "acceptedTheInvitation": "👍 {username} அழைப்பை ஏற்றுக்கொண்டது", "@acceptedTheInvitation": { "type": "String", @@ -11238,5 +11238,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "இயல்பியல்", + "spanTypeWordChoice": "சொல் தேர்வு", + "spanTypeSpelling": "எழுத்துப்பிழை", + "spanTypePunctuation": "இணைச்சொல்", + "spanTypeStyle": "அழகு", + "spanTypeFluency": "தரிசனம்", + "spanTypeAccents": "உயர்த்தல்கள்", + "spanTypeCapitalization": "முதலெழுத்து", + "spanTypeCorrection": "திருத்தம்", + "spanFeedbackTitle": "திருத்தப் பிரச்சினையைப் புகாரளிக்கவும்", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_te.arb b/lib/l10n/intl_te.arb index 544df2529..f31433634 100644 --- a/lib/l10n/intl_te.arb +++ b/lib/l10n/intl_te.arb @@ -1917,7 +1917,7 @@ "playWithAI": "ఇప్పుడే AI తో ఆడండి", "courseStartDesc": "పాంజియా బాట్ ఎప్పుడైనా సిద్ధంగా ఉంటుంది!\n\n...కానీ స్నేహితులతో నేర్చుకోవడం మెరుగైనది!", "@@locale": "te", - "@@last_modified": "2026-02-09 11:09:36.591690", + "@@last_modified": "2026-02-09 15:31:58.761810", "@setCustomPermissionLevel": { "type": "String", "placeholders": {} @@ -12103,5 +12103,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "వ్యాకరణం", + "spanTypeWordChoice": "పద ఎంపిక", + "spanTypeSpelling": "అక్షరరూపం", + "spanTypePunctuation": "పంక్తి చిహ్నాలు", + "spanTypeStyle": "శైలి", + "spanTypeFluency": "ప్రవాహం", + "spanTypeAccents": "ఉచ్చారణలు", + "spanTypeCapitalization": "పెద్ద అక్షరాలు", + "spanTypeCorrection": "సరిదిద్దు", + "spanFeedbackTitle": "సరిదిద్దు సమస్యను నివేదించండి", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_th.arb b/lib/l10n/intl_th.arb index e50e12ac3..f1b74bf55 100644 --- a/lib/l10n/intl_th.arb +++ b/lib/l10n/intl_th.arb @@ -4453,7 +4453,7 @@ "playWithAI": "เล่นกับ AI ชั่วคราว", "courseStartDesc": "Pangea Bot พร้อมที่จะเริ่มต้นได้ทุกเมื่อ!\n\n...แต่การเรียนรู้ดีกว่ากับเพื่อน!", "@@locale": "th", - "@@last_modified": "2026-02-09 11:09:25.941768", + "@@last_modified": "2026-02-09 15:31:39.902769", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12072,5 +12072,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "ไวยากรณ์", + "spanTypeWordChoice": "การเลือกคำ", + "spanTypeSpelling": "การสะกด", + "spanTypePunctuation": "เครื่องหมายวรรคตอน", + "spanTypeStyle": "สไตล์", + "spanTypeFluency": "ความคล่องแคล่ว", + "spanTypeAccents": "สำเนียง", + "spanTypeCapitalization": "การใช้ตัวพิมพ์ใหญ่", + "spanTypeCorrection": "การแก้ไข", + "spanFeedbackTitle": "รายงานปัญหาการแก้ไข", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index e90077aae..1ba922127 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -1,6 +1,6 @@ { "@@locale": "tr", - "@@last_modified": "2026-02-09 11:09:34.545567", + "@@last_modified": "2026-02-09 15:31:56.191986", "about": "Hakkında", "@about": { "type": "String", @@ -11236,5 +11236,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Dilbilgisi", + "spanTypeWordChoice": "Kelime Seçimi", + "spanTypeSpelling": "Yazım", + "spanTypePunctuation": "Noktalama", + "spanTypeStyle": "Üslup", + "spanTypeFluency": "Akıcılık", + "spanTypeAccents": "Aksanlar", + "spanTypeCapitalization": "Büyük Harf Kullanımı", + "spanTypeCorrection": "Düzeltme", + "spanFeedbackTitle": "Düzeltme sorununu bildir", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_uk.arb b/lib/l10n/intl_uk.arb index e43890fed..ea9946ec9 100644 --- a/lib/l10n/intl_uk.arb +++ b/lib/l10n/intl_uk.arb @@ -1,6 +1,6 @@ { "@@locale": "uk", - "@@last_modified": "2026-02-09 11:09:18.460030", + "@@last_modified": "2026-02-09 15:31:25.978266", "about": "Про застосунок", "@about": { "type": "String", @@ -11008,5 +11008,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Граматика", + "spanTypeWordChoice": "Вибір слів", + "spanTypeSpelling": "Орфографія", + "spanTypePunctuation": "Пунктуація", + "spanTypeStyle": "Стиль", + "spanTypeFluency": "Вільність", + "spanTypeAccents": "Акценти", + "spanTypeCapitalization": "Великі літери", + "spanTypeCorrection": "Виправлення", + "spanFeedbackTitle": "Повідомити про проблему з виправленням", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_vi.arb b/lib/l10n/intl_vi.arb index 97dc307cd..f11eeaae4 100644 --- a/lib/l10n/intl_vi.arb +++ b/lib/l10n/intl_vi.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:39.961686", + "@@last_modified": "2026-02-09 15:32:04.137171", "about": "Giới thiệu", "@about": { "type": "String", @@ -6592,5 +6592,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Ngữ pháp", + "spanTypeWordChoice": "Lựa chọn từ", + "spanTypeSpelling": "Chính tả", + "spanTypePunctuation": "Dấu câu", + "spanTypeStyle": "Phong cách", + "spanTypeFluency": "Lưu loát", + "spanTypeAccents": "Giọng điệu", + "spanTypeCapitalization": "Chữ hoa", + "spanTypeCorrection": "Sửa lỗi", + "spanFeedbackTitle": "Báo cáo vấn đề sửa lỗi", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_yue.arb b/lib/l10n/intl_yue.arb index dab04d273..5d2d0d348 100644 --- a/lib/l10n/intl_yue.arb +++ b/lib/l10n/intl_yue.arb @@ -1853,7 +1853,7 @@ "selectAll": "全選", "deselectAll": "取消全選", "@@locale": "yue", - "@@last_modified": "2026-02-09 11:09:15.805629", + "@@last_modified": "2026-02-09 15:31:21.578854", "@ignoreUser": { "type": "String", "placeholders": {} @@ -12105,5 +12105,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "文法", + "spanTypeWordChoice": "用詞選擇", + "spanTypeSpelling": "拼寫", + "spanTypePunctuation": "標點符號", + "spanTypeStyle": "風格", + "spanTypeFluency": "流暢度", + "spanTypeAccents": "口音", + "spanTypeCapitalization": "大寫", + "spanTypeCorrection": "更正", + "spanFeedbackTitle": "報告更正問題", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index 0fe08595b..1f2424e65 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -1,6 +1,6 @@ { "@@locale": "zh", - "@@last_modified": "2026-02-09 11:09:45.374826", + "@@last_modified": "2026-02-09 15:32:14.219030", "about": "关于", "@about": { "type": "String", @@ -11005,5 +11005,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "语法", + "spanTypeWordChoice": "用词选择", + "spanTypeSpelling": "拼写", + "spanTypePunctuation": "标点", + "spanTypeStyle": "风格", + "spanTypeFluency": "流利度", + "spanTypeAccents": "口音", + "spanTypeCapitalization": "大写", + "spanTypeCorrection": "更正", + "spanFeedbackTitle": "报告更正问题", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_zh_Hant.arb b/lib/l10n/intl_zh_Hant.arb index e39d78cdc..d40146897 100644 --- a/lib/l10n/intl_zh_Hant.arb +++ b/lib/l10n/intl_zh_Hant.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:28.599644", + "@@last_modified": "2026-02-09 15:31:45.022584", "about": "關於", "@about": { "type": "String", @@ -11012,5 +11012,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "文法", + "spanTypeWordChoice": "用詞選擇", + "spanTypeSpelling": "拼寫", + "spanTypePunctuation": "標點符號", + "spanTypeStyle": "風格", + "spanTypeFluency": "流暢度", + "spanTypeAccents": "重音", + "spanTypeCapitalization": "大寫", + "spanTypeCorrection": "修正", + "spanFeedbackTitle": "報告修正問題", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 86d333ca1..452e2a0a0 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -2240,18 +2240,23 @@ class ChatController extends State choreographer, context, showNextMatch, + (feedback) => onRequestWritingAssistance(feedback: feedback), ); } Future onRequestWritingAssistance({ bool manual = false, bool autosend = false, + String? feedback, }) async { if (shouldShowLanguageMismatchPopupByActivity) { return showLanguageMismatchPopup(manual: manual); } - await choreographer.requestWritingAssistance(manual: manual); + feedback == null + ? await choreographer.requestWritingAssistance(manual: manual) + : await choreographer.rerunWithFeedback(feedback); + if (choreographer.assistanceState == AssistanceStateEnum.fetched) { showNextMatch(); } else if (autosend) { diff --git a/lib/pages/chat/input_bar.dart b/lib/pages/chat/input_bar.dart index 77d0bab05..4aec9b5ca 100644 --- a/lib/pages/chat/input_bar.dart +++ b/lib/pages/chat/input_bar.dart @@ -37,6 +37,7 @@ class InputBar extends StatelessWidget { final PangeaTextController? controller; final Choreographer choreographer; final VoidCallback showNextMatch; + final Future Function(String) onFeedbackSubmitted; // Pangea# final InputDecoration decoration; final ValueChanged? onChanged; @@ -60,6 +61,7 @@ class InputBar extends StatelessWidget { // #Pangea required this.choreographer, required this.showNextMatch, + required this.onFeedbackSubmitted, // Pangea# super.key, }); @@ -426,6 +428,7 @@ class InputBar extends StatelessWidget { choreographer, context, showNextMatch, + onFeedbackSubmitted, ); // rebuild the text field to highlight the newly selected match @@ -468,9 +471,9 @@ class InputBar extends StatelessWidget { focusNode: focusNode, textEditingController: controller, optionsBuilder: getSuggestions, - fieldViewBuilder: (context, __, focusNode, _) => ValueListenableBuilder( - valueListenable: choreographer.itController.open, - builder: (context, _, __) { + fieldViewBuilder: (context, __, focusNode, _) => ListenableBuilder( + listenable: choreographer, + builder: (context, _) { return TextField( controller: controller, focusNode: focusNode, diff --git a/lib/pangea/chat/widgets/pangea_chat_input_row.dart b/lib/pangea/chat/widgets/pangea_chat_input_row.dart index fdff8a030..6c9f08c8a 100644 --- a/lib/pangea/chat/widgets/pangea_chat_input_row.dart +++ b/lib/pangea/chat/widgets/pangea_chat_input_row.dart @@ -211,6 +211,8 @@ class PangeaChatInputRow extends StatelessWidget { onChanged: controller.onInputBarChanged, choreographer: controller.choreographer, showNextMatch: controller.showNextMatch, + onFeedbackSubmitted: (feedback) => controller + .onRequestWritingAssistance(feedback: feedback), ), ), ), diff --git a/lib/pangea/choreographer/choreographer.dart b/lib/pangea/choreographer/choreographer.dart index 169f5edeb..500a70139 100644 --- a/lib/pangea/choreographer/choreographer.dart +++ b/lib/pangea/choreographer/choreographer.dart @@ -284,10 +284,22 @@ class Choreographer extends ChangeNotifier { } _stopLoading(); - if (!igcController.openMatches - .any((match) => match.updatedMatch.isITStart)) { - igcController.fetchAllSpanDetails().catchError((e) => clearMatches(e)); + } + + /// Re-runs IGC with user feedback and updates the UI. + Future rerunWithFeedback(String feedbackText) async { + MatrixState.pAnyState.closeAllOverlays(); + igcController.clearMatches(); + igcController.clearCurrentText(); + + _startLoading(); + final success = await igcController.rerunWithFeedback(feedbackText); + if (success && igcController.openAutomaticMatches.isNotEmpty) { + await igcController.acceptNormalizationMatches(); } + _stopLoading(); + + return success; } Future getMessageContent(String message) async { diff --git a/lib/pangea/choreographer/edit_type_auto_apply.md b/lib/pangea/choreographer/edit_type_auto_apply.md new file mode 100644 index 000000000..f55ac0d0b --- /dev/null +++ b/lib/pangea/choreographer/edit_type_auto_apply.md @@ -0,0 +1,97 @@ +# Edit Type Auto-Apply Planning + +## Current Behavior + +The client currently auto-applies edits (without user interaction) based on a single condition: +- **Normalization errors**: Edits where the correction is the same as the original when normalized (punctuation, spacing, accents removed) + +This is implemented in: +- [span_data_model.dart](igc/span_data_model.dart#L147) - `isNormalizationError()` method +- [igc_controller.dart](igc/igc_controller.dart#L43) - `openAutomaticMatches` getter +- [igc_controller.dart](igc/igc_controller.dart#L227) - `acceptNormalizationMatches()` method + +Current `isNormalizationError()` logic: +```dart +bool isNormalizationError() { + final correctChoice = choices?.firstWhereOrNull((c) => c.isBestCorrection)?.value; + final l2Code = MatrixState.pangeaController.userController.userL2?.langCodeShort; + + return correctChoice != null && + l2Code != null && + normalizeString(correctChoice, l2Code) == normalizeString(errorSpan, l2Code); +} +``` + +The `normalizeString` function (in [text_normalization_util.dart](igc/text_normalization_util.dart)): +- Converts to lowercase +- Removes diacritics (language-specific) +- Replaces hyphens with spaces +- Removes punctuation +- Normalizes whitespace + +## Proposed Change + +Split auto-apply behavior based on **edit type** instead of just normalization matching. + +### Questions to Answer + +1. **What edit types should we distinguish?** + - Punctuation-only edits + - Accent/diacritic-only edits + - Capitalization-only edits + - Spelling errors + - Grammar errors (conjugation, agreement, etc.) + - Word choice / vocabulary suggestions + - Code-switching corrections (L1 word replaced with L2) + +2. **Which edit types should auto-apply?** + - Current: All "normalization" edits (punctuation + accent + case) + - Proposed: Make this configurable by type? + +3. **Where does the edit type come from?** + - Currently from `SpanData.rule` (has `Rule.id`, `Rule.category`, etc.) + - Or from `SpanDataTypeEnum` (grammar, correction, etc.) + - May need choreo/backend to provide explicit type classification + +4. **What user interaction modes exist?** + - Auto-apply (no interaction, edit applied silently) + - Notification (edit applied but user is informed) + - Selection (user must choose from options) + - Full interaction (span card with explanation) + +## Files to Modify + +### Client-side (this repo) +- `igc/span_data_model.dart` - Add edit type classification methods +- `igc/igc_controller.dart` - Update auto-apply logic based on type +- `choreographer.dart` - Handle different interaction modes +- Potentially new enum for edit categories + +### Backend (choreo) +- May need to return explicit edit type/category in response +- See [2-step-choreographer next_steps.md](../../../../../2-step-choreographer/app/handlers/wa/next_steps.md) + +## Current SpanData Structure + +```dart +class SpanData { + final String? message; + final String? shortMessage; + final List? choices; + final int offset; + final int length; + final String fullText; + final SpanDataTypeEnum type; // grammar, correction, etc. + final Rule? rule; // has id, category, description +} +``` + +## Tasks + +- [ ] Define edit type categories/enum +- [ ] Determine classification logic (client-side vs server-side) +- [ ] Design interaction mode mapping (type → mode) +- [ ] Implement type classification in SpanData +- [ ] Update IgcController to use type-based auto-apply +- [ ] Add user preference support (optional) +- [ ] Coordinate with choreo backend if needed diff --git a/lib/pangea/choreographer/igc/SPAN_CARD_REDESIGN_FINALIZED.md b/lib/pangea/choreographer/igc/SPAN_CARD_REDESIGN_FINALIZED.md new file mode 100644 index 000000000..330678eeb --- /dev/null +++ b/lib/pangea/choreographer/igc/SPAN_CARD_REDESIGN_FINALIZED.md @@ -0,0 +1,269 @@ +# Span Card UI Redesign - Finalized Plan + +## Overview + +Redesign the `SpanCard` widget to improve UX and add user feedback capabilities. This document consolidates all decisions from the design Q&A. + +--- + +## New Layout + +### Visual Structure + +``` +┌─────────────────────────────────────────┐ +│ [X] 🤖 [🚩] │ <- Header: Close, BotFace, Flag +├─────────────────────────────────────────┤ +│ Span Type Label │ <- Error category (e.g., "Grammar") +├─────────────────────────────────────────┤ +│ [ Choice 1 ] [ Choice 2 ] [ ... ] │ <- ChoicesArray +├─────────────────────────────────────────┤ +│ "Best choice feedback text..." │ <- Feedback shown on open +├─────────────────────────────────────────┤ +│ [ Ignore ] [ Replace ] │ <- Action buttons +└─────────────────────────────────────────┘ +``` + +### Header Row Details + +Follow `WordZoomWidget` pattern exactly: + +```dart +SizedBox( + height: 40.0, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + color: Theme.of(context).iconTheme.color, + icon: const Icon(Icons.close), + onPressed: widget.showNextMatch, + ), + Flexible( + child: Container( + constraints: const BoxConstraints(minHeight: 40.0), + alignment: Alignment.center, + child: BotFace(width: 40, expression: BotExpression.idle), + ), + ), + IconButton( + color: Theme.of(context).iconTheme.color, + icon: const Icon(Icons.flag_outlined), + onPressed: _onFlagPressed, + ), + ], + ), +), +``` + +--- + +## Color Scheme + +### Underline Colors by Type + +Use `AppConfig.primaryColor` (#8560E0) for IT/auto-apply types. + +| Category | Types | Color | +| ------------- | ------------------------------------------------ | ----------------------------------------- | +| IT/Auto-apply | `itStart`, `punct`, `diacritics`, `spell`, `cap` | `AppConfig.primaryColor.withOpacity(0.7)` | +| Grammar | All `grammarTypes` list | `AppConfig.warning.withOpacity(0.7)` | +| Word Choice | All `wordChoiceTypes` list | `Colors.blue.withOpacity(0.7)` | +| Style/Fluency | `style`, `fluency` | `Colors.teal.withOpacity(0.7)` | +| Other/Unknown | Everything else | `colorScheme.error.withOpacity(0.7)` | + +### Implementation + +Add to `replacement_type_enum.dart`: + +```dart +extension ReplacementTypeEnumColors on ReplacementTypeEnum { + Color underlineColor(BuildContext context) { + if (this == ReplacementTypeEnum.itStart || isAutoApply) { + return AppConfig.primaryColor.withOpacity(0.7); + } + if (isGrammarType) { + return AppConfig.warning.withOpacity(0.7); + } + if (isWordChoiceType) { + return Colors.blue.withOpacity(0.7); + } + switch (this) { + case ReplacementTypeEnum.style: + case ReplacementTypeEnum.fluency: + return Colors.teal.withOpacity(0.7); + default: + return Theme.of(context).colorScheme.error.withOpacity(0.7); + } + } +} +``` + +--- + +## Feedback Flow + +### Flag Button (Explicit Feedback) + +1. User taps 🚩 flag icon +2. Show `FeedbackDialog` for user to enter feedback text +3. Close span card, show spinning IGC indicator +4. Call grammar_v2 endpoint with feedback attached via new `IgcController.rerunWithFeedback()` method +5. Clear existing matches, replace with new response +6. Display new span card if matches exist + +### Ignore Button (Auto-Feedback) + +1. User taps "Ignore" +2. Fire-and-forget call to grammar_v2 with auto-generated feedback: + ``` + "user ignored the correction ({old} -> {new}) without feedback. not sure why" + ``` +3. Don't wait for response, proceed to next match +4. Silent fail on errors (logged server-side for finetuning) + +### Feedback Schema + +Use existing `LLMFeedbackSchema` structure: + +```dart +{ + "feedback": "user's feedback text", + "content": { /* original IGCResponseModel as JSON */ } +} +``` + +### Implementation Details + +1. **Store last response**: Add `IGCResponseModel? _lastResponse` field to `IgcController` +2. **New method**: Add `rerunWithFeedback(String feedbackText)` to `IgcController` +3. **Feedback always included**: No loading state needed - feedback text comes with initial response +4. **No snackbar initially**: Just use spinning IGC indicator (may add UX feedback later if too abrupt) + +--- + +## Span Type Display + +### Display Names + +Add `displayName(context)` method to `ReplacementTypeEnum`: + +| Type Category | Display String | +| ------------- | ---------------- | +| Grammar types | "Grammar" | +| Word choice | "Word Choice" | +| `spell` | "Spelling" | +| `punct` | "Punctuation" | +| `style` | "Style" | +| `fluency` | "Fluency" | +| `diacritics` | "Accents" | +| `cap` | "Capitalization" | +| Other | "Correction" | + +--- + +## Choices Behavior + +### Alts vs Distractors + +- **Alts**: Equally valid alternatives (e.g., "él" vs "ella" when gender ambiguous) +- **Distractors**: Intentionally wrong options to test learner + +These arrive as `SpanChoice` objects in `SpanData.choices`. + +### Selection Behavior + +| Choice Type | On Select | +| ----------- | ----------------------------------- | +| Best | Enable "Replace" button | +| Alt | Treat as accepted, apply that value | +| Distractor | Show "try again" feedback | + +### Single Choice + +If only one choice exists, show it without choice UI chrome. + +--- + +## Button Behaviors + +| Button | Action | +| ------- | -------------------------------------------------- | +| X | Close card, show next match (same as Ignore) | +| Replace | Apply selected choice, close card, show next match | +| Ignore | Auto-feedback (fire-and-forget), show next match | +| Flag | Open feedback dialog, re-run WA with user feedback | + +--- + +## Files to Modify + +| File | Changes | +| ----------------------------- | -------------------------------------------------------- | +| `replacement_type_enum.dart` | Add `underlineColor(context)` and `displayName(context)` | +| `pangea_text_controller.dart` | Update `_underlineColor` to use type-based colors | +| `span_card.dart` | Restructure layout per new design | +| `intl_en.arb` | Add new l10n strings | + +--- + +## Localization Strings + +Add to `intl_en.arb`: + +```json +{ + "spanFeedbackTitle": "Report correction issue", + "spanTypeGrammar": "Grammar", + "spanTypeWordChoice": "Word Choice", + "spanTypeSpelling": "Spelling", + "spanTypePunctuation": "Punctuation", + "spanTypeStyle": "Style", + "spanTypeFluency": "Fluency", + "spanTypeAccents": "Accents", + "spanTypeCapitalization": "Capitalization", + "spanTypeCorrection": "Correction" +} +``` + +**TODO**: Run translation script after adding strings. + +--- + +## Implementation Order + +1. [ ] Update `replacement_type_enum.dart` + - [ ] Add `underlineColor(context)` method + - [ ] Add `displayName(context)` method +2. [ ] Update `pangea_text_controller.dart` + - [ ] Change `_underlineColor` to use type-based colors +3. [ ] Add l10n strings to `intl_en.arb` +4. [ ] Update `igc_controller.dart` + - [ ] Add `IGCResponseModel? _lastResponse` field + - [ ] Store response in `getIGCTextData()` after fetch + - [ ] Add `rerunWithFeedback(String feedbackText)` method + - [ ] Add `sendAutoFeedback(PangeaMatch match)` method (fire-and-forget) +5. [ ] Restructure `span_card.dart` + - [ ] Add header row (X, BotFace, Flag) + - [ ] Add span type display row + - [ ] Show feedback on card open (no loading state needed) + - [ ] Wire up flag button to feedback flow + - [ ] Wire up ignore button to auto-feedback +6. [ ] Test full flow + +--- + +## Testing Considerations + +- Verify underline colors display correctly for each error type +- Test feedback dialog flow end-to-end +- Test auto-feedback on ignore (verify silent fail) +- Ensure span card closes properly after all actions +- Test with various span types to verify type labels +- Test distractor selection shows "try again" +- Test alt selection applies the alt value + +## NEXT STEPS + +- Figure out why feedback isn't displaying +- Considering migrating to using match message field instead of choice feedback diff --git a/lib/pangea/choreographer/igc/SPAN_CARD_REDESIGN_Q_AND_A.md b/lib/pangea/choreographer/igc/SPAN_CARD_REDESIGN_Q_AND_A.md new file mode 100644 index 000000000..6587ea63c --- /dev/null +++ b/lib/pangea/choreographer/igc/SPAN_CARD_REDESIGN_Q_AND_A.md @@ -0,0 +1,574 @@ +# Span Card UI Redesign Plan + +## Overview + +Redesign the `SpanCard` widget to improve UX and add user feedback capabilities. This document outlines the changes needed for the new layout and feedback flow. + +## Decisions Made ✅ + +### 1. Feedback Endpoint Behavior ✅ + +**Decision**: Re-run WA analysis with feedback to get different/better correction + +**Implementation notes**: + +- Use `gpt_5_2` model on regeneration +- Use `prompt_version="verbose"` +- Pull at least 3 varied examples in +- Need choreo-side testing to verify feedback is included in re-run + +### 2. Close Button (X) Behavior ✅ + +**Decision**: Close span card and show next match (same as current "Ignore") + +### 3. Best Choice Feedback Display ✅ + +**Decision**: Show immediately on card open + +> _Pedagogical note: Since the user has already seen their error highlighted and the choices displayed, the feedback explains "why" after they've had a chance to think about it. Hiding it behind a button adds friction without clear benefit._ + +### 4. Span Type Copy Format ✅ + +**Decision**: Use short labels defined in `replacement_type_enum.dart` + +Will add `displayName(context)` method returning l10n strings like: + +- "Grammar" / "Word Choice" / "Spelling" / "Punctuation" / "Style" + +### 5. Color Scheme ✅ + +**Decision**: Use brand colors from `app_config.dart`: + +- **Primary/Purple**: `AppConfig.primaryColor` (#8560E0) - for IT/auto-apply +- **Warning/Orange**: `AppConfig.warning` (rgba 210,124,12) - for grammar errors +- **Error/Red**: Use `colorScheme.error` - for unknown/other +- **Style/Teal**: Keep proposed teal for style/fluency +- **Word Choice**: Keep proposed blue + +### 6. Auto-Feedback on Ignore ✅ + +**Decision**: Auto-submit feedback on ignore (fire-and-forget) + +**Implementation**: + +- Submit async, don't block UI +- Catch errors internally (silent fail) +- Default message: `"user ignored the correction ({old} -> {new}) without feedback. not sure why"` + +### 7. Re-run Feedback Flow UX ✅ + +**Decision**: Close card → show spinning IGC indicator → snackbar with bot face "Got it! Recalculating..." + +- Shows card again when complete if there are more matches +- Uses existing spinning indicator pattern in input bar + +### 8. What if re-run returns no correction? ✅ + +**Decision**: Spinner turns green and it's done (no special message) + +### 9. Choices Layout ✅ + +**Decision**: Include alts/distractors in initial WA call with expanded TSV format + +--- + +## Discussion: Expanded TSV Format for Alts/Distractors + +### Proposed Format + +``` +old new type reason alt_1 alt_2 distractor_1 distractor_2 +``` + +### Your Pros + +1. Include alts when subjective (gender ambiguity, etc.) - user can pick +2. Include distractors when it makes a good learning exercise +3. Single handler = less to finetune, less feedback loops to manage + +### My Analysis on Complexity vs Quality + +**Short answer**: I think it's fine, with some guardrails. + +**Reasoning**: + +1. **Column count is manageable** - 8 columns is still very parseable for the model. The current 4-column format works well; doubling it shouldn't degrade accuracy much. + +2. **Optional columns help** - Making alt/distractor columns optional (empty when not applicable) keeps simple cases simple. The model only populates them when genuinely useful. + +3. **Clear semantics matter most** - As long as the prompt clearly defines: + - `alt_1/alt_2`: Equally valid alternatives (subjective/ambiguous cases) + - `distractor_1/distractor_2`: Plausible-but-wrong options (learning exercise) + + ...the model should handle it fine. + +4. **Finetuning examples will solidify it** - If your training examples show when to use alts vs not, the model will learn the pattern. + +### Potential Risks + +1. **Over-generation of alts** - Model might add alts when not needed. Mitigation: Prompt to say "only include alts if genuinely ambiguous" + +2. **Distractor quality** - Bad distractors could confuse learners. Mitigation: Could validate distractors are grammatically plausible but semantically wrong + +3. **Parsing edge cases** - Tab characters in reasons could break TSV. Already handled? + +### My Recommendation + +Go for it. The benefits (unified handler, better UX for ambiguous cases) outweigh the complexity cost. Just make sure: + +- Clear prompt instructions on when to use each column +- Good training examples showing both populated and empty cases +- Keep distractor generation optional/conservative initially + +--- + +## Follow-up Questions + +### 10. Alts vs Distractors Distinction ✅ + +**Clarified**: + +- **Alts**: All correct options given the context (e.g., "él" vs "ella" when gender unclear) +- **Distractors**: Intentionally wrong options to test the learner + +### 11. What if user picks an alt? ✅ + +**Decision**: Treat as "accepted" - apply that alt as the correction + +### 12. Distractor selection behavior ✅ + +**Decision**: Show "try again" feedback, don't apply the distractor + +### 13. Empty alts/distractors ✅ + +**Decision**: Just show the single `new` choice (no choice UI for single option) + +--- + +## All Questions Resolved ✅ + +No more open questions. Ready for implementation. + +--- + +## Current Implementation + +**File**: [span_card.dart](span_card.dart) + +Current layout (top to bottom): + +1. `ChoicesArray` - answer options +2. `_SpanCardFeedback` - feedback text with lightbulb button +3. `_SpanCardButtons` - Ignore / Replace buttons + +## New Layout + +### Visual Structure + +``` +┌─────────────────────────────────────────┐ +│ [X] 🤖 [🚩] │ <- Row 1: Close, BotFace, Flag +├─────────────────────────────────────────┤ +│ Span Type Copy │ <- Row 2: Error category label +├─────────────────────────────────────────┤ +│ [ Choice 1 ] [ Choice 2 ] [ ... ] │ <- Row 3: ChoicesArray +├─────────────────────────────────────────┤ +│ "Best choice feedback text..." │ <- Row 4: Best choice feedback +├─────────────────────────────────────────┤ +│ [ Ignore ] [ Replace ] │ <- Row 5: Action buttons +└─────────────────────────────────────────┘ +``` + +### Detailed Rows + +1. **Top Row (Header)** + - Left: X button (close overlay) - `IconButton(Icons.close)` + - Center: Bot face - `BotFace(width: 40, expression: BotExpression.idle)` + - Right: Flag button (feedback) - `IconButton(Icons.flag_outlined)` + +2. **Span Type Row** + - Display the error category from `match.updatedMatch.match.type` + - Use `ReplacementTypeEnum.defaultPrompt(context)` for human-readable text + - Consider adding l10n strings for each type's display name + +3. **Choices Row** + - Keep existing `ChoicesArray` widget + - No changes needed here + +4. **Best Choice Feedback Row** + - Display `bestChoice.feedback` text when available + - Show on card open (no button needed) since feedback is now always included + - Fall back to loading state if feedback needs fetching + +5. **Action Buttons Row** + - Keep existing `_SpanCardButtons` widget + - No changes needed here + +## Underline Color by Type + +### Files to Modify + +**File**: [pangea_text_controller.dart](../text_editing/pangea_text_controller.dart) + +Current `_underlineColor` method uses `match.match.rule?.id` to determine color. Change to use `match.match.type` (ReplacementTypeEnum). + +### Proposed Color Mapping + +Add extension method to `ReplacementTypeEnum`: + +```dart +// In replacement_type_enum.dart +extension ReplacementTypeEnumColors on ReplacementTypeEnum { + Color get underlineColor { + if (isAutoApply) { + return Colors.purple.withOpacity(0.7); // punct, diacritics, spell, cap + } + if (isGrammarType) { + return Colors.orange.withOpacity(0.7); // grammar errors + } + if (isWordChoiceType) { + return Colors.blue.withOpacity(0.7); // word choice issues + } + // Higher-level suggestions + switch (this) { + case ReplacementTypeEnum.style: + case ReplacementTypeEnum.fluency: + return Colors.teal.withOpacity(0.7); + case ReplacementTypeEnum.itStart: + return Colors.purple.withOpacity(0.7); + default: + return Colors.red.withOpacity(0.7); // other/unknown + } + } +} +``` + +Update `pangea_text_controller.dart`: + +```dart +Color _underlineColor(PangeaMatch match) { + if (match.status == PangeaMatchStatusEnum.automatic) { + return const Color.fromARGB(187, 132, 96, 224); + } + + // Use type-based coloring instead of rule ID + return match.match.type.underlineColor; +} +``` + +## Feedback Flag Flow + +### Reference Implementation + +See activity feedback flow in: + +- [activity_sessions_start_view.dart](../../activity_sessions/activity_session_start/activity_sessions_start_view.dart#L83-L139) +- [feedback_dialog.dart](../../common/widgets/feedback_dialog.dart) + +### Flow Steps + +1. User taps flag icon in SpanCard header +2. Show `FeedbackDialog` with optional text input +3. On submit, call WA endpoint with feedback +4. Show `FeedbackResponseDialog` with response +5. Close span card + +### New Files to Create + +1. **`span_feedback_request.dart`** - Request model for WA feedback +2. **`span_feedback_repo.dart`** - Repository to call WA endpoint with feedback + +### Endpoint Integration + +The WA endpoint already supports feedback via `GrammarRequestV2.feedback` field. + +**Choreo endpoint**: `POST /choreo/grammar_v2` + +**Request with feedback**: + +```json +{ + "full_text": "original user text", + "user_l1": "en", + "user_l2": "es", + "feedback": [ + { + "user_feedback": "This correction doesn't make sense", + "input": { ... original request ... }, + "output": { ... original response ... } + } + ] +} +``` + +### Implementation in SpanCard + +```dart +// In SpanCardState + +Future _onFlagPressed() async { + final feedback = await showDialog( + context: context, + builder: (context) => FeedbackDialog( + title: L10n.of(context).spanFeedbackTitle, + onSubmit: (feedback) => Navigator.of(context).pop(feedback), + scrollable: false, + ), + ); + + if (feedback == null || feedback.isEmpty) return; + + final resp = await showFutureLoadingDialog( + context: context, + future: () => SpanFeedbackRepo.submitFeedback( + SpanFeedbackRequest( + span: widget.match.updatedMatch.match, + feedbackText: feedback, + userId: Matrix.of(context).client.userID!, + userL1: MatrixState.pangeaController.userController.userL1Code!, + userL2: MatrixState.pangeaController.userController.userL2Code!, + ), + ), + ); + + if (resp.isError) return; + + await showDialog( + context: context, + builder: (context) => FeedbackResponseDialog( + title: L10n.of(context).feedbackTitle, + feedback: resp.result!.userFriendlyResponse, + description: L10n.of(context).feedbackRespDesc, + ), + ); + + // Close the span card + widget.showNextMatch(); +} +``` + +## Localization Strings Needed + +Add to `intl_en.arb`: + +```json +{ + "spanFeedbackTitle": "Report correction issue", + "spanTypeGrammar": "Grammar", + "spanTypeWordChoice": "Word Choice", + "spanTypeSpelling": "Spelling", + "spanTypePunctuation": "Punctuation", + "spanTypeStyle": "Style", + "spanTypeFluency": "Fluency" +} +``` + +## Files to Modify + +| File | Changes | +| ----------------------------- | ----------------------------------------------------------------- | +| `span_card.dart` | Restructure layout, add header row with X/BotFace/Flag | +| `replacement_type_enum.dart` | Add `underlineColor` extension, add `displayName(context)` method | +| `pangea_text_controller.dart` | Update `_underlineColor` to use type-based colors | + +## New Files to Create + +| File | Purpose | +| ---------------------------- | -------------------------------------- | +| `span_feedback_request.dart` | Request model for span feedback | +| `span_feedback_repo.dart` | API calls for submitting span feedback | + +## Implementation Order + +1. [ ] Update `replacement_type_enum.dart` with `underlineColor` and `displayName` +2. [ ] Update `pangea_text_controller.dart` to use type-based underline colors +3. [ ] Create `span_feedback_request.dart` and `span_feedback_repo.dart` +4. [ ] Restructure `span_card.dart` layout: + - [ ] Add header row (X, BotFace, Flag) + - [ ] Add span type display row + - [ ] Move ChoicesArray + - [ ] Show best choice feedback on open + - [ ] Implement flag button handler +5. [ ] Add localization strings +6. [ ] Test full flow + +## Testing Considerations + +- Verify underline colors display correctly for each error type +- Test feedback dialog flow end-to-end +- Ensure span card closes properly after feedback submission +- Test with various span types to verify type label displays correctly + +--- + +## Pre-Implementation Questions + +### 1. Re-run Feedback Flow Architecture + +The plan says feedback triggers a re-run of WA analysis with `gpt_5_2` model and `prompt_version="verbose"`. + +- **Q1a**: Does the choreo endpoint (`POST /choreo/grammar_v2`) already support the `prompt_version` parameter, or does this need to be added? +- **Q1b**: Where should the model override (`gpt_5_2`) be configured? On the request from the client, or hardcoded in the handler when feedback is present? + +this will be handled server-side. the client just needs to send the feedback in the request object. you can remove the notes about the serverside implemention, those have been added to server doc wa/next_steps.md + +### 2. Alts/Distractors in TSV Format + +The plan mentions expanding TSV format to include `alt_1`, `alt_2`, `distractor_1`, `distractor_2` columns. + +- **Q2a**: Is this TSV format expansion already implemented in choreo, or is this a future change we're planning for? +- **Q2b**: If not implemented yet, should the SpanCard redesign proceed without the alts/distractors UI, or should we stub it out? + +don't worry about this. they'll be SpanChoices in SpanData + +### 3. SpanFeedbackRepo Return Type + +The plan shows `resp.result!.userFriendlyResponse` after submitting feedback (similar to activity feedback). + +- **Q3**: For span feedback, what should the response contain? A new `GrammarResponseV2` with re-analyzed corrections? Or a simple acknowledgment with `userFriendlyResponse`? + +the client will call grammar_v2 endpoint again with feedback. it'll return a new grammar response object. + +### 4. Auto-Feedback on Ignore + +The plan states auto-submit feedback on ignore with message: `"user ignored the correction ({old} -> {new}) without feedback"`. + +- **Q4**: Should this auto-feedback actually trigger a re-run of WA analysis (like explicit feedback does), or should it just be logged/stored for finetuning purposes without re-analysis? + +it should send it via the grammar_v2 endpoint again but not worry about the result. the choreo will audit and store. + +### 5. Localization + +The plan lists new l10n strings to add. + +- **Q5**: Should I add these to `intl_en.arb` only, or are there other language files that need the new keys as well? + +some may be existing, others will be new. just add to intl_en.arb. make a TODO about them to run the translation script. + +### 6. Color Implementation + +The plan uses `Colors.purple.withOpacity(0.7)` for auto-apply types, but references `AppConfig.primaryColor` (#8560E0) in the decisions section. + +- **Q6**: Should I use the literal `Colors.purple.withOpacity(0.7)` or the app's `AppConfig.primaryColor` with opacity? (They're slightly different shades) + +use the primary with opacity. it's what itStart uses. + +--- + +## Follow-up Questions (Round 2) + +### 7. Feedback Request Structure + +The existing `GrammarRequestV2` has a `feedback` field of type `List`. Looking at the activity feedback flow, it uses a separate `ActivityFeedbackRequest` model. + +- **Q7a**: Should the span feedback flow modify the existing `IGCController` to re-call the grammar endpoint with feedback attached to the request, or create a separate `SpanFeedbackRepo` that wraps the grammar call? + +reuse the existing flow unless you spot complications with that. + +- **Q7b**: What fields should be included in the feedback object? Just `user_feedback` text, or also the original `input`/`output` as shown in the plan's JSON example? + +LLMFeedbackSchema calls for the response object plus the user_feedback text + +### 8. UI Flow on Re-run + +The plan says: "Close card → show spinning IGC indicator → snackbar with bot face 'Got it! Recalculating...'" + +- **Q8a**: After the re-run completes, if the same span now has a different correction (or no correction), how should we update the existing match state? Replace in-place, or clear all matches and re-process? + +it will return all new matches. clear the IGC match data and replace it with new + +- **Q8b**: Should the snackbar be shown, or is the spinning IGC indicator sufficient feedback? + +sure, let's skip the snackbar for now and see. + +### 9. SpanCard Header Layout + +The plan shows `[X] 🤖 [🚩]` in the header. + +- **Q9**: Should the X button and Flag button be the same size for visual symmetry, or should flag be smaller/less prominent? + +same size and color for visual symmetry. see the word card for example and follow that exactly + +--- + +## All Questions Resolved ✅ + +Ready to implement. Summary of key decisions from Q&A: + +1. **Feedback flow**: Reuse existing `IGCController` to call grammar endpoint with feedback attached +2. **Feedback schema**: `LLMFeedbackSchema` with `feedback` (user text) + `content` (original response object) +3. **Re-run result**: Clear existing IGC matches and replace with new response +4. **No snackbar**: Just use spinning IGC indicator +5. **Header layout**: Follow `WordZoomWidget` exactly - both buttons same size/color using `IconButton` with `Theme.of(context).iconTheme.color` +6. **Colors**: Use `AppConfig.primaryColor` with opacity for IT/auto-apply +7. **Localization**: Add to `intl_en.arb` only, TODO for translation script + +--- + +## Follow-up Questions (Round 3) + +### 10. Best Choice Feedback Loading State + +The original plan mentioned "Fall back to loading state if feedback needs fetching." + +**Q10**: Is feedback always included in the initial WA response now (so no fetching needed), or should we still handle a loading state for feedback? + +yes, both feedback and choices will always be included + +### 11. FeedbackResponseDialog After Flag + +The original plan showed a `FeedbackResponseDialog` after submitting feedback, but Q8b decided "skip the snackbar" and Q3 clarified the response is a new `GrammarResponseV2` (not a `userFriendlyResponse` string). + +**Q11**: After flag feedback re-run, should we: + +- (A) Just close card → spinner → show new matches (no dialog), or +- (B) Show a dialog acknowledging the feedback before showing new results? + +i think we probably need something. just closing the card would be abrupt. that's why i was thinking the snackbar. let's start without it and see though. + +### 12. Re-run Trigger Method + +The feedback calls grammar_v2 which returns a new response. + +**Q12**: How does the SpanCard trigger the re-run through IGCController? Should it: + +- (A) Call a new method like `igcController.rerunWithFeedback(feedback, originalResponse)`, or +- (B) Call the existing flow but with feedback attached to the request somehow? + +probably a new method is a good idea so we can add in any logic needed for this + +### 13. Original Response Reference + +`LLMFeedbackSchema` needs the original response (`content` field). + +yes. send the contested grammar response + +**Q13**: Where is the original `GrammarResponseV2` stored that we can reference when building the feedback object? Is it on `IGCController` or `Choreographer`? + +i'm not sure exactly. actually, i think it should be accessible via igc_repo.dart which will have cached it + +--- + +## All Round 3 Questions Answered ✅ + +Summary of Round 3 decisions: + +10. **Feedback always included**: No loading state needed for feedback text +11. **Post-feedback UX**: Start without snackbar/dialog, may add later if too abrupt +12. **New method needed**: Create `igcController.rerunWithFeedback(feedback, originalResponse)` +13. **Original response location**: Access via `IgcRepo` cache (keyed by request hashcode) + +--- + +## Follow-up Questions (Round 4) + +### 14. IgcRepo Cache Access + +Looking at `igc_repo.dart`, the cache is keyed by `IGCRequestModel.hashCode.toString()` and stores `Future`. The cache is private (`_igcCache`). + +**Q14**: To access the cached response for feedback, should I: + +- (A) Add a public method to `IgcRepo` like `getLastResponse()` or `getCachedResponse(request)`, or +- (B) Store the response on `IgcController` after fetch completes (simpler)? + +not sure. use your judgment. diff --git a/lib/pangea/choreographer/igc/igc_controller.dart b/lib/pangea/choreographer/igc/igc_controller.dart index 9b78cb439..7ca41e5c8 100644 --- a/lib/pangea/choreographer/igc/igc_controller.dart +++ b/lib/pangea/choreographer/igc/igc_controller.dart @@ -7,11 +7,11 @@ import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/choreographer/igc/igc_repo.dart'; import 'package:fluffychat/pangea/choreographer/igc/igc_request_model.dart'; +import 'package:fluffychat/pangea/choreographer/igc/igc_response_model.dart'; import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart'; import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart'; import 'package:fluffychat/pangea/choreographer/igc/span_data_model.dart'; -import 'package:fluffychat/pangea/choreographer/igc/span_data_repo.dart'; -import 'package:fluffychat/pangea/choreographer/igc/span_data_request.dart'; +import 'package:fluffychat/pangea/common/models/llm_feedback_model.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -24,6 +24,12 @@ class IgcController { bool _isFetching = false; String? _currentText; + /// Last request made - stored for feedback rerun + IGCRequestModel? _lastRequest; + + /// Last response received - stored for feedback rerun + IGCResponseModel? _lastResponse; + final List _openMatches = []; final List _closedMatches = []; @@ -70,21 +76,11 @@ class IgcController { IGCRequestModel( fullText: text, userId: MatrixState.pangeaController.userController.client.userID!, - userL1: MatrixState.pangeaController.userController.userL1Code!, - userL2: MatrixState.pangeaController.userController.userL2Code!, enableIGC: true, enableIT: true, prevMessages: prevMessages, ); - SpanDetailsRequest _spanDetailsRequest(SpanData span) => SpanDetailsRequest( - userL1: MatrixState.pangeaController.userController.userL1Code!, - userL2: MatrixState.pangeaController.userController.userL2Code!, - enableIGC: true, - enableIT: true, - span: span, - ); - void dispose() { matchUpdateStream.close(); } @@ -92,6 +88,8 @@ class IgcController { void clear() { _isFetching = false; _currentText = null; + _lastRequest = null; + _lastResponse = null; _openMatches.clear(); _closedMatches.clear(); MatrixState.pAnyState.closeAllOverlays(); @@ -102,6 +100,8 @@ class IgcController { _closedMatches.clear(); } + void clearCurrentText() => _currentText = null; + void _filterPreviouslyIgnoredMatches() { for (final match in _openMatches) { if (IgcRepo.isIgnored(match.updatedMatch)) { @@ -304,29 +304,88 @@ class IgcController { ) async { if (text.isEmpty) return clear(); if (_isFetching) return; + + final request = _igcRequest(text, prevMessages); + await _fetchIGC(request); + } + + /// Re-runs IGC with user feedback about the previous response. + /// Returns true if feedback was submitted, false if no previous data. + Future rerunWithFeedback(String feedbackText) async { + debugPrint('rerunWithFeedback called with: $feedbackText'); + debugPrint('_lastRequest: $_lastRequest, _lastResponse: $_lastResponse'); + if (_lastRequest == null || _lastResponse == null) { + ErrorHandler.logError( + e: StateError( + 'rerunWithFeedback called without prior request/response', + ), + data: { + 'hasLastRequest': _lastRequest != null, + 'hasLastResponse': _lastResponse != null, + 'currentText': _currentText, + }, + ); + return false; + } + if (_isFetching) { + debugPrint('rerunWithFeedback: already fetching, returning false'); + return false; + } + + // Create feedback containing the original response + final feedback = LLMFeedbackModel( + feedback: feedbackText, + content: _lastResponse!, + contentToJson: (r) => r.toJson(), + ); + + // Clear existing matches and state + clearMatches(); + + // Create request with feedback attached + final requestWithFeedback = _lastRequest!.copyWithFeedback([feedback]); + debugPrint( + 'requestWithFeedback.feedback.length: ${requestWithFeedback.feedback.length}', + ); + debugPrint('requestWithFeedback.hashCode: ${requestWithFeedback.hashCode}'); + debugPrint('_lastRequest.hashCode: ${_lastRequest!.hashCode}'); + debugPrint('Calling IgcRepo.get...'); + return _fetchIGC(requestWithFeedback); + } + + Future _fetchIGC(IGCRequestModel request) async { _isFetching = true; + _lastRequest = request; final res = await IgcRepo.get( MatrixState.pangeaController.userController.accessToken, - _igcRequest(text, prevMessages), + request, ).timeout( - (const Duration(seconds: 10)), + const Duration(seconds: 10), onTimeout: () { return Result.error( - TimeoutException('IGC request timed out'), + TimeoutException( + request.feedback.isNotEmpty + ? 'IGC feedback request timed out' + : 'IGC request timed out', + ), ); }, ); if (res.isError) { + debugPrint('IgcRepo.get error: ${res.asError}'); onError(res.asError!); clear(); - return; - } else { - onFetch(); + return false; } - if (!_isFetching) return; + debugPrint('IgcRepo.get success, calling onFetch'); + onFetch(); + + if (!_isFetching) return false; + + _lastResponse = res.result!; _currentText = res.result!.originalInput; for (final match in res.result!.matches) { final matchState = PangeaMatchState( @@ -342,38 +401,6 @@ class IgcController { } _filterPreviouslyIgnoredMatches(); _isFetching = false; - } - - Future fetchSpanDetails({ - required PangeaMatchState match, - bool force = false, - }) async { - final span = match.updatedMatch.match; - if (span.isNormalizationError() && !force) { - return; - } - - final response = await SpanDataRepo.get( - MatrixState.pangeaController.userController.accessToken, - request: _spanDetailsRequest(span), - ).timeout( - (const Duration(seconds: 10)), - onTimeout: () { - return Result.error( - TimeoutException('Span details request timed out'), - ); - }, - ); - - if (response.isError) throw response.error!; - setSpanData(match, response.result!); - } - - Future fetchAllSpanDetails() async { - final fetches = []; - for (final match in _openMatches) { - fetches.add(fetchSpanDetails(match: match)); - } - await Future.wait(fetches); + return true; } } diff --git a/lib/pangea/choreographer/igc/igc_repo.dart b/lib/pangea/choreographer/igc/igc_repo.dart index 3ffbc5d07..998a1faee 100644 --- a/lib/pangea/choreographer/igc/igc_repo.dart +++ b/lib/pangea/choreographer/igc/igc_repo.dart @@ -56,10 +56,15 @@ class IgcRepo { String? accessToken, IGCRequestModel igcRequest, ) { + debugPrint( + '[IgcRepo.get] called, request.hashCode: ${igcRequest.hashCode}', + ); final cached = _getCached(igcRequest); if (cached != null) { + debugPrint('[IgcRepo.get] cache HIT'); return _getResult(igcRequest, cached); } + debugPrint('[IgcRepo.get] cache MISS, fetching from server...'); final future = _fetch( accessToken, diff --git a/lib/pangea/choreographer/igc/igc_request_model.dart b/lib/pangea/choreographer/igc/igc_request_model.dart index cbe6a41eb..1d3ddb7f2 100644 --- a/lib/pangea/choreographer/igc/igc_request_model.dart +++ b/lib/pangea/choreographer/igc/igc_request_model.dart @@ -1,36 +1,67 @@ import 'dart:convert'; +import 'package:fluffychat/pangea/choreographer/igc/igc_response_model.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; +import 'package:fluffychat/pangea/common/models/base_request_model.dart'; +import 'package:fluffychat/pangea/common/models/llm_feedback_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; -class IGCRequestModel { +class IGCRequestModel with BaseRequestModel { final String fullText; - final String userL1; - final String userL2; final bool enableIT; final bool enableIGC; final String userId; final List prevMessages; + final List> feedback; + + @override + String get userCefr => MatrixState + .pangeaController.userController.profile.userSettings.cefrLevel.string; + + @override + String get userL1 => MatrixState.pangeaController.userController.userL1Code!; + + @override + String get userL2 => MatrixState.pangeaController.userController.userL2Code!; const IGCRequestModel({ required this.fullText, - required this.userL1, - required this.userL2, required this.enableIGC, required this.enableIT, required this.userId, required this.prevMessages, + this.feedback = const [], }); - Map toJson() => { - ModelKey.fullText: fullText, - ModelKey.userL1: userL1, - ModelKey.userL2: userL2, - ModelKey.enableIT: enableIT, - ModelKey.enableIGC: enableIGC, - ModelKey.userId: userId, - ModelKey.prevMessages: - jsonEncode(prevMessages.map((x) => x.toJson()).toList()), - }; + /// Creates a copy of this request with optional feedback. + IGCRequestModel copyWithFeedback( + List> newFeedback, + ) => + IGCRequestModel( + fullText: fullText, + enableIGC: enableIGC, + enableIT: enableIT, + userId: userId, + prevMessages: prevMessages, + feedback: newFeedback, + ); + + Map toJson() { + final json = { + ModelKey.fullText: fullText, + ModelKey.userL1: userL1, + ModelKey.userL2: userL2, + ModelKey.enableIT: enableIT, + ModelKey.enableIGC: enableIGC, + ModelKey.userId: userId, + ModelKey.prevMessages: + jsonEncode(prevMessages.map((x) => x.toJson()).toList()), + }; + if (feedback.isNotEmpty) { + json[ModelKey.feedback] = feedback.map((f) => f.toJson()).toList(); + } + return json; + } @override bool operator ==(Object other) { @@ -43,9 +74,14 @@ class IGCRequestModel { userL1 == other.userL1 && userL2 == other.userL2 && enableIT == other.enableIT && - userId == other.userId; + userId == other.userId && + _feedbackHash == other._feedbackHash; } + /// Hash of feedback content for cache differentiation + int get _feedbackHash => + feedback.isEmpty ? 0 : Object.hashAll(feedback.map((f) => f.feedback)); + @override int get hashCode => Object.hash( fullText.trim(), @@ -54,6 +90,7 @@ class IGCRequestModel { enableIT, enableIGC, userId, + _feedbackHash, ); } diff --git a/lib/pangea/choreographer/igc/igc_response_model.dart b/lib/pangea/choreographer/igc/igc_response_model.dart index aed6b4bcd..2feb383fb 100644 --- a/lib/pangea/choreographer/igc/igc_response_model.dart +++ b/lib/pangea/choreographer/igc/igc_response_model.dart @@ -7,7 +7,13 @@ class IGCResponseModel { final List matches; final String userL1; final String userL2; + + /// Whether interactive translation is enabled. + /// Defaults to true for V2 responses which don't include this field. final bool enableIT; + + /// Whether in-context grammar is enabled. + /// Defaults to true for V2 responses which don't include this field. final bool enableIGC; IGCResponseModel({ @@ -16,35 +22,39 @@ class IGCResponseModel { required this.matches, required this.userL1, required this.userL2, - required this.enableIT, - required this.enableIGC, + this.enableIT = true, + this.enableIGC = true, }); factory IGCResponseModel.fromJson(Map json) { + final String originalInput = json["original_input"]; return IGCResponseModel( matches: json["matches"] != null ? (json["matches"] as Iterable) .map( - (e) { - return PangeaMatch.fromJson(e as Map); - }, + (e) => PangeaMatch.fromJson( + e as Map, + fullText: originalInput, + ), ) .toList() .cast() : [], - originalInput: json["original_input"], + originalInput: originalInput, fullTextCorrection: json["full_text_correction"], userL1: json[ModelKey.userL1], userL2: json[ModelKey.userL2], - enableIT: json[ModelKey.enableIT], - enableIGC: json[ModelKey.enableIGC], + // V2 responses don't include these fields; default to true + enableIT: json[ModelKey.enableIT] ?? true, + enableIGC: json[ModelKey.enableIGC] ?? true, ); } Map toJson() => { "original_input": originalInput, "full_text_correction": fullTextCorrection, - "matches": matches.map((e) => e.toJson()).toList(), + // Serialize as flat SpanData objects matching server's SpanDataV2 schema + "matches": matches.map((e) => e.match.toJson()).toList(), ModelKey.userL1: userL1, ModelKey.userL2: userL2, ModelKey.enableIT: enableIT, diff --git a/lib/pangea/choreographer/igc/pangea_match_model.dart b/lib/pangea/choreographer/igc/pangea_match_model.dart index a2db081f8..e76400813 100644 --- a/lib/pangea/choreographer/igc/pangea_match_model.dart +++ b/lib/pangea/choreographer/igc/pangea_match_model.dart @@ -1,5 +1,5 @@ import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart'; -import 'package:fluffychat/pangea/choreographer/igc/span_data_type_enum.dart'; +import 'package:fluffychat/pangea/choreographer/igc/replacement_type_enum.dart'; import 'match_rule_id_model.dart'; import 'span_data_model.dart'; @@ -12,10 +12,31 @@ class PangeaMatch { required this.status, }); - factory PangeaMatch.fromJson(Map json) { + /// Parse PangeaMatch from JSON. + /// + /// Supports two formats: + /// - V1/Legacy: {"match": {...span_data...}, "status": "open"} + /// - V2: {...span_data...} (SpanData directly, status defaults to open) + /// + /// [fullText] is passed to SpanData as fallback when the span JSON doesn't + /// contain full_text (e.g., when using original_input from parent response). + factory PangeaMatch.fromJson( + Map json, { + String? fullText, + }) { + // Check if this is V1 format (has "match" wrapper) or V2 format (flat SpanData) + final bool isV1Format = json[_matchKey] is Map; + + final Map spanJson = + isV1Format ? json[_matchKey] as Map : json; + return PangeaMatch( - match: SpanData.fromJson(json[_matchKey] as Map), - status: json[_statusKey] != null + match: SpanData.fromJson( + spanJson, + parentFullText: fullText, + ), + // V1 format may have status; V2 format always defaults to open + status: isV1Format && json[_statusKey] != null ? PangeaMatchStatusEnum.fromString(json[_statusKey] as String) : PangeaMatchStatusEnum.open, ); @@ -31,8 +52,7 @@ class PangeaMatch { bool get isITStart => match.rule?.id == MatchRuleIdModel.interactiveTranslation || - [SpanDataTypeEnum.itStart, SpanDataTypeEnum.itStart.name] - .contains(match.type.typeName); + match.type == ReplacementTypeEnum.itStart; bool get _needsTranslation => match.rule?.id != null ? [ diff --git a/lib/pangea/choreographer/igc/replacement_type_enum.dart b/lib/pangea/choreographer/igc/replacement_type_enum.dart new file mode 100644 index 000000000..827d587cb --- /dev/null +++ b/lib/pangea/choreographer/igc/replacement_type_enum.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +enum ReplacementTypeEnum { + // Client-specific types + definition, + practice, + itStart, + + // === GRAMMAR CATEGORIES (granular for teacher analytics) === + verbConjugation, // Wrong form for person/number/tense + verbTense, // Using wrong tense for context + verbMood, // Indicative vs subjunctive vs imperative + subjectVerbAgreement, // "he go" -> "he goes" + genderAgreement, // "la libro" -> "el libro" + numberAgreement, // Singular/plural mismatches + caseError, // For languages with grammatical cases + article, // Missing, wrong, or unnecessary articles + preposition, // Wrong preposition choice + pronoun, // Wrong form, reference, or missing + wordOrder, // Syntax/structure issues + negation, // Double negatives, wrong placement + questionFormation, // Incorrect question structure + relativeClause, // who/which/that errors + connector, // Conjunction usage (but/and/however) + possessive, // Apostrophe usage, possessive pronouns + comparative, // Comparative/superlative forms + passiveVoice, // Passive construction errors + conditional, // If clauses, would/could + infinitiveGerund, // Infinitive vs gerund usage + modal, // Modal verb usage (can/could/should/must) + + // === SURFACE-LEVEL CORRECTIONS (auto-applied) === + punct, + diacritics, + spell, + cap, + + // === WORD CHOICE CATEGORIES (granular for teacher analytics) === + falseCognate, // False friends (e.g., "embarazada" ≠ "embarrassed") + l1Interference, // L1 patterns bleeding through incorrectly + collocation, // Wrong word pairing (e.g., "do a mistake" → "make a mistake") + semanticConfusion, // Similar meanings, wrong choice (e.g., "see/watch/look") + + // === HIGHER-LEVEL SUGGESTIONS === + transcription, + style, + fluency, + didYouMean, + translation, + other, +} + +extension SpanDataTypeEnumExt on ReplacementTypeEnum { + /// Types that should be auto-applied without user interaction. + /// These are minor corrections like punctuation, spacing, accents, etc. + static const List autoApplyTypes = [ + ReplacementTypeEnum.punct, + ReplacementTypeEnum.diacritics, + ReplacementTypeEnum.spell, + ReplacementTypeEnum.cap, + ]; + + /// Grammar types that require explanatory reasons for learning. + static const List grammarTypes = [ + ReplacementTypeEnum.verbConjugation, + ReplacementTypeEnum.verbTense, + ReplacementTypeEnum.verbMood, + ReplacementTypeEnum.subjectVerbAgreement, + ReplacementTypeEnum.genderAgreement, + ReplacementTypeEnum.numberAgreement, + ReplacementTypeEnum.caseError, + ReplacementTypeEnum.article, + ReplacementTypeEnum.preposition, + ReplacementTypeEnum.pronoun, + ReplacementTypeEnum.wordOrder, + ReplacementTypeEnum.negation, + ReplacementTypeEnum.questionFormation, + ReplacementTypeEnum.relativeClause, + ReplacementTypeEnum.connector, + ReplacementTypeEnum.possessive, + ReplacementTypeEnum.comparative, + ReplacementTypeEnum.passiveVoice, + ReplacementTypeEnum.conditional, + ReplacementTypeEnum.infinitiveGerund, + ReplacementTypeEnum.modal, + ]; + + /// Word choice types that require explanatory reasons for learning. + static const List wordChoiceTypes = [ + ReplacementTypeEnum.falseCognate, + ReplacementTypeEnum.l1Interference, + ReplacementTypeEnum.collocation, + ReplacementTypeEnum.semanticConfusion, + ]; + + /// Whether this type should be auto-applied without user interaction. + bool get isAutoApply => autoApplyTypes.contains(this); + + /// Whether this is a grammar-related type (for analytics grouping). + bool get isGrammarType => grammarTypes.contains(this); + + /// Whether this is a word-choice-related type (for analytics grouping). + bool get isWordChoiceType => wordChoiceTypes.contains(this); + + /// Convert enum to snake_case string for JSON serialization. + String get name { + switch (this) { + // Client-specific types + case ReplacementTypeEnum.definition: + return "definition"; + case ReplacementTypeEnum.practice: + return "practice"; + case ReplacementTypeEnum.itStart: + return "itStart"; + + // Grammar types + case ReplacementTypeEnum.verbConjugation: + return "verb_conjugation"; + case ReplacementTypeEnum.verbTense: + return "verb_tense"; + case ReplacementTypeEnum.verbMood: + return "verb_mood"; + case ReplacementTypeEnum.subjectVerbAgreement: + return "subject_verb_agreement"; + case ReplacementTypeEnum.genderAgreement: + return "gender_agreement"; + case ReplacementTypeEnum.numberAgreement: + return "number_agreement"; + case ReplacementTypeEnum.caseError: + return "case_error"; + case ReplacementTypeEnum.article: + return "article"; + case ReplacementTypeEnum.preposition: + return "preposition"; + case ReplacementTypeEnum.pronoun: + return "pronoun"; + case ReplacementTypeEnum.wordOrder: + return "word_order"; + case ReplacementTypeEnum.negation: + return "negation"; + case ReplacementTypeEnum.questionFormation: + return "question_formation"; + case ReplacementTypeEnum.relativeClause: + return "relative_clause"; + case ReplacementTypeEnum.connector: + return "connector"; + case ReplacementTypeEnum.possessive: + return "possessive"; + case ReplacementTypeEnum.comparative: + return "comparative"; + case ReplacementTypeEnum.passiveVoice: + return "passive_voice"; + case ReplacementTypeEnum.conditional: + return "conditional"; + case ReplacementTypeEnum.infinitiveGerund: + return "infinitive_gerund"; + case ReplacementTypeEnum.modal: + return "modal"; + + // Surface-level corrections + case ReplacementTypeEnum.punct: + return "punct"; + case ReplacementTypeEnum.diacritics: + return "diacritics"; + case ReplacementTypeEnum.spell: + return "spell"; + case ReplacementTypeEnum.cap: + return "cap"; + + // Word choice types + case ReplacementTypeEnum.falseCognate: + return "false_cognate"; + case ReplacementTypeEnum.l1Interference: + return "l1_interference"; + case ReplacementTypeEnum.collocation: + return "collocation"; + case ReplacementTypeEnum.semanticConfusion: + return "semantic_confusion"; + + // Higher-level suggestions + case ReplacementTypeEnum.transcription: + return "transcription"; + case ReplacementTypeEnum.style: + return "style"; + case ReplacementTypeEnum.fluency: + return "fluency"; + case ReplacementTypeEnum.didYouMean: + return "did_you_mean"; + case ReplacementTypeEnum.translation: + return "translation"; + case ReplacementTypeEnum.other: + return "other"; + } + } + + /// Parse type string from JSON, handling backward compatibility + /// for old saved data and snake_case to camelCase conversion. + static ReplacementTypeEnum? fromString(String? typeString) { + if (typeString == null) return null; + + // Normalize snake_case to camelCase and handle backward compatibility + final normalized = switch (typeString) { + // Legacy mappings - grammar and word_choice were split into subtypes + 'correction' => 'subjectVerbAgreement', // Legacy fallback + 'grammar' => 'subjectVerbAgreement', // Legacy fallback + 'word_choice' => 'semanticConfusion', // Legacy fallback + + // Snake_case to camelCase conversions - grammar types + 'did_you_mean' => 'didYouMean', + 'verb_conjugation' => 'verbConjugation', + 'verb_tense' => 'verbTense', + 'verb_mood' => 'verbMood', + 'subject_verb_agreement' => 'subjectVerbAgreement', + 'gender_agreement' => 'genderAgreement', + 'number_agreement' => 'numberAgreement', + 'case_error' => 'caseError', + 'word_order' => 'wordOrder', + 'question_formation' => 'questionFormation', + 'relative_clause' => 'relativeClause', + 'passive_voice' => 'passiveVoice', + 'infinitive_gerund' => 'infinitiveGerund', + + // Snake_case to camelCase conversions - word choice types + 'false_cognate' => 'falseCognate', + 'l1_interference' => 'l1Interference', + 'semantic_confusion' => 'semanticConfusion', + // 'collocation' is already single word, no conversion needed + + // Already camelCase or single word - pass through + _ => typeString, + }; + + return ReplacementTypeEnum.values.firstWhereOrNull( + (e) => e.name == normalized || e.toString().split('.').last == normalized, + ); + } + + String defaultPrompt(BuildContext context) { + switch (this) { + case ReplacementTypeEnum.definition: + return L10n.of(context).definitionDefaultPrompt; + case ReplacementTypeEnum.practice: + return L10n.of(context).practiceDefaultPrompt; + case ReplacementTypeEnum.itStart: + return L10n.of(context).needsItMessage( + MatrixState.pangeaController.userController.userL2 + ?.getDisplayName(context) ?? + L10n.of(context).targetLanguage, + ); + // All grammar types and other corrections use the same default prompt + default: + return L10n.of(context).correctionDefaultPrompt; + } + } + + /// Returns the underline color for this replacement type. + /// Used to visually distinguish different error categories in the text field. + Color underlineColor() { + // IT start and auto-apply types use primary color + if (this == ReplacementTypeEnum.itStart || isAutoApply) { + return AppConfig.primaryColor.withAlpha(180); + } + // Grammar errors use warning/orange + if (isGrammarType) { + return AppConfig.warning.withAlpha(180); + } + // Word choice uses blue + if (isWordChoiceType) { + return Colors.blue.withAlpha(180); + } + // Style and fluency use teal + switch (this) { + case ReplacementTypeEnum.style: + case ReplacementTypeEnum.fluency: + return Colors.teal.withAlpha(180); + default: + // Other/unknown use error color + return AppConfig.error.withAlpha(180); + } + } + + /// Returns a human-readable display name for this replacement type. + /// Used in the SpanCard UI to show the error category. + String displayName(BuildContext context) { + if (isGrammarType) { + return L10n.of(context).spanTypeGrammar; + } + if (isWordChoiceType) { + return L10n.of(context).spanTypeWordChoice; + } + switch (this) { + case ReplacementTypeEnum.spell: + return L10n.of(context).spanTypeSpelling; + case ReplacementTypeEnum.punct: + return L10n.of(context).spanTypePunctuation; + case ReplacementTypeEnum.style: + return L10n.of(context).spanTypeStyle; + case ReplacementTypeEnum.fluency: + return L10n.of(context).spanTypeFluency; + case ReplacementTypeEnum.diacritics: + return L10n.of(context).spanTypeAccents; + case ReplacementTypeEnum.cap: + return L10n.of(context).spanTypeCapitalization; + default: + return L10n.of(context).spanTypeCorrection; + } + } +} diff --git a/lib/pangea/choreographer/igc/span_card.dart b/lib/pangea/choreographer/igc/span_card.dart index a69225650..3b8ca6715 100644 --- a/lib/pangea/choreographer/igc/span_card.dart +++ b/lib/pangea/choreographer/igc/span_card.dart @@ -4,14 +4,15 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/bot/utils/bot_style.dart'; +import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart'; import 'package:fluffychat/pangea/choreographer/choreographer.dart'; import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart'; import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart'; +import 'package:fluffychat/pangea/choreographer/igc/replacement_type_enum.dart'; import 'package:fluffychat/pangea/choreographer/igc/span_choice_type_enum.dart'; import 'package:fluffychat/pangea/choreographer/igc/span_data_model.dart'; -import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; +import 'package:fluffychat/pangea/common/widgets/feedback_dialog.dart'; import '../../../widgets/matrix.dart'; import '../../common/widgets/choice_array.dart'; @@ -19,12 +20,14 @@ class SpanCard extends StatefulWidget { final PangeaMatchState match; final Choreographer choreographer; final VoidCallback showNextMatch; + final Future Function(String) onFeedbackSubmitted; const SpanCard({ super.key, required this.match, required this.choreographer, required this.showNextMatch, + required this.onFeedbackSubmitted, }); @override @@ -32,95 +35,24 @@ class SpanCard extends StatefulWidget { } class SpanCardState extends State { - bool _loadingChoices = true; - final ValueNotifier> _feedbackState = - ValueNotifier>(const AsyncIdle()); - final ScrollController scrollController = ScrollController(); @override void initState() { super.initState(); - _fetchChoices(); } @override void dispose() { - _feedbackState.dispose(); scrollController.dispose(); super.dispose(); } - List? get _choices => widget.match.updatedMatch.match.choices; - SpanChoice? get _selectedChoice => widget.match.updatedMatch.match.selectedChoice; - String? get _selectedFeedback => _selectedChoice?.feedback; - - Future _fetchChoices() async { - if (_choices != null && _choices!.length > 1) { - setState(() => _loadingChoices = false); - return; - } - - setState(() => _loadingChoices = true); - - try { - await widget.choreographer.igcController.fetchSpanDetails( - match: widget.match, - ); - - if (_choices == null || _choices!.isEmpty) { - widget.choreographer.clearMatches( - 'No choices available for span ${widget.match.updatedMatch.match.message}', - ); - } - } catch (e) { - widget.choreographer.clearMatches(e); - } finally { - if (mounted) { - setState(() => _loadingChoices = false); - } - } - } - - Future _fetchFeedback() async { - if (_selectedFeedback != null) { - _feedbackState.value = AsyncLoaded(_selectedFeedback!); - return; - } - - try { - _feedbackState.value = const AsyncLoading(); - await widget.choreographer.igcController.fetchSpanDetails( - match: widget.match, - force: true, - ); - - if (!mounted) return; - if (_selectedFeedback != null) { - _feedbackState.value = AsyncLoaded(_selectedFeedback!); - } else { - _feedbackState.value = AsyncError( - L10n.of(context).failedToLoadFeedback, - ); - } - } catch (e) { - if (mounted) { - _feedbackState.value = AsyncError(e); - } - } - } - void _onChoiceSelect(int index) { - final selected = _choices![index]; widget.match.selectChoice(index); - - _feedbackState.value = selected.feedback != null - ? AsyncLoaded(selected.feedback!) - : const AsyncIdle(); - setState(() {}); } @@ -145,12 +77,54 @@ class SpanCardState extends State { } } + Future _showFeedbackDialog() async { + final resp = await showDialog( + context: context, + builder: (context) => FeedbackDialog( + title: L10n.of(context).spanFeedbackTitle, + onSubmit: (feedback) => Navigator.of(context).pop(feedback), + ), + ); + if (resp == null || resp.isEmpty) { + return; + } + + await widget.onFeedbackSubmitted(resp); + } + @override Widget build(BuildContext context) { return SizedBox( height: 300.0, child: Column( children: [ + // Header row: Close, Type Label + BotFace, Flag + SizedBox( + height: 40.0, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + icon: const Icon(Icons.close), + color: Theme.of(context).iconTheme.color, + onPressed: () => _updateMatch(PangeaMatchStatusEnum.ignored), + ), + const Flexible( + child: Center( + child: BotFace( + width: 32.0, + expression: BotExpression.idle, + ), + ), + ), + IconButton( + icon: const Icon(Icons.flag_outlined), + color: Theme.of(context).iconTheme.color, + onPressed: _showFeedbackDialog, + ), + ], + ), + ), Expanded( child: Scrollbar( controller: scrollController, @@ -165,13 +139,13 @@ class SpanCardState extends State { spacing: 12.0, children: [ ChoicesArray( - isLoading: _loadingChoices, + isLoading: false, choices: widget.match.updatedMatch.match.choices ?.map( (e) => Choice( text: e.value, color: e.selected ? e.type.color : null, - isGold: e.type.name == 'bestCorrection', + isGold: e.type.isSuggestion, ), ) .toList(), @@ -184,9 +158,7 @@ class SpanCardState extends State { ), const SizedBox(), _SpanCardFeedback( - _selectedChoice != null, - _fetchFeedback, - _feedbackState, + widget.match.updatedMatch.match, ), ], ), @@ -206,48 +178,32 @@ class SpanCardState extends State { } class _SpanCardFeedback extends StatelessWidget { - final bool hasSelectedChoice; - final VoidCallback fetchFeedback; - final ValueNotifier> feedbackState; - - const _SpanCardFeedback( - this.hasSelectedChoice, - this.fetchFeedback, - this.feedbackState, - ); + final SpanData? span; + const _SpanCardFeedback(this.span); @override Widget build(BuildContext context) { + String prompt = L10n.of(context).correctionDefaultPrompt; + if (span != null) { + prompt = span!.type.defaultPrompt(context); + } + + final defaultContent = Text( + prompt, + style: BotStyle.text(context).copyWith( + fontStyle: FontStyle.italic, + ), + ); + return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - ValueListenableBuilder( - valueListenable: feedbackState, - builder: (context, state, __) { - return switch (state) { - AsyncIdle() => hasSelectedChoice - ? IconButton( - onPressed: fetchFeedback, - icon: const Icon(Icons.lightbulb_outline, size: 24), - ) - : Text( - L10n.of(context).correctionDefaultPrompt, - style: BotStyle.text(context).copyWith( - fontStyle: FontStyle.italic, - ), - ), - AsyncLoading() => const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(), - ), - AsyncError(:final error) => - ErrorIndicator(message: error.toString()), - AsyncLoaded(:final value) => - Text(value, style: BotStyle.text(context)), - }; - }, - ), + span == null || span!.selectedChoice == null + ? defaultContent + : Text( + span!.selectedChoice!.feedbackToDisplay(context), + style: BotStyle.text(context), + ), ], ); } diff --git a/lib/pangea/choreographer/igc/span_choice_type_enum.dart b/lib/pangea/choreographer/igc/span_choice_type_enum.dart index af8dccb32..94e03fbba 100644 --- a/lib/pangea/choreographer/igc/span_choice_type_enum.dart +++ b/lib/pangea/choreographer/igc/span_choice_type_enum.dart @@ -3,53 +3,64 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; enum SpanChoiceTypeEnum { - bestCorrection, + suggestion, + alt, distractor, + @Deprecated('Use suggestion instead') + bestCorrection, + @Deprecated('Use suggestion instead') bestAnswer, } extension SpanChoiceExt on SpanChoiceTypeEnum { - String get name { - switch (this) { - case SpanChoiceTypeEnum.bestCorrection: - return "bestCorrection"; - case SpanChoiceTypeEnum.distractor: - return "distractor"; - case SpanChoiceTypeEnum.bestAnswer: - return "bestAnswer"; - } - } + bool get isSuggestion => + this == SpanChoiceTypeEnum.suggestion || + // ignore: deprecated_member_use_from_same_package + this == SpanChoiceTypeEnum.bestCorrection || + // ignore: deprecated_member_use_from_same_package + this == SpanChoiceTypeEnum.bestAnswer; String defaultFeedback(BuildContext context) { switch (this) { + case SpanChoiceTypeEnum.suggestion: + // ignore: deprecated_member_use_from_same_package case SpanChoiceTypeEnum.bestCorrection: return L10n.of(context).bestCorrectionFeedback; - case SpanChoiceTypeEnum.distractor: - return L10n.of(context).distractorFeedback; + case SpanChoiceTypeEnum.alt: + // ignore: deprecated_member_use_from_same_package case SpanChoiceTypeEnum.bestAnswer: return L10n.of(context).bestAnswerFeedback; + case SpanChoiceTypeEnum.distractor: + return L10n.of(context).distractorFeedback; } } IconData get icon { switch (this) { + case SpanChoiceTypeEnum.suggestion: + // ignore: deprecated_member_use_from_same_package case SpanChoiceTypeEnum.bestCorrection: + case SpanChoiceTypeEnum.alt: + // ignore: deprecated_member_use_from_same_package + case SpanChoiceTypeEnum.bestAnswer: return Icons.check_circle; case SpanChoiceTypeEnum.distractor: return Icons.cancel; - case SpanChoiceTypeEnum.bestAnswer: - return Icons.check_circle; } } Color get color { switch (this) { + case SpanChoiceTypeEnum.suggestion: + // ignore: deprecated_member_use_from_same_package case SpanChoiceTypeEnum.bestCorrection: return Colors.green; + case SpanChoiceTypeEnum.alt: + // ignore: deprecated_member_use_from_same_package + case SpanChoiceTypeEnum.bestAnswer: + return Colors.green; case SpanChoiceTypeEnum.distractor: return Colors.red; - case SpanChoiceTypeEnum.bestAnswer: - return Colors.green; } } } diff --git a/lib/pangea/choreographer/igc/span_data_model.dart b/lib/pangea/choreographer/igc/span_data_model.dart index ac65a1347..fea744140 100644 --- a/lib/pangea/choreographer/igc/span_data_model.dart +++ b/lib/pangea/choreographer/igc/span_data_model.dart @@ -4,8 +4,8 @@ import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/choreographer/igc/text_normalization_util.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'replacement_type_enum.dart'; import 'span_choice_type_enum.dart'; -import 'span_data_type_enum.dart'; class SpanData { final String? message; @@ -14,7 +14,7 @@ class SpanData { final int offset; final int length; final String fullText; - final SpanDataType type; + final ReplacementTypeEnum type; final Rule? rule; SpanData({ @@ -35,7 +35,7 @@ class SpanData { int? offset, int? length, String? fullText, - SpanDataType? type, + ReplacementTypeEnum? type, Rule? rule, }) { return SpanData( @@ -50,8 +50,28 @@ class SpanData { ); } - factory SpanData.fromJson(Map json) { + /// Parse SpanData from JSON. + /// + /// [parentFullText] is used as fallback when the span JSON doesn't contain + /// full_text (e.g., when the server omits it to reduce payload size and + /// the full text is available at the response level as original_input). + factory SpanData.fromJson( + Map json, { + String? parentFullText, + }) { final Iterable? choices = json['choices'] ?? json['replacements']; + final dynamic rawType = + json['type'] ?? json['type_name'] ?? json['typeName']; + final String? typeString = rawType is Map + ? (rawType['type_name'] ?? rawType['type'] ?? rawType['typeName']) + as String? + : rawType as String?; + + // Try to get fullText from span JSON, fall back to parent's original_input + final String? spanFullText = + json['sentence'] ?? json['full_text'] ?? json['fullText']; + final String fullText = spanFullText ?? parentFullText ?? ''; + return SpanData( message: json['message'], shortMessage: json['shortMessage'] ?? json['short_message'], @@ -62,9 +82,9 @@ class SpanData { .toList(), offset: json['offset'] as int, length: json['length'] as int, - fullText: - json['sentence'] ?? json['full_text'] ?? json['fullText'] as String, - type: SpanDataType.fromJson(json['type'] as Map), + fullText: fullText, + type: SpanDataTypeEnumExt.fromString(typeString) ?? + ReplacementTypeEnum.other, rule: json['rule'] != null ? Rule.fromJson(json['rule'] as Map) : null, @@ -76,7 +96,7 @@ class SpanData { 'offset': offset, 'length': length, 'full_text': fullText, - 'type': type.toJson(), + 'type': type.name, }; if (message != null) { @@ -135,7 +155,17 @@ class SpanData { String get errorSpan => fullText.characters.skip(offset).take(length).toString(); + /// Whether this span is a minor correction that should be auto-applied. + /// Returns true if: + /// 1. The type is explicitly marked as auto-apply (e.g., punct, spell, cap, diacritics), OR + /// 2. For backwards compatibility with old data that lacks new types: + /// the type is NOT auto-apply AND the normalized strings match. bool isNormalizationError() { + // New data with explicit auto-apply types + if (type.isAutoApply) { + return true; + } + final correctChoice = choices ?.firstWhereOrNull( (c) => c.isBestCorrection, @@ -227,8 +257,8 @@ class SpanChoice { ? SpanChoiceTypeEnum.values.firstWhereOrNull( (element) => element.name == json['type'], ) ?? - SpanChoiceTypeEnum.bestCorrection - : SpanChoiceTypeEnum.bestCorrection, + SpanChoiceTypeEnum.suggestion + : SpanChoiceTypeEnum.suggestion, feedback: json['feedback'], selected: json['selected'] ?? false, timestamp: @@ -242,18 +272,15 @@ class SpanChoice { 'type': type.name, }; - if (selected) { - data['selected'] = selected; + // V2 format: use selected_at instead of separate selected + timestamp + if (selected && timestamp != null) { + data['selected_at'] = timestamp!.toIso8601String(); } if (feedback != null) { data['feedback'] = feedback; } - if (timestamp != null) { - data['timestamp'] = timestamp!.toIso8601String(); - } - return data; } @@ -264,7 +291,7 @@ class SpanChoice { return feedback!; } - bool get isBestCorrection => type == SpanChoiceTypeEnum.bestCorrection; + bool get isBestCorrection => type.isSuggestion; Color get color => type.color; @@ -318,39 +345,3 @@ class Rule { return id.hashCode; } } - -class SpanDataType { - final SpanDataTypeEnum typeName; - - const SpanDataType({ - required this.typeName, - }); - - factory SpanDataType.fromJson(Map json) { - final String? type = - json['typeName'] ?? json['type'] ?? json['type_name'] as String?; - return SpanDataType( - typeName: type != null - ? SpanDataTypeEnum.values - .firstWhereOrNull((element) => element.name == type) ?? - SpanDataTypeEnum.correction - : SpanDataTypeEnum.correction, - ); - } - - Map toJson() => { - 'type_name': typeName.name, - }; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is! SpanDataType) return false; - return other.typeName == typeName; - } - - @override - int get hashCode { - return typeName.hashCode; - } -} diff --git a/lib/pangea/choreographer/igc/span_data_type_enum.dart b/lib/pangea/choreographer/igc/span_data_type_enum.dart deleted file mode 100644 index d3cfa4b87..000000000 --- a/lib/pangea/choreographer/igc/span_data_type_enum.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -enum SpanDataTypeEnum { - definition, - practice, - correction, - itStart, -} - -extension SpanDataTypeEnumExt on SpanDataTypeEnum { - String get name { - switch (this) { - case SpanDataTypeEnum.definition: - return "definition"; - case SpanDataTypeEnum.practice: - return "practice"; - case SpanDataTypeEnum.correction: - return "correction"; - case SpanDataTypeEnum.itStart: - return "itStart"; - } - } - - String defaultPrompt(BuildContext context) { - switch (this) { - case SpanDataTypeEnum.definition: - return L10n.of(context).definitionDefaultPrompt; - case SpanDataTypeEnum.practice: - return L10n.of(context).practiceDefaultPrompt; - case SpanDataTypeEnum.correction: - return L10n.of(context).correctionDefaultPrompt; - case SpanDataTypeEnum.itStart: - return L10n.of(context).needsItMessage( - MatrixState.pangeaController.userController.userL2 - ?.getDisplayName(context) ?? - L10n.of(context).targetLanguage, - ); - } - } -} diff --git a/lib/pangea/choreographer/text_editing/pangea_text_controller.dart b/lib/pangea/choreographer/text_editing/pangea_text_controller.dart index 26691b94c..73b0e1d98 100644 --- a/lib/pangea/choreographer/text_editing/pangea_text_controller.dart +++ b/lib/pangea/choreographer/text_editing/pangea_text_controller.dart @@ -2,12 +2,13 @@ import 'package:flutter/material.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/choreographer/choreo_constants.dart'; import 'package:fluffychat/pangea/choreographer/igc/autocorrect_span.dart'; -import 'package:fluffychat/pangea/choreographer/igc/match_rule_id_model.dart'; import 'package:fluffychat/pangea/choreographer/igc/pangea_match_model.dart'; import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart'; import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart'; +import 'package:fluffychat/pangea/choreographer/igc/replacement_type_enum.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -34,19 +35,13 @@ class PangeaTextController extends TextEditingController { ); Color _underlineColor(PangeaMatch match) { + // Automatic corrections use primary color if (match.status == PangeaMatchStatusEnum.automatic) { - return const Color.fromARGB(187, 132, 96, 224); + return AppConfig.primaryColor.withAlpha(180); } - switch (match.match.rule?.id ?? "unknown") { - case MatchRuleIdModel.interactiveTranslation: - return const Color.fromARGB(187, 132, 96, 224); - case MatchRuleIdModel.tokenNeedsTranslation: - case MatchRuleIdModel.tokenSpanNeedsTranslation: - return const Color.fromARGB(186, 255, 132, 0); - default: - return const Color.fromARGB(149, 255, 17, 0); - } + // Use type-based coloring + return match.match.type.underlineColor(); } TextStyle _textStyle( diff --git a/lib/pangea/common/constants/model_keys.dart b/lib/pangea/common/constants/model_keys.dart index 445c5b8ff..74d28e6e3 100644 --- a/lib/pangea/common/constants/model_keys.dart +++ b/lib/pangea/common/constants/model_keys.dart @@ -107,6 +107,8 @@ class ModelKey { static const String currentText = "current"; static const String bestContinuance = "best_continuance"; static const String feedbackLang = "feedback_lang"; + static const String feedback = "feedback"; + static const String content = "content"; static const String transcription = "transcription"; static const String botTranscription = 'bot_transcription'; diff --git a/lib/pangea/common/models/base_request_model.dart b/lib/pangea/common/models/base_request_model.dart new file mode 100644 index 000000000..2ffd77a27 --- /dev/null +++ b/lib/pangea/common/models/base_request_model.dart @@ -0,0 +1,41 @@ +import 'package:fluffychat/pangea/common/constants/model_keys.dart'; +import 'package:fluffychat/pangea/learning_settings/gender_enum.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +/// Base request schema matching the backend's BaseRequestSchema. +/// Common fields for all LLM-based requests. +mixin BaseRequestModel { + /// User's native language code (L1) + String get userL1; + + /// User's target language code (L2) + String get userL2; + + /// User's CEFR proficiency level (defaults to "pre_a1") + String get userCefr; + + /// Convert to JSON map with common fields + Map toBaseJson() => { + ModelKey.userL1: userL1, + ModelKey.userL2: userL2, + ModelKey.cefrLevel: userCefr, + ModelKey.userGender: MatrixState + .pangeaController.userController.profile.userSettings.gender.string, + }; + + /// Injects user context (CEFR level, gender) into a request body. + /// Safely handles cases where MatrixState is not yet initialized. + /// Does not overwrite existing values. + static Map injectUserContext(Map body) { + final result = Map.from(body); + try { + final settings = + MatrixState.pangeaController.userController.profile.userSettings; + result[ModelKey.cefrLevel] ??= settings.cefrLevel.string; + result[ModelKey.userGender] ??= settings.gender.string; + } catch (_) { + // MatrixState not initialized - leave existing values or omit + } + return result; + } +} diff --git a/lib/pangea/common/models/llm_feedback_model.dart b/lib/pangea/common/models/llm_feedback_model.dart new file mode 100644 index 000000000..29e0a83ee --- /dev/null +++ b/lib/pangea/common/models/llm_feedback_model.dart @@ -0,0 +1,25 @@ +import 'package:fluffychat/pangea/common/constants/model_keys.dart'; + +/// Generic feedback schema matching the backend's LLMFeedbackSchema. +/// Used for providing user corrections to LLM-generated content. +class LLMFeedbackModel { + /// User's feedback text describing the issue + final String feedback; + + /// Original response that user is providing feedback on + final T content; + + /// Function to serialize the content to JSON + final Map Function(T) contentToJson; + + const LLMFeedbackModel({ + required this.feedback, + required this.content, + required this.contentToJson, + }); + + Map toJson() => { + ModelKey.feedback: feedback, + ModelKey.content: contentToJson(content), + }; +} diff --git a/lib/pangea/common/network/requests.dart b/lib/pangea/common/network/requests.dart index 4cf4e792c..7481db2fd 100644 --- a/lib/pangea/common/network/requests.dart +++ b/lib/pangea/common/network/requests.dart @@ -5,9 +5,7 @@ import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:fluffychat/pangea/common/constants/model_keys.dart'; -import 'package:fluffychat/pangea/learning_settings/gender_enum.dart'; -import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/pangea/common/models/base_request_model.dart'; class Requests { late String? accessToken; @@ -22,13 +20,10 @@ class Requests { required String url, required Map body, }) async { - body[ModelKey.cefrLevel] = MatrixState - .pangeaController.userController.profile.userSettings.cefrLevel.string; - body[ModelKey.userGender] = MatrixState - .pangeaController.userController.profile.userSettings.gender.string; + final enrichedBody = BaseRequestModel.injectUserContext(body); dynamic encoded; - encoded = jsonEncode(body); + encoded = jsonEncode(enrichedBody); final http.Response response = await http.post( Uri.parse(url), diff --git a/lib/pangea/common/network/urls.dart b/lib/pangea/common/network/urls.dart index a9b027e01..e8102bf55 100644 --- a/lib/pangea/common/network/urls.dart +++ b/lib/pangea/common/network/urls.dart @@ -29,7 +29,7 @@ class PApiUrls { static String languageDetection = "${PApiUrls._choreoEndpoint}/language_detection"; - static String igcLite = "${PApiUrls._choreoEndpoint}/grammar_lite"; + static String igcLite = "${PApiUrls._choreoEndpoint}/grammar_v2"; static String spanDetails = "${PApiUrls._choreoEndpoint}/span_details"; static String simpleTranslation = diff --git a/lib/pangea/common/utils/overlay.dart b/lib/pangea/common/utils/overlay.dart index 23bc2ff27..d7ecad999 100644 --- a/lib/pangea/common/utils/overlay.dart +++ b/lib/pangea/common/utils/overlay.dart @@ -226,6 +226,7 @@ class OverlayUtil { Choreographer choreographer, BuildContext context, VoidCallback showNextMatch, + Future Function(String) onFeedbackSubmitted, ) { MatrixState.pAnyState.closeAllOverlays(); showPositionedCard( @@ -236,6 +237,7 @@ class OverlayUtil { match: match, choreographer: choreographer, showNextMatch: showNextMatch, + onFeedbackSubmitted: onFeedbackSubmitted, ), maxHeight: 325, maxWidth: 325, diff --git a/test/pangea/igc_response_model_test.dart b/test/pangea/igc_response_model_test.dart new file mode 100644 index 000000000..d26e40151 --- /dev/null +++ b/test/pangea/igc_response_model_test.dart @@ -0,0 +1,246 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/pangea/choreographer/igc/igc_response_model.dart'; + +void main() { + group('IGCResponseModel.fromJson', () { + test('passes originalInput to matches as fullText fallback', () { + final Map jsonData = { + 'original_input': 'I want to know the United States', + 'full_text_correction': null, + 'matches': [ + { + 'match': { + 'message': 'Grammar error', + 'short_message': 'grammar', + 'choices': [ + {'value': 'learn about', 'type': 'bestCorrection'}, + ], + 'offset': 10, + 'length': 4, + // Note: no full_text in match - should use original_input + 'type': 'grammar', + }, + 'status': 'open', + }, + ], + 'user_l1': 'en', + 'user_l2': 'es', + 'enable_it': true, + 'enable_igc': true, + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.matches.length, 1); + expect( + response.matches[0].match.fullText, + 'I want to know the United States', + ); + }); + + test('match full_text takes precedence over originalInput', () { + final Map jsonData = { + 'original_input': 'Original input text', + 'full_text_correction': null, + 'matches': [ + { + 'match': { + 'message': 'Grammar error', + 'short_message': 'grammar', + 'choices': [ + {'value': 'correction', 'type': 'bestCorrection'}, + ], + 'offset': 0, + 'length': 5, + 'full_text': 'Full text from span', // This should take precedence + 'type': 'grammar', + }, + 'status': 'open', + }, + ], + 'user_l1': 'en', + 'user_l2': 'es', + 'enable_it': true, + 'enable_igc': true, + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.matches.length, 1); + expect(response.matches[0].match.fullText, 'Full text from span'); + }); + + test('handles empty matches array', () { + final Map jsonData = { + 'original_input': 'Clean text with no errors', + 'full_text_correction': null, + 'matches': [], + 'user_l1': 'en', + 'user_l2': 'es', + 'enable_it': true, + 'enable_igc': true, + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.matches.length, 0); + expect(response.originalInput, 'Clean text with no errors'); + }); + + test('handles null matches', () { + final Map jsonData = { + 'original_input': 'Text', + 'full_text_correction': null, + 'matches': null, + 'user_l1': 'en', + 'user_l2': 'es', + 'enable_it': true, + 'enable_igc': true, + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.matches.length, 0); + }); + }); + + group('IGCResponseModel V2 format compatibility', () { + test('parses V2 response without enable_it and enable_igc', () { + // V2 response format from /choreo/grammar_v2 endpoint + final Map jsonData = { + 'original_input': 'Me gusta el café', + 'matches': [ + { + // V2 format: flat SpanData, no "match" wrapper + 'choices': [ + { + 'value': 'Me encanta', + 'type': 'bestCorrection', + 'feedback': 'Use "encantar" for expressing love', + }, + ], + 'offset': 0, + 'length': 8, + 'type': 'vocabulary', + }, + ], + 'user_l1': 'en', + 'user_l2': 'es', + // Note: no enable_it, enable_igc in V2 response + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.originalInput, 'Me gusta el café'); + expect(response.userL1, 'en'); + expect(response.userL2, 'es'); + // Should default to true when not present + expect(response.enableIT, true); + expect(response.enableIGC, true); + expect(response.matches.length, 1); + expect(response.matches[0].match.offset, 0); + expect(response.matches[0].match.length, 8); + }); + + test('parses V2 response with empty matches', () { + final Map jsonData = { + 'original_input': 'Perfect sentence with no errors', + 'matches': [], + 'user_l1': 'en', + 'user_l2': 'fr', + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.matches.length, 0); + expect(response.enableIT, true); + expect(response.enableIGC, true); + expect(response.fullTextCorrection, isNull); + }); + + test('parses V2 response with multiple matches', () { + final Map jsonData = { + 'original_input': 'Yo soy ir a la tienda', + 'matches': [ + { + 'choices': [ + { + 'value': 'voy', + 'type': 'bestCorrection', + 'feedback': 'Use conjugated form', + }, + ], + 'offset': 7, + 'length': 2, + 'type': 'grammar', + }, + { + 'choices': [ + {'value': 'Voy', 'type': 'bestCorrection'}, + ], + 'offset': 0, + 'length': 6, + 'type': 'grammar', + }, + ], + 'user_l1': 'en', + 'user_l2': 'es', + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.matches.length, 2); + expect(response.matches[0].match.offset, 7); + expect(response.matches[1].match.offset, 0); + }); + + test('V1 format with explicit enable_it=false still works', () { + final Map jsonData = { + 'original_input': 'Test text', + 'full_text_correction': 'Corrected text', + 'matches': [], + 'user_l1': 'en', + 'user_l2': 'es', + 'enable_it': false, + 'enable_igc': false, + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.enableIT, false); + expect(response.enableIGC, false); + expect(response.fullTextCorrection, 'Corrected text'); + }); + + test('V2 response choice includes feedback field', () { + final Map jsonData = { + 'original_input': 'Je suis alle', + 'matches': [ + { + 'choices': [ + { + 'value': 'allé', + 'type': 'bestCorrection', + 'feedback': 'Add accent to past participle', + }, + ], + 'offset': 8, + 'length': 4, + 'type': 'diacritics', + }, + ], + 'user_l1': 'en', + 'user_l2': 'fr', + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.matches.length, 1); + expect( + response.matches[0].match.bestChoice?.feedback, + 'Add accent to past participle', + ); + }); + }); +} diff --git a/test/pangea/pangea_match_model_test.dart b/test/pangea/pangea_match_model_test.dart new file mode 100644 index 000000000..73783dcd8 --- /dev/null +++ b/test/pangea/pangea_match_model_test.dart @@ -0,0 +1,179 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/pangea/choreographer/igc/pangea_match_model.dart'; +import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart'; + +void main() { + group('PangeaMatch.fromJson', () { + group('V1 format (wrapped with match key)', () { + test('parses match wrapper correctly', () { + final Map jsonData = { + 'match': { + 'message': 'Grammar error', + 'short_message': 'grammar', + 'choices': [ + {'value': 'correction', 'type': 'bestCorrection'}, + ], + 'offset': 10, + 'length': 4, + 'full_text': 'Some full text', + 'type': 'grammar', + }, + 'status': 'open', + }; + + final PangeaMatch match = PangeaMatch.fromJson(jsonData); + + expect(match.match.offset, 10); + expect(match.match.length, 4); + expect(match.match.fullText, 'Some full text'); + expect(match.status, PangeaMatchStatusEnum.open); + }); + + test('uses parentFullText as fallback when no full_text in match', () { + final Map jsonData = { + 'match': { + 'message': 'Error', + 'choices': [ + {'value': 'fix', 'type': 'bestCorrection'}, + ], + 'offset': 5, + 'length': 3, + 'type': 'grammar', + }, + 'status': 'open', + }; + + final PangeaMatch match = PangeaMatch.fromJson( + jsonData, + fullText: 'Parent original input', + ); + + expect(match.match.fullText, 'Parent original input'); + }); + + test('parses status from V1 format', () { + final Map jsonData = { + 'match': { + 'message': 'Error', + 'choices': [], + 'offset': 0, + 'length': 1, + 'type': 'grammar', + }, + 'status': 'accepted', + }; + + final PangeaMatch match = PangeaMatch.fromJson(jsonData); + + expect(match.status, PangeaMatchStatusEnum.accepted); + }); + }); + + group('V2 format (flat SpanData)', () { + test('parses flat SpanData correctly', () { + final Map jsonData = { + 'message': 'Grammar error', + 'short_message': 'grammar', + 'choices': [ + {'value': 'correction', 'type': 'bestCorrection'}, + ], + 'offset': 10, + 'length': 4, + 'type': 'grammar', + }; + + final PangeaMatch match = PangeaMatch.fromJson(jsonData); + + expect(match.match.offset, 10); + expect(match.match.length, 4); + expect(match.match.message, 'Grammar error'); + // V2 format always defaults to open status + expect(match.status, PangeaMatchStatusEnum.open); + }); + + test('uses parentFullText when provided', () { + final Map jsonData = { + 'message': 'Error', + 'choices': [ + {'value': 'fix', 'type': 'bestCorrection'}, + ], + 'offset': 5, + 'length': 3, + 'type': 'vocabulary', + }; + + final PangeaMatch match = PangeaMatch.fromJson( + jsonData, + fullText: 'The original input text', + ); + + expect(match.match.fullText, 'The original input text'); + }); + + test('parses type as string in V2 format', () { + final Map jsonData = { + 'message': 'Out of target', + 'choices': [], + 'offset': 0, + 'length': 5, + 'type': 'itStart', // String type in V2 + }; + + final PangeaMatch match = PangeaMatch.fromJson(jsonData); + + expect(match.isITStart, true); + }); + + test('handles V2 format with string type grammar', () { + final Map jsonData = { + 'message': 'Tense error', + 'choices': [ + {'value': 'went', 'type': 'bestCorrection'}, + ], + 'offset': 2, + 'length': 4, + 'type': 'grammar', // String type in V2 + }; + + final PangeaMatch match = PangeaMatch.fromJson(jsonData); + + expect(match.isGrammarMatch, true); + expect(match.isITStart, false); + }); + }); + + group('backward compatibility', () { + test('V1 format with type as object still works', () { + final Map jsonData = { + 'match': { + 'message': 'Error', + 'choices': [], + 'offset': 0, + 'length': 1, + 'type': {'type_name': 'grammar'}, // Old object format + }, + 'status': 'open', + }; + + final PangeaMatch match = PangeaMatch.fromJson(jsonData); + + expect(match.isGrammarMatch, true); + }); + + test('V2 format with type as string works', () { + final Map jsonData = { + 'message': 'Error', + 'choices': [], + 'offset': 0, + 'length': 1, + 'type': 'grammar', // New string format + }; + + final PangeaMatch match = PangeaMatch.fromJson(jsonData); + + expect(match.isGrammarMatch, true); + }); + }); + }); +} diff --git a/test/pangea/span_data_model_test.dart b/test/pangea/span_data_model_test.dart new file mode 100644 index 000000000..cef2755c2 --- /dev/null +++ b/test/pangea/span_data_model_test.dart @@ -0,0 +1,178 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/pangea/choreographer/igc/replacement_type_enum.dart'; +import 'package:fluffychat/pangea/choreographer/igc/span_data_model.dart'; + +void main() { + test('SpanData.fromJson handles legacy correction type (maps to grammar)', + () { + final Map legacyJson = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'full_text': 'Test', + 'type': { + 'type_name': 'correction', + }, + }; + + expect(() => SpanData.fromJson(legacyJson), returnsNormally); + final SpanData span = SpanData.fromJson(legacyJson); + // 'correction' is mapped to 'grammar' for backward compatibility + expect(span.type, ReplacementTypeEnum.subjectVerbAgreement); + }); + + test('SpanData.fromJson handles legacy typeName object', () { + final Map legacyJson = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'full_text': 'Test', + 'type': { + 'typeName': 'itStart', + }, + }; + + expect(() => SpanData.fromJson(legacyJson), returnsNormally); + final SpanData span = SpanData.fromJson(legacyJson); + expect(span.type, ReplacementTypeEnum.itStart); + }); + + test('SpanData.fromJson handles did_you_mean string', () { + final Map jsonData = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'full_text': 'Test', + 'type': 'did_you_mean', + }; + + expect(() => SpanData.fromJson(jsonData), returnsNormally); + final SpanData span = SpanData.fromJson(jsonData); + expect(span.type, ReplacementTypeEnum.didYouMean); + }); + + test('SpanData.fromJson handles legacy vocabulary type (maps to wordChoice)', + () { + final Map legacyJson = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'full_text': 'Test', + 'type': 'vocabulary', + }; + + expect(() => SpanData.fromJson(legacyJson), returnsNormally); + final SpanData span = SpanData.fromJson(legacyJson); + expect(span.type, ReplacementTypeEnum.other); + }); + + test('SpanData.fromJson handles new grammar type directly', () { + final Map jsonData = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'full_text': 'Test', + 'type': 'grammar', + }; + + expect(() => SpanData.fromJson(jsonData), returnsNormally); + final SpanData span = SpanData.fromJson(jsonData); + expect(span.type, ReplacementTypeEnum.subjectVerbAgreement); + }); + + test('SpanData.fromJson handles translation type', () { + final Map jsonData = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'full_text': 'Test', + 'type': 'translation', + }; + + expect(() => SpanData.fromJson(jsonData), returnsNormally); + final SpanData span = SpanData.fromJson(jsonData); + expect(span.type, ReplacementTypeEnum.translation); + }); + + group('SpanData.fromJson fullText fallback', () { + test('uses full_text from JSON when present', () { + final Map jsonData = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'full_text': 'Text from span', + 'type': 'grammar', + }; + + final SpanData span = SpanData.fromJson( + jsonData, + parentFullText: 'Text from parent', + ); + expect(span.fullText, 'Text from span'); + }); + + test('uses parentFullText when full_text not in JSON', () { + final Map jsonData = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + // Note: no full_text field + 'type': 'grammar', + }; + + final SpanData span = SpanData.fromJson( + jsonData, + parentFullText: 'Text from parent', + ); + expect(span.fullText, 'Text from parent'); + }); + + test('uses empty string when neither full_text nor parentFullText present', + () { + final Map jsonData = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'type': 'grammar', + }; + + final SpanData span = SpanData.fromJson(jsonData); + expect(span.fullText, ''); + }); + + test('prefers sentence over full_text (legacy field name)', () { + final Map jsonData = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'sentence': 'Text from sentence field', + 'full_text': 'Text from full_text field', + 'type': 'grammar', + }; + + final SpanData span = SpanData.fromJson(jsonData); + expect(span.fullText, 'Text from sentence field'); + }); + }); +} From 6b33ae6ce8f0339dce241936e8b3b6f2720b5a1b Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:56:43 -0500 Subject: [PATCH 12/12] Merge main into prod (#5603) * fix: restrict height of dropdowns in user menu popup * chore: make sso button order consistent * fix: use latest edit to make representations * chore: show tooltip on full phonetic transcription widget * chore: shrink tooltip text size Also give it maxTimelineWidth in chat to match other widgets placement, and give slightly less padding between icons * feat: show audio message transcripts in vocab practice * moved some logic around * chore: check for button in showMessageShimmer * fix: show error message when not enough data for practice * fix: clear selected token in activity vocab display on word card dismissed * chore: throw expection while loading practice session is user is unsubscribed * fix: account for blocked and capped constructs in analytics download model * chore: save voice in TTS events and re-request if requested voice doesn't match saved voice * Fix grammar error null error and only reload current question upon encountering error * fix: filter RoomMemberChangeType.other events from timeline * chore: store font size settings per-user * fix: oops, don't return null from representationByLanguage (#5301) * feat: expose construct level up stream * 5259 bot settings language settings (#5305) * feat: add voice to user model * update bot settings on language / learning settings update * use room summary to determine member count * translations * chore: Remove sentence-level pronunciation (#5306) * fix: use sync stream to update analytics requests indicator (#5307) * fix: disable text scaling in learning progress indicators (#5313) * fix: don't auto-play bot audio message if another audio message is playing (#5315) * fix: restrict when analytics practice session loss popup is shown (#5316) * feat: rise and fade animation for construct levels * fix: hide info about course editing in join mode (#5317) * chore: update knock copy (#5318) * fix: switch back to flutter's built in dropdown for cerf level dropdown menu (#5322) * fix: fix public room sheet navigation (#5323) * fix: update some Russion translations (#5324) * feat: bring back old course pages (#5328) * fix: add more space between text and underline for highlighted tokens (#5332) * chore: close emoji picker on send message (#5336) * chore: add copy asking user to search for users in invite public tab (#5338) * chore: hide invite all in space button if everyone from space is already in room (#5340) * fix: enable language mismatch popup for activity langs that match l1 (#5341) * chore: remove set status button in settings (#5343) * chore: hide option to seperate chat types (#5345) * add translations for error questions and some spacing tweaks to improve layout and overflow issues * forgot to push file and formatting * feat: enable emoji search (#5350) * re-enable choice notifier * fix syntax * fix: reset audio player after auto-playing bot voice message (#5353) * fix: set explicit height for expanded nav rail item section (#5356) * fix: move onTap call up a level in widget tree (#5359) * chore: increase hitbox size of mini analytics navigation buttons * chore: clamp number of points shown in gain points animation * chore: reverse change to cefr level display in saved activities * chore: empty analytics usage dots display update * simplify growth animation remove stream, calculate manually with the analytics feedback for XP, new vocab and new morphs * chore: update disabled toolbar button color * cleanup * Limit activity role to 2 lines, use ellipses if needed * fetch translation on activity target generation * Disable l1 translation for audio messages * fix: use token offset and length to determine where to highlight in example messages * Hide view status toggle in style view * Hide status message when viewing profile * Add tooltip to course analytics button * feat: add progress bar to IT bar * chore: show loading indicator on recording dialog start up * fix: prevent out-of-date lemma loading futures from overriding new futures * chore: If IGC change is different by a whitespace, apply automatically * chore: prevent UI block on save activity * chore: Darken Screen further on Activity End Popup * chore: show shimmer on full activity role indicator * fix: use event stream for construct level animation * remove async function for analytics in chat and sort imports * chore: block notification permission request on app launch * fix: uncomment shouldShowActivityInstructions * feat: use image as activity background - add switch tile in settings to toggle - if set, remove image from activity summary widget * feat: add alert to notification settings to enable notifications * translations * add back bot settings widgets * chore: If link, treat as regular message * feat: highlight chat with support * fix: reset bypassExitConfirmation on session-level error * Add default images when activity doesn't have image * feat: Bring back language setting in bot avatar popup * chore: better match tooltip style * chore: update constant in level equation to make 6000 xp ~level 10 * chore: keep input focused after send * chore: if mobile keyboard open on show toolbar, close it and still show toolbar * fix: add padding to bottom of main chat list to make all items visible * chore: Expand role card if needed/available space * fix: account for smaller screens * fix: remove public course route between find a course and public course preview * fix: prevent avatar flickering on expand nav rail * fix: only allow one line of text in grammar match choices * chore: Default courses to public but restricted * chore: Keep cursor as hand when mousing over word-card emojis * fix: use unique storage key for morph info cache * fix: give morph definition a fixed height to prevent other element from jumping around * chore: Search for course filter not saved when open new course page * fix: Prevent Grammar Practice Blank Fill-Ins (#5464) * feat: filter out new constructs with category 'other' (#5454) * fix: always show scroll bars in activity user summary widgets (#5465) * fix: distinguish constuct level up animations by construct ID instead of count (#5468) * chore: Keep Tooltip until word enters Catagory (#5469) * feat: filter 'other' constructs from existing analytics data (#5473) * fix: don't include error span as choice in grammar error practice if the translation contains the error span (#5474) * chore: translation button style update translation appears in message bubble like in chat with a pressable button and sound effect * 5415 if invalid lemma definition breaks practice (#5466) * skip error causing lemmas in practice * update progress on skipping and play audio/update value after loading question, so a skipped questions isn't displayed * remove unnecessary line and comment * fix: don't label room as activity room if activityID is null (#5480) * chore: onboarding updates (#5485) * chore: update logic for which bot chats are targeted for bot options update on language update, add retry logic (#5488) * chore: ensure grammar category has example and multiple choices * chore: add subtitle to chat with support tile (#5494) * Use vocab symbol for newly collected words (#5489) * Show different course plan page if 500 error is detected (#5478) * Show different course plan page if 500 error is detected * translations --------- Co-authored-by: ggurdin * chore: In user search, append needed decorators (#5495) * Move login/signup back buttons closer to center of screen (#5496) * fix: better message offset defaults (#5497) * chore: more onboarding tweaks (#5499) * chore: don't give normalization errors or single choices * chore: update room summary model (#5502) * fix: Don't shimmer disabled translation button (#5505) * chore: skip recently practiced grammar errors wip: only partially works due to analytics not being given to every question * feat: initial updates to public course preview page (#5453) * feat: initial updates to public course preview page * chore: account for join rules and power levels in RoomSummaryResponse * load room preview in course preview page * seperate public course preview page from selected course page * display course admins * Add avatar URL and display name to room summary. Get courseID from room summary * don't leave page on knock * fix: on IT closed, only replace source text if IT manually dismissed to prevent race condition with accepted continuance stream for single-span translation (#5510) * fix: reset IT progress on send and on edit (#5511) * chore: show close button on error snackbar (#5512) * fix: make analytics practice view scrollable, fix heights of top elements to prevent jumping around (#5513) * fix: save activities to analytics room for corresponding language (#5514) * chore: make login and signup views more consistent (#5518) * fix: return capped uses allows all grammar error targets to be searched for recent uses and filtered out, even maxed out ones * fix: prevent activity title from jumping on phonetic transcription load (#5519) * chore: fix inkwell border radius in activity summary (#5520) * fix: listen to scroll metrics to update scroll down button (#5522) * chore: update copy for auto-igc toggle (#5523) * chore: error on empty audio recording (#5524) * chore: show correct answer hint button and don't show answer description on selection of correct answer * make grammar icons larger and more spaced * chore: update bot target gender on user settings gender update (#5528) * fix: use correct stripe management URL in staging environment (#5530) * fix: update activity analytics stream on reinit analytics (#5532) * chore: add padding to extended activity description (#5534) * chore: don't add artificial profile to DM search results (#5535) * fix: update language chips materialTapTargetSize (#5538) * fix: add exampleMessage to AnalyticsActivityTarget and remove it from PracticeTarget * fix: only call getUses once in fetchErrors * feat: make deeplinks work for public course preview page (#5540) * fix: use stream to always update saved activity list on language update (#5541) * fix: use MorphInfoRepo to filter valid morph categories * feat: track end date on cancel subscription click and refresh page when end date changes (#5542) * initial work to add enable notifications to onboarding * notification page navigation * chore: add morphExampleInfo to activity model * fix: missing line * fix login redirect * move try-catch into request permission function * fix typos, dispose value notifier * fix: update UI on reply / edit event update * fix: update data type of user genders in bot options model * fix: move use activity image background setting into pangea user-specific style settings * fix: one click to close word card in activity vocab * fix: don't show error on cancel add recovery email * fix: filter edited events from search results * feat: add new parts of speech (idiom, phrasal verb, compound) and update localization (#5564) * fix: include stt for audio messages in level summary request * fix: don't pop from language selection page when not possible * fix: add new parts of speech to function for getting grammar copy (#5586) * chore: bump version to 4.1.17+7 --------- Co-authored-by: Ava Shilling <165050625+avashilling@users.noreply.github.com> Co-authored-by: Kelrap Co-authored-by: Kelrap <99418823+Kelrap@users.noreply.github.com> Co-authored-by: wcjord <32568597+wcjord@users.noreply.github.com> --- android/app/src/main/AndroidManifest.xml | 2 +- ios/Podfile | 3 + ios/Runner/Info.plist | 2 +- lib/config/app_config.dart | 1 + lib/config/routes.dart | 196 ++--- lib/l10n/intl_ar.arb | 191 ++++- lib/l10n/intl_be.arb | 191 ++++- lib/l10n/intl_bn.arb | 191 ++++- lib/l10n/intl_bo.arb | 191 ++++- lib/l10n/intl_ca.arb | 191 ++++- lib/l10n/intl_cs.arb | 191 ++++- lib/l10n/intl_da.arb | 191 ++++- lib/l10n/intl_de.arb | 191 ++++- lib/l10n/intl_el.arb | 191 ++++- lib/l10n/intl_en.arb | 58 +- lib/l10n/intl_eo.arb | 191 ++++- lib/l10n/intl_es.arb | 183 ++++- lib/l10n/intl_et.arb | 191 ++++- lib/l10n/intl_eu.arb | 191 ++++- lib/l10n/intl_fa.arb | 191 ++++- lib/l10n/intl_fi.arb | 191 ++++- lib/l10n/intl_fil.arb | 191 ++++- lib/l10n/intl_fr.arb | 191 ++++- lib/l10n/intl_ga.arb | 191 ++++- lib/l10n/intl_gl.arb | 191 ++++- lib/l10n/intl_he.arb | 191 ++++- lib/l10n/intl_hi.arb | 191 ++++- lib/l10n/intl_hr.arb | 191 ++++- lib/l10n/intl_hu.arb | 191 ++++- lib/l10n/intl_ia.arb | 191 ++++- lib/l10n/intl_id.arb | 191 ++++- lib/l10n/intl_ie.arb | 191 ++++- lib/l10n/intl_it.arb | 191 ++++- lib/l10n/intl_ja.arb | 191 ++++- lib/l10n/intl_ka.arb | 191 ++++- lib/l10n/intl_ko.arb | 191 ++++- lib/l10n/intl_lt.arb | 191 ++++- lib/l10n/intl_lv.arb | 191 ++++- lib/l10n/intl_nb.arb | 191 ++++- lib/l10n/intl_nl.arb | 191 ++++- lib/l10n/intl_pl.arb | 191 ++++- lib/l10n/intl_pt.arb | 191 ++++- lib/l10n/intl_pt_BR.arb | 191 ++++- lib/l10n/intl_pt_PT.arb | 191 ++++- lib/l10n/intl_ro.arb | 191 ++++- lib/l10n/intl_ru.arb | 226 +++++- lib/l10n/intl_sk.arb | 191 ++++- lib/l10n/intl_sl.arb | 191 ++++- lib/l10n/intl_sr.arb | 191 ++++- lib/l10n/intl_sv.arb | 191 ++++- lib/l10n/intl_ta.arb | 191 ++++- lib/l10n/intl_te.arb | 191 ++++- lib/l10n/intl_th.arb | 191 ++++- lib/l10n/intl_tr.arb | 191 ++++- lib/l10n/intl_uk.arb | 191 ++++- lib/l10n/intl_vi.arb | 183 ++++- lib/l10n/intl_yue.arb | 191 ++++- lib/l10n/intl_zh.arb | 191 ++++- lib/l10n/intl_zh_Hant.arb | 191 ++++- lib/pages/chat/chat.dart | 224 +++--- lib/pages/chat/chat_emoji_picker.dart | 26 +- lib/pages/chat/chat_event_list.dart | 293 +++---- lib/pages/chat/chat_view.dart | 52 +- lib/pages/chat/events/audio_player.dart | 25 + lib/pages/chat/events/html_message.dart | 99 +-- lib/pages/chat/events/message.dart | 411 +++++----- lib/pages/chat/events/state_message.dart | 16 +- lib/pages/chat/recording_dialog.dart | 34 +- lib/pages/chat/reply_display.dart | 76 +- lib/pages/chat_list/chat_list.dart | 16 +- lib/pages/chat_list/chat_list_body.dart | 57 ++ lib/pages/chat_list/navi_rail_item.dart | 223 +++--- lib/pages/chat_search/chat_search_page.dart | 17 +- lib/pages/login/login.dart | 14 + .../new_private_chat/new_private_chat.dart | 20 +- .../onboarding/enable_notifications.dart | 125 +++ .../onboarding/space_code_onboarding.dart | 83 ++ .../space_code_onboarding_view.dart | 92 +++ lib/pages/settings/settings_view.dart | 44 +- lib/pages/settings_3pid/settings_3pid.dart | 3 + lib/pages/settings_chat/settings_chat.dart | 12 + .../settings_chat/settings_chat_view.dart | 6 + .../settings_notifications.dart | 6 + .../settings_notifications_view.dart | 32 + lib/pages/settings_style/settings_style.dart | 16 +- .../settings_style/settings_style_view.dart | 50 +- .../activity_planner/activity_plan_model.dart | 14 +- .../activity_participant_indicator.dart | 97 +-- .../activity_participant_list.dart | 110 ++- .../activity_room_extension.dart | 3 +- .../activity_chat_extension.dart | 12 +- .../activity_finished_status_message.dart | 45 +- .../activity_vocab_widget.dart | 103 ++- .../activity_session_start_page.dart | 12 +- .../activity_summary_widget.dart | 160 ++-- .../activity_user_summaries_widget.dart | 70 +- .../analytics_data_service.dart | 59 +- .../analytics_data/analytics_database.dart | 29 +- .../analytics_sync_controller.dart | 8 + .../analytics_update_dispatcher.dart | 51 +- .../analytics_update_events.dart | 17 + .../analytics_update_service.dart | 29 +- .../analytics_updater_mixin.dart | 16 + .../analytics_data/construct_merge_table.dart | 64 +- .../derived_analytics_data_model.dart | 3 +- .../level_up_analytics_service.dart | 17 +- .../analytics_details_popup.dart | 157 ++-- .../construct_xp_progress_bar.dart | 36 +- .../lemma_usage_dots.dart | 28 +- .../morph_analytics_list_view.dart | 1 + .../morph_details_view.dart | 8 +- .../morph_meaning_widget.dart | 7 +- .../vocab_analytics_details_view.dart | 51 +- .../vocab_analytics_list_tile.dart | 2 +- .../vocab_analytics_list_view.dart | 9 +- .../analytics_dowload_dialog.dart | 29 +- .../space_analytics_summary_model.dart | 12 +- .../analytics_misc/analytics_constants.dart | 2 +- .../client_analytics_extension.dart | 44 ++ .../analytics_misc/construct_type_enum.dart | 38 +- .../analytics_misc/construct_use_model.dart | 4 +- .../construct_use_type_enum.dart | 36 + .../analytics_misc/constructs_model.dart | 2 +- .../analytics_misc/example_message_util.dart | 158 ++++ .../analytics_misc/gain_points_animation.dart | 23 +- .../analytics_misc/growth_animation.dart | 97 +++ .../lemma_emoji_setter_mixin.dart | 1 - .../analytics_page/activity_archive.dart | 96 ++- .../analytics_practice_constants.dart | 5 + .../analytics_practice_page.dart | 561 ++++++++++++++ .../analytics_practice_session_model.dart | 280 +++++++ .../analytics_practice_session_repo.dart | 416 ++++++++++ .../analytics_practice_view.dart | 728 ++++++++++++++++++ .../choice_cards/audio_choice_card.dart | 6 +- .../choice_cards/game_choice_card.dart | 164 ++++ .../choice_cards/grammar_choice_card.dart | 81 ++ .../choice_cards/meaning_choice_card.dart | 6 +- .../completed_activity_session_view.dart | 227 ++++++ .../grammar_error_practice_generator.dart | 55 ++ .../morph_category_activity_generator.dart | 67 ++ .../percent_marker_bar.dart | 0 .../practice_timer_widget.dart} | 10 +- .../stat_card.dart | 0 .../vocab_audio_activity_generator.dart | 7 +- .../vocab_meaning_activity_generator.dart | 7 +- .../learning_progress_indicators.dart | 243 +++--- .../analytics_summary/progress_indicator.dart | 46 +- lib/pangea/bot/utils/bot_room_extension.dart | 44 +- .../bot/widgets/bot_chat_settings_dialog.dart | 133 ++-- .../widgets/chat_floating_action_button.dart | 1 + lib/pangea/chat/widgets/chat_input_bar.dart | 32 +- .../widgets/request_regeneration_button.dart | 59 -- .../chat_list/support_client_extension.dart | 12 + .../widgets/public_room_bottom_sheet.dart | 26 +- .../models/bot_options_model.dart | 145 ++-- .../pages/pangea_invitation_selection.dart | 27 +- .../pangea_invitation_selection_view.dart | 41 +- .../pages/space_details_content.dart | 4 +- .../utils/bot_client_extension.dart | 91 ++- .../widgets/language_level_dropdown.dart | 108 ++- .../choreographer/choreo_constants.dart | 1 + lib/pangea/choreographer/choreographer.dart | 51 +- .../choreographer/igc/igc_controller.dart | 5 +- .../choreographer/igc/span_data_model.dart | 5 +- .../igc/text_normalization_util.dart | 2 +- lib/pangea/choreographer/it/it_bar.dart | 24 +- .../choreographer/it/it_controller.dart | 15 +- lib/pangea/common/config/environment.dart | 4 +- lib/pangea/common/constants/local.key.dart | 1 + lib/pangea/common/constants/model_keys.dart | 2 + .../common/controllers/pangea_controller.dart | 18 +- lib/pangea/common/utils/async_state.dart | 8 +- lib/pangea/common/utils/overlay.dart | 27 + .../widgets/anchored_overlay_widget.dart | 4 +- .../common/widgets/shimmer_background.dart | 36 +- .../common/widgets/shrinkable_text.dart | 3 + .../widgets/tutorial_overlay_message.dart | 46 +- .../constructs/construct_identifier.dart | 14 + .../course_chats/course_chats_page.dart | 11 +- .../public_course_preview.dart | 188 +++++ .../public_course_preview_view.dart | 388 ++++++++++ .../course_creation/selected_course_page.dart | 58 +- .../course_creation/selected_course_view.dart | 66 +- .../courses/course_plan_builder.dart | 8 +- .../course_settings/course_settings.dart | 12 + lib/pangea/download/download_file_util.dart | 47 +- .../events/constants/pangea_event_types.dart | 2 + .../event_wrappers/pangea_message_event.dart | 33 +- lib/pangea/events/repo/tokens_repo.dart | 14 +- .../instructions/instructions_enum.dart | 15 +- .../instructions_inline_tooltip.dart | 17 +- .../join_codes/space_code_controller.dart | 13 - .../language_mismatch_repo.dart | 4 - .../p_settings_switch_list_tile.dart | 1 - .../learning_settings/settings_learning.dart | 36 +- .../settings_learning_view.dart | 273 +++---- .../learning_settings/voice_dropdown.dart | 59 ++ lib/pangea/lemmas/construct_xp_widget.dart | 37 - .../lemmas/lemma_highlight_emoji_row.dart | 164 ++-- lib/pangea/lemmas/lemma_info_repo.dart | 4 - lib/pangea/lemmas/lemma_meaning_builder.dart | 65 +- lib/pangea/lemmas/lemma_meaning_widget.dart | 77 -- lib/pangea/login/pages/add_course_page.dart | 316 ++++---- lib/pangea/login/pages/course_code_page.dart | 4 +- .../pages/create_pangea_account_page.dart | 4 +- lib/pangea/login/pages/find_course_page.dart | 534 +++++++++++++ .../login/pages/language_selection_page.dart | 125 ++- .../login/pages/login_options_view.dart | 26 +- lib/pangea/login/pages/new_course_page.dart | 14 +- lib/pangea/login/pages/pangea_login_view.dart | 19 +- .../login/pages/public_courses_page.dart | 704 +++++++++-------- lib/pangea/login/pages/signup_view.dart | 21 +- .../login/pages/signup_with_email_view.dart | 20 +- lib/pangea/login/widgets/p_sso_button.dart | 51 +- lib/pangea/login/widgets/p_sso_dialog.dart | 79 +- lib/pangea/morphs/get_grammar_copy.dart | 6 + .../morphs/morph_meaning/morph_info_repo.dart | 8 +- lib/pangea/morphs/parts_of_speech_enum.dart | 110 +-- .../phonetic_transcription_builder.dart | 66 +- .../phonetic_transcription_repo.dart | 48 +- .../phonetic_transcription_widget.dart | 141 ++-- .../activity_type_enum.dart | 56 +- .../emoji_activity_generator.dart | 10 +- .../lemma_activity_generator.dart | 10 +- .../lemma_meaning_activity_generator.dart | 10 +- .../message_activity_request.dart | 102 ++- .../morph_activity_generator.dart | 16 +- .../practice_activity_model.dart | 552 +++++++++---- .../practice_generation_repo.dart | 16 +- .../practice_activities/practice_target.dart | 86 +-- .../word_focus_listening_generator.dart | 10 +- .../analytics_request_indicator.dart | 96 ++- .../analytics_requests_repo.dart | 4 + .../download_space_analytics_dialog.dart | 18 +- .../space_analytics/space_analytics.dart | 2 + .../space_analytics/space_analytics_view.dart | 55 +- lib/pangea/spaces/space_constants.dart | 1 + .../spaces/space_navigation_column.dart | 74 +- .../pages/settings_subscription.dart | 46 +- .../pages/settings_subscription_view.dart | 5 +- .../repo/subscription_management_repo.dart | 20 +- .../text_to_speech_response_model.dart | 11 +- .../show_token_feedback_dialog.dart | 3 + .../layout/message_selection_positioner.dart | 40 +- .../toolbar/layout/overlay_message.dart | 35 +- .../message_practice/message_audio_card.dart | 4 +- .../message_morph_choice.dart | 16 +- .../practice_activity_card.dart | 15 +- .../message_practice/practice_controller.dart | 82 +- .../message_practice/practice_match_card.dart | 30 +- .../practice_record_controller.dart | 119 +++ .../reading_assistance_input_bar.dart | 4 +- .../token_practice_button.dart | 25 +- .../reading_assistance/new_word_overlay.dart | 9 +- .../select_mode_buttons.dart | 110 ++- .../select_mode_controller.dart | 39 +- .../stt_transcript_tokens.dart | 25 +- .../token_rendering_util.dart | 43 +- .../underline_text_widget.dart | 53 ++ lib/pangea/toolbar/token_rendering_mixin.dart | 5 + .../word_card/lemma_meaning_display.dart | 87 +-- .../toolbar/word_card/word_zoom_widget.dart | 10 +- .../user/pangea_push_rules_extension.dart | 23 + lib/pangea/user/style_settings_repo.dart | 82 ++ lib/pangea/user/user_controller.dart | 2 + lib/pangea/user/user_model.dart | 9 +- lib/pangea/user/user_search_extension.dart | 19 + .../choice_cards/game_choice_card.dart | 186 ----- .../completed_activity_session_view.dart | 292 ------- .../vocab_practice/vocab_practice_page.dart | 478 ------------ .../vocab_practice_session_model.dart | 253 ------ .../vocab_practice_session_repo.dart | 102 --- .../vocab_practice/vocab_practice_view.dart | 333 -------- lib/utils/background_push.dart | 47 +- lib/utils/error_reporter.dart | 3 + lib/utils/localized_exception_extension.dart | 10 + .../filtered_timeline_extension.dart | 3 +- lib/widgets/adaptive_dialogs/user_dialog.dart | 36 +- .../local_notifications_extension.dart | 42 + lib/widgets/matrix.dart | 41 +- lib/widgets/navigation_rail.dart | 51 +- pubspec.yaml | 2 +- 282 files changed, 19406 insertions(+), 6584 deletions(-) create mode 100644 lib/pages/onboarding/enable_notifications.dart create mode 100644 lib/pages/onboarding/space_code_onboarding.dart create mode 100644 lib/pages/onboarding/space_code_onboarding_view.dart create mode 100644 lib/pangea/analytics_misc/example_message_util.dart create mode 100644 lib/pangea/analytics_misc/growth_animation.dart create mode 100644 lib/pangea/analytics_practice/analytics_practice_constants.dart create mode 100644 lib/pangea/analytics_practice/analytics_practice_page.dart create mode 100644 lib/pangea/analytics_practice/analytics_practice_session_model.dart create mode 100644 lib/pangea/analytics_practice/analytics_practice_session_repo.dart create mode 100644 lib/pangea/analytics_practice/analytics_practice_view.dart rename lib/pangea/{vocab_practice => analytics_practice}/choice_cards/audio_choice_card.dart (88%) create mode 100644 lib/pangea/analytics_practice/choice_cards/game_choice_card.dart create mode 100644 lib/pangea/analytics_practice/choice_cards/grammar_choice_card.dart rename lib/pangea/{vocab_practice => analytics_practice}/choice_cards/meaning_choice_card.dart (93%) create mode 100644 lib/pangea/analytics_practice/completed_activity_session_view.dart create mode 100644 lib/pangea/analytics_practice/grammar_error_practice_generator.dart create mode 100644 lib/pangea/analytics_practice/morph_category_activity_generator.dart rename lib/pangea/{vocab_practice => analytics_practice}/percent_marker_bar.dart (100%) rename lib/pangea/{vocab_practice/vocab_timer_widget.dart => analytics_practice/practice_timer_widget.dart} (87%) rename lib/pangea/{vocab_practice => analytics_practice}/stat_card.dart (100%) rename lib/pangea/{vocab_practice => analytics_practice}/vocab_audio_activity_generator.dart (85%) rename lib/pangea/{vocab_practice => analytics_practice}/vocab_meaning_activity_generator.dart (86%) delete mode 100644 lib/pangea/chat/widgets/request_regeneration_button.dart create mode 100644 lib/pangea/chat_list/support_client_extension.dart create mode 100644 lib/pangea/course_creation/public_course_preview.dart create mode 100644 lib/pangea/course_creation/public_course_preview_view.dart create mode 100644 lib/pangea/learning_settings/voice_dropdown.dart delete mode 100644 lib/pangea/lemmas/construct_xp_widget.dart delete mode 100644 lib/pangea/lemmas/lemma_meaning_widget.dart create mode 100644 lib/pangea/login/pages/find_course_page.dart create mode 100644 lib/pangea/toolbar/message_practice/practice_record_controller.dart create mode 100644 lib/pangea/toolbar/reading_assistance/underline_text_widget.dart create mode 100644 lib/pangea/user/style_settings_repo.dart create mode 100644 lib/pangea/user/user_search_extension.dart delete mode 100644 lib/pangea/vocab_practice/choice_cards/game_choice_card.dart delete mode 100644 lib/pangea/vocab_practice/completed_activity_session_view.dart delete mode 100644 lib/pangea/vocab_practice/vocab_practice_page.dart delete mode 100644 lib/pangea/vocab_practice/vocab_practice_session_model.dart delete mode 100644 lib/pangea/vocab_practice/vocab_practice_session_repo.dart delete mode 100644 lib/pangea/vocab_practice/vocab_practice_view.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1b2e5b616..12d15f6a7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -134,7 +134,7 @@ + android:value="false" /> io.flutter.embedded_views_preview FlutterDeepLinkingEnabled - + diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 3617539e3..975f985a6 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -145,6 +145,7 @@ abstract class AppConfig { static bool sendPublicReadReceipts = true; static bool swipeRightToLeftToReply = true; static bool? sendOnEnter; + static bool useActivityImageAsChatBackground = true; static bool showPresences = true; // #Pangea // static bool displayNavigationRail = false; diff --git a/lib/config/routes.dart b/lib/config/routes.dart index a2487a95a..032bf08e5 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -5,10 +5,10 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:go_router/go_router.dart'; -import 'package:matrix/matrix_api_lite/generated/model.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/archive/archive.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat_access_settings/chat_access_settings_controller.dart'; @@ -21,6 +21,8 @@ import 'package:fluffychat/pages/device_settings/device_settings.dart'; import 'package:fluffychat/pages/login/login.dart'; import 'package:fluffychat/pages/new_group/new_group.dart'; import 'package:fluffychat/pages/new_private_chat/new_private_chat.dart'; +import 'package:fluffychat/pages/onboarding/enable_notifications.dart'; +import 'package:fluffychat/pages/onboarding/space_code_onboarding.dart'; import 'package:fluffychat/pages/settings/settings.dart'; import 'package:fluffychat/pages/settings_3pid/settings_3pid.dart'; import 'package:fluffychat/pages/settings_chat/settings_chat.dart'; @@ -38,6 +40,7 @@ import 'package:fluffychat/pangea/analytics_misc/analytics_navigation_util.dart' import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_page/activity_archive.dart'; import 'package:fluffychat/pangea/analytics_page/empty_analytics_page.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart'; import 'package:fluffychat/pangea/analytics_summary/level_analytics_details_content.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; import 'package:fluffychat/pangea/chat_settings/pages/edit_course.dart'; @@ -45,24 +48,25 @@ import 'package:fluffychat/pangea/chat_settings/pages/pangea_invitation_selectio import 'package:fluffychat/pangea/common/utils/p_vguard.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/course_creation/course_invite_page.dart'; +import 'package:fluffychat/pangea/course_creation/public_course_preview.dart'; import 'package:fluffychat/pangea/course_creation/selected_course_page.dart'; import 'package:fluffychat/pangea/join_codes/join_with_link_page.dart'; import 'package:fluffychat/pangea/learning_settings/settings_learning.dart'; -import 'package:fluffychat/pangea/login/pages/add_course_page.dart'; import 'package:fluffychat/pangea/login/pages/course_code_page.dart'; import 'package:fluffychat/pangea/login/pages/create_pangea_account_page.dart'; +import 'package:fluffychat/pangea/login/pages/find_course_page.dart'; import 'package:fluffychat/pangea/login/pages/language_selection_page.dart'; import 'package:fluffychat/pangea/login/pages/login_or_signup_view.dart'; import 'package:fluffychat/pangea/login/pages/new_course_page.dart'; -import 'package:fluffychat/pangea/login/pages/public_courses_page.dart'; import 'package:fluffychat/pangea/login/pages/signup.dart'; import 'package:fluffychat/pangea/space_analytics/space_analytics.dart'; import 'package:fluffychat/pangea/spaces/space_constants.dart'; import 'package:fluffychat/pangea/subscription/pages/settings_subscription.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:fluffychat/widgets/config_viewer.dart'; import 'package:fluffychat/widgets/layouts/empty_page.dart'; import 'package:fluffychat/widgets/layouts/two_column_layout.dart'; +import 'package:fluffychat/widgets/local_notifications_extension.dart'; import 'package:fluffychat/widgets/log_view.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/share_scaffold_dialog.dart'; @@ -206,98 +210,29 @@ abstract class AppRoutes { const CreatePangeaAccountPage(), ), ), + GoRoute( + path: 'notifications', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const EnableNotifications(), + ), + redirect: (context, state) async { + final redirect = + await PAuthGaurd.onboardingRedirect(context, state); + if (redirect != null) return redirect; + final enabled = await Matrix.of(context).notificationsEnabled; + if (enabled) return "/registration/course"; + return null; + }, + ), GoRoute( path: 'course', pageBuilder: (context, state) => defaultPageBuilder( context, state, - const AddCoursePage(route: 'registration'), + const SpaceCodeOnboarding(), ), - routes: [ - GoRoute( - path: 'private', - pageBuilder: (context, state) { - return defaultPageBuilder( - context, - state, - const CourseCodePage(), - ); - }, - ), - GoRoute( - path: 'public', - pageBuilder: (context, state) { - return defaultPageBuilder( - context, - state, - const PublicCoursesPage( - route: 'registration', - showFilters: false, - ), - ); - }, - routes: [ - GoRoute( - path: ':courseid', - pageBuilder: (context, state) { - return defaultPageBuilder( - context, - state, - SelectedCourse( - state.pathParameters['courseid']!, - SelectedCourseMode.join, - roomChunk: state.extra as PublicRoomsChunk?, - ), - ); - }, - ), - ], - ), - GoRoute( - path: 'own', - pageBuilder: (context, state) { - return defaultPageBuilder( - context, - state, - const NewCoursePage( - route: 'registration', - showFilters: false, - ), - ); - }, - routes: [ - GoRoute( - path: ':courseid', - pageBuilder: (context, state) { - return defaultPageBuilder( - context, - state, - SelectedCourse( - state.pathParameters['courseid']!, - SelectedCourseMode.launch, - ), - ); - }, - routes: [ - GoRoute( - path: 'invite', - pageBuilder: (context, state) { - return defaultPageBuilder( - context, - state, - CourseInvitePage( - state.pathParameters['courseid']!, - courseCreationCompleter: - state.extra as Completer?, - ), - ); - }, - ), - ], - ), - ], - ), - ], ), ], ), @@ -432,7 +367,7 @@ abstract class AppRoutes { pageBuilder: (context, state) => defaultPageBuilder( context, state, - const AddCoursePage(route: 'rooms'), + const FindCoursePage(), ), routes: [ GoRoute( @@ -445,41 +380,16 @@ abstract class AppRoutes { ); }, ), - GoRoute( - path: 'public', - pageBuilder: (context, state) { - return defaultPageBuilder( - context, - state, - const PublicCoursesPage( - route: 'rooms', - ), - ); - }, - routes: [ - GoRoute( - path: ':courseid', - pageBuilder: (context, state) { - return defaultPageBuilder( - context, - state, - SelectedCourse( - state.pathParameters['courseid']!, - SelectedCourseMode.join, - roomChunk: state.extra as PublicRoomsChunk?, - ), - ); - }, - ), - ], - ), GoRoute( path: 'own', pageBuilder: (context, state) { return defaultPageBuilder( context, state, - const NewCoursePage(route: 'rooms'), + NewCoursePage( + route: 'rooms', + initialLanguageCode: state.uri.queryParameters['lang'], + ), ); }, routes: [ @@ -514,6 +424,18 @@ abstract class AppRoutes { ), ], ), + GoRoute( + path: ':courseroomid', + pageBuilder: (context, state) { + return defaultPageBuilder( + context, + state, + PublicCoursePreview( + roomID: state.pathParameters['courseroomid']!, + ), + ); + }, + ), ], ), GoRoute( @@ -542,6 +464,18 @@ abstract class AppRoutes { ), redirect: loggedOutRedirect, routes: [ + GoRoute( + path: 'practice', + pageBuilder: (context, state) { + return defaultPageBuilder( + context, + state, + const AnalyticsPractice( + type: ConstructTypeEnum.morph, + ), + ); + }, + ), GoRoute( path: ':construct', pageBuilder: (context, state) { @@ -580,9 +514,29 @@ abstract class AppRoutes { return defaultPageBuilder( context, state, - const VocabPractice(), + const AnalyticsPractice( + type: ConstructTypeEnum.vocab, + ), ); }, + onExit: (context, state) async { + // Check if bypass flag was set before navigation + if (AnalyticsPractice.bypassExitConfirmation) { + AnalyticsPractice.bypassExitConfirmation = false; + return true; + } + + final result = await showOkCancelAlertDialog( + useRootNavigator: false, + context: context, + title: L10n.of(context).areYouSure, + okLabel: L10n.of(context).yes, + cancelLabel: L10n.of(context).cancel, + message: L10n.of(context).exitPractice, + ); + + return result == OkCancelResult.ok; + }, ), GoRoute( path: ':construct', diff --git a/lib/l10n/intl_ar.arb b/lib/l10n/intl_ar.arb index 37cbcb133..cebe2097f 100644 --- a/lib/l10n/intl_ar.arb +++ b/lib/l10n/intl_ar.arb @@ -1,6 +1,6 @@ { "@@locale": "ar", - "@@last_modified": "2026-01-07 14:27:10.974178", + "@@last_modified": "2026-02-05 10:09:56.397837", "about": "حول", "@about": { "type": "String", @@ -3670,8 +3670,6 @@ "noPaymentInfo": "لا حاجة لمعلومات الدفع!", "updatePhoneOS": "قد تحتاج إلى تحديث إصدار نظام تشغيل جهازك.", "wordsPerMinute": "كلمات في الدقيقة", - "autoIGCToolName": "تشغيل مساعدة الكتابة Pangea تلقائيًا", - "autoIGCToolDescription": "تشغيل مساعدة القواعد والترجمة في دردشة Pangea تلقائيًا قبل إرسال رسالتي.", "tooltipInstructionsTitle": "لست متأكدًا مما يفعله ذلك؟", "tooltipInstructionsMobileBody": "اضغط مع الاستمرار على العناصر لعرض تلميحات الأدوات.", "tooltipInstructionsBrowserBody": "مرر فوق العناصر لعرض تلميحات الأدوات.", @@ -4300,7 +4298,6 @@ "numModules": "{num} وحدات", "coursePlan": "خطة الدورة", "editCourseLater": "يمكنك تعديل عنوان النموذج، الأوصاف، وصورة الدورة لاحقًا.", - "newCourseAccess": "افتراضيًا، الدورات خاصة وتتطلب موافقة المسؤول للانضمام. يمكنك تعديل هذه الإعدادات في أي وقت.", "createCourse": "إنشاء دورة", "stats": "إحصائيات", "createGroupChat": "إنشاء دردشة جماعية", @@ -6423,14 +6420,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9049,10 +9038,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11042,5 +11027,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 لقد غادرت الدردشة", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "تم بدء التنزيل", + "webDownloadPermissionMessage": "إذا كان متصفحك يمنع التنزيلات، يرجى تمكين التنزيلات لهذا الموقع.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "لن يتم حفظ تقدم جلسة التدريب الخاصة بك.", + "practiceGrammar": "تدرب على القواعد", + "notEnoughToPractice": "أرسل المزيد من الرسائل لفتح التدريب", + "constructUseCorGCDesc": "تدريب على فئة القواعد الصحيحة", + "constructUseIncGCDesc": "تدريب على فئة القواعد غير الصحيحة", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "ممارسة تصحيح أخطاء القواعد", + "constructUseIncGEDesc": "ممارسة أخطاء القواعد غير الصحيحة", + "fillInBlank": "املأ الفراغ بالخيار الصحيح", + "learn": "تعلم", + "languageUpdated": "تم تحديث اللغة المستهدفة!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "صوت بوت بانجيا", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "تم إرسال طلبك إلى إدارة الدورة! سيتم السماح لك بالدخول إذا وافقوا.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "هل لديك رمز دعوة أو رابط لدورة عامة؟", + "welcomeUser": "مرحبًا {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "ابحث عن المستخدمين لدعوتهم إلى هذه الدردشة.", + "publicInviteDescSpace": "ابحث عن المستخدمين لدعوتهم إلى هذا الفضاء.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "بانجيا شات هو تطبيق رسائل لذا فإن الإشعارات مهمة!", + "enableNotificationsDesc": "السماح بالإشعارات", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "استخدم صورة النشاط كخلفية للدردشة", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "الدردشة مع الدعم", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "بشكل افتراضي، الدورات قابلة للبحث علنًا وتتطلب موافقة المسؤول للانضمام. يمكنك تعديل هذه الإعدادات في أي وقت.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "ما اللغة التي تتعلمها؟", + "searchLanguagesHint": "ابحث عن اللغات المستهدفة", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "أسئلة؟ نحن هنا للمساعدة!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "حدث خطأ ما، ونحن نعمل بجد على إصلاحه. تحقق مرة أخرى لاحقًا.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "تفعيل مساعدة الكتابة", + "autoIGCToolDescription": "تشغيل أدوات دردشة بانجيا تلقائيًا لتصحيح الرسائل المرسلة إلى اللغة المستهدفة.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "فشل التسجيل. يرجى التحقق من أذونات الصوت الخاصة بك والمحاولة مرة أخرى.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "تعبير اصطلاحي", + "grammarCopyPOSphrasalv": "فعل مركب", + "grammarCopyPOScompn": "مركب", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_be.arb b/lib/l10n/intl_be.arb index 8f328151c..72fb9a8ca 100644 --- a/lib/l10n/intl_be.arb +++ b/lib/l10n/intl_be.arb @@ -1187,8 +1187,6 @@ "noPaymentInfo": "Інфармацыя аб плацяжах не патрабуецца!", "updatePhoneOS": "Магчыма, вам трэба абнавіць версію аперацыйнай сістэмы вашага прылады.", "wordsPerMinute": "Словы за хвіліну", - "autoIGCToolName": "Аўтаматычна запускаць дапамогу для пісьма Pangea", - "autoIGCToolDescription": "Аўтаматычна запускаць дапамогу для граматыкі і перакладу ў чат-прыкладанні Pangea перад адпраўкай майго паведамлення.", "tooltipInstructionsTitle": "Не ўпэўнены, што гэта робіць?", "tooltipInstructionsMobileBody": "Затрымайце і трымайце элементы, каб праглядзець падказкі.", "tooltipInstructionsBrowserBody": "Навядзіце курсор на элементы, каб праглядзець падказкі.", @@ -1816,7 +1814,6 @@ "numModules": "{num} модулі", "coursePlan": "План курса", "editCourseLater": "Вы можаце рэдагаваць назву шаблона, апісанні і выяву курса пазней.", - "newCourseAccess": "Па змаўчанні курсы прыватныя і патрабуюць адабрэння адміністратара для далучэння. Вы можаце змяняць гэтыя налады ў любы час.", "createCourse": "Стварыць курс", "stats": "Статыстыка", "createGroupChat": "Стварыць групавы чат", @@ -1911,7 +1908,7 @@ "playWithAI": "Пакуль гуляйце з ШІ", "courseStartDesc": "Pangea Bot гатовы да працы ў любы час!\n\n...але навучанне лепш з сябрамі!", "@@locale": "be", - "@@last_modified": "2026-01-07 14:26:19.740329", + "@@last_modified": "2026-02-05 10:09:46.469770", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -7305,14 +7302,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9931,10 +9920,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11924,5 +11909,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Вы пакінулі чат", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Спампоўка ініцыявана", + "webDownloadPermissionMessage": "Калі ваш браўзер блакуе спампоўкі, калі ласка, уключыце спампоўкі для гэтага сайта.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Ваш практычны сеанс не будзе захаваны.", + "practiceGrammar": "Практыкаваць граматыку", + "notEnoughToPractice": "Адпраўце больш паведамленняў, каб разблакаваць практыку", + "constructUseCorGCDesc": "Практыка ў катэгорыі правільнай граматыкі", + "constructUseIncGCDesc": "Практыка ў катэгорыі няправільнай граматыкі", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Практыка правільнага выкарыстання граматычных памылак", + "constructUseIncGEDesc": "Практыка няправільнага выкарыстання граматычных памылак", + "fillInBlank": "Запоўніце прабел правільным выбарам", + "learn": "Навучыцца", + "languageUpdated": "Мэтавая мова абноўлена!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Голас Pangea Bot", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Ваш запыт быў адпраўлены адміністрацыі курса! Вы будзеце дапушчаны, калі яны зацвердзяць.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Ці маеце вы код запрашэння або спасылку на публічны курс?", + "welcomeUser": "Сардэчна запрашаем, {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Шукаць карыстальнікаў, каб запрасіць іх у гэты чат.", + "publicInviteDescSpace": "Шукаць карыстальнікаў, каб запрасіць іх у гэтае прастору.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat - гэта прыкладанне для адпраўкі паведамленняў, таму апавяшчэнні важныя!", + "enableNotificationsDesc": "Дазволіць апавяшчэнні", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Выкарыстоўвайце малюнак актыўнасці як фон чата", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Чат з падтрымкай", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Па змаўчанні курсы з'яўляюцца адкрытымі для пошуку і патрабуюць адабрэння адміністратара для далучэння. Вы можаце змяняць гэтыя налады ў любы час.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Якую мову вы вывучаеце?", + "searchLanguagesHint": "Пошук мэтавых моў", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Пытанні? Мы тут, каб дапамагчы!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Што-то пайшло не так, і мы актыўна працуем над выпраўленнем. Праверце пазней.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Уключыць дапамогу ў напісанні", + "autoIGCToolDescription": "Аўтаматычна запускаць інструменты Pangea Chat для выпраўлення адпраўленых паведамленняў на мэтавую мову.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Запіс не ўдалося. Калі ласка, праверце свае аўдыё дазволы і паспрабуйце яшчэ раз.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Ідыём", + "grammarCopyPOSphrasalv": "Фразавы дзеяслоў", + "grammarCopyPOScompn": "Складаны", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_bn.arb b/lib/l10n/intl_bn.arb index 0c4ceee1b..ce0510c60 100644 --- a/lib/l10n/intl_bn.arb +++ b/lib/l10n/intl_bn.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:28:06.931186", + "@@last_modified": "2026-02-05 10:10:08.830801", "about": "সম্পর্কে", "@about": { "type": "String", @@ -3779,8 +3779,6 @@ "noPaymentInfo": "কোনও পেমেন্ট তথ্য প্রয়োজন নয়!", "updatePhoneOS": "আপনার ডিভাইসের অপারেটিং সিস্টেমের সংস্করণ আপডেটের প্রয়োজন হতে পারে।", "wordsPerMinute": "প্রতি মিনিটে শব্দ", - "autoIGCToolName": "পাঙ্গিয়া লেখনী সহায়তা স্বয়ংক্রিয়ভাবে চালান", - "autoIGCToolDescription": "আমার বার্তা পাঠানোর আগে স্বয়ংক্রিয়ভাবে পাঙ্গিয়া চ্যাট ব্যাকরণ এবং অনুবাদ লেখনী সহায়তা চালান।", "tooltipInstructionsTitle": "এটি কি করে তা নিশ্চিত নন?", "tooltipInstructionsMobileBody": "আইটেমে চাপুন এবং ধরে রাখুন টুলটিপ দেখার জন্য।", "tooltipInstructionsBrowserBody": "আইটেমের উপর হোভার করে টুলটিপ দেখুন।", @@ -4407,7 +4405,6 @@ "numModules": "{num} মডিউল", "coursePlan": "কোর্স পরিকল্পনা", "editCourseLater": "আপনি পরে টেমপ্লেট শিরোনাম, বিবরণ, এবং কোর্স ছবি সম্পাদনা করতে পারেন।", - "newCourseAccess": "ডিফল্টভাবে, কোর্সগুলি ব্যক্তিগত এবং যোগদানের জন্য অ্যাডমিন অনুমোদন প্রয়োজন। আপনি এই সেটিংস যেকোন সময় সম্পাদনা করতে পারেন।", "createCourse": "কোর্স তৈরি করুন", "stats": "পরিসংখ্যান", "createGroupChat": "গ্রুপ চ্যাট তৈরি করুন", @@ -7310,14 +7307,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9936,10 +9925,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11929,5 +11914,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 আপনি চ্যাট ছেড়ে দিয়েছেন", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "ডাউনলোড শুরু হয়েছে", + "webDownloadPermissionMessage": "যদি আপনার ব্রাউজার ডাউনলোড ব্লক করে, অনুগ্রহ করে এই সাইটের জন্য ডাউনলোড সক্ষম করুন।", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "আপনার অনুশীলন সেশনের অগ্রগতি সংরক্ষিত হবে না।", + "practiceGrammar": "ব্যাকরণ অনুশীলন করুন", + "notEnoughToPractice": "অনুশীলন আনলক করতে আরও বার্তা পাঠান", + "constructUseCorGCDesc": "সঠিক ব্যাকরণ বিভাগ অনুশীলন", + "constructUseIncGCDesc": "ভুল ব্যাকরণ বিভাগ অনুশীলন", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "সঠিক ব্যাকরণ ত্রুটি অনুশীলন", + "constructUseIncGEDesc": "ভুল ব্যাকরণ ত্রুটি অনুশীলন", + "fillInBlank": "সঠিক পছন্দ দিয়ে ফাঁকা স্থান পূরণ করুন", + "learn": "শিখুন", + "languageUpdated": "লক্ষ্য ভাষা আপডেট করা হয়েছে!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "প্যাঙ্গিয়া বটের কণ্ঠ", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "আপনার অনুরোধ কোর্স প্রশাসকের কাছে পাঠানো হয়েছে! তারা অনুমোদন করলে আপনাকে প্রবেশ করতে দেওয়া হবে।", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "আপনার কি একটি আমন্ত্রণ কোড বা একটি পাবলিক কোর্সের লিঙ্ক আছে?", + "welcomeUser": "স্বাগতম {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "এই চ্যাটে আমন্ত্রণ জানানোর জন্য ব্যবহারকারীদের খুঁজুন।", + "publicInviteDescSpace": "এই স্পেসে আমন্ত্রণ জানানোর জন্য ব্যবহারকারীদের খুঁজুন।", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "প্যাঙ্গিয়া চ্যাট একটি টেক্সটিং অ্যাপ, তাই নোটিফিকেশন গুরুত্বপূর্ণ!", + "enableNotificationsDesc": "নোটিফিকেশন অনুমোদন করুন", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "চ্যাট ব্যাকগ্রাউন্ড হিসেবে কার্যকলাপের ছবি ব্যবহার করুন", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "সমর্থনের সাথে চ্যাট করুন", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "ডিফল্টভাবে, কোর্সগুলি জনসাধারণের জন্য অনুসন্ধানযোগ্য এবং যোগদানের জন্য প্রশাসক অনুমোদনের প্রয়োজন। আপনি যে কোনও সময় এই সেটিংসগুলি সম্পাদনা করতে পারেন।", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "আপনি কোন ভাষা শিখছেন?", + "searchLanguagesHint": "লক্ষ্য ভাষা অনুসন্ধান করুন", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "প্রশ্ন আছে? আমরা সাহায্য করতে এখানে আছি!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "কিছু ভুল হয়েছে, এবং আমরা এটি ঠিক করতে কঠোর পরিশ্রম করছি। পরে আবার চেক করুন।", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "লেখার সহায়তা সক্রিয় করুন", + "autoIGCToolDescription": "লক্ষ্য ভাষায় পাঠানো বার্তা সংশোধন করতে স্বয়ংক্রিয়ভাবে প্যাঙ্গিয়া চ্যাট টুলগুলি চালান।", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "রেকর্ডিং ব্যর্থ হয়েছে। দয়া করে আপনার অডিও অনুমতিগুলি পরীক্ষা করুন এবং আবার চেষ্টা করুন।", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "বাগধারা", + "grammarCopyPOSphrasalv": "ফ্রেজাল ক্রিয়া", + "grammarCopyPOScompn": "যুগ্ম", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_bo.arb b/lib/l10n/intl_bo.arb index 6bb73e24c..7cf7f1681 100644 --- a/lib/l10n/intl_bo.arb +++ b/lib/l10n/intl_bo.arb @@ -3621,8 +3621,6 @@ "translationTooltip": "འབྲེལ་བའི་སྒྲོམ", "updatePhoneOS": "ཁྱེད་ཀྱི་རྒྱུན་ལས་སྤྱོད་ལམ་ལ་བསྐུར་བྱེད་དགོས་མིན་པ", "wordsPerMinute": "ཚིག་ལ་སྤྱོད་ལམ་ལ་བརྟེན་", - "autoIGCToolName": "ཕན་ཚུལ་ལས་འགན་སྤྱོད་ལས་སྤྱོད་ལམ་ལ་བརྟེན་", - "autoIGCToolDescription": "ཁྱེད་ཀྱི་དུས་སྐབས་སྤྱོད་ལམ་ལ་བརྟེན་པའི་ཕན་ཚུལ་ལས་འགན་སྤྱོད་ལས་སྤྱོད་ལམ་ལ་བརྟེན་", "tooltipInstructionsTitle": "དེ་ལ་གང་འདྲ་ཡོད་པ?", "tooltipInstructionsMobileBody": "རྟེན་འབྲེལ་དང་བསྟན་པའི་རྟེན་འབྲེལ་ལ་ལོག་བརྟེན་", "tooltipInstructionsBrowserBody": "རྟེན་འབྲེལ་ལ་ལོག་བརྟེན་", @@ -4211,7 +4209,6 @@ "numModules": "{num} ᠪᠣᠯᠣᠰ", "coursePlan": "ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ", "editCourseLater": "ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ", - "newCourseAccess": "ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ", "createCourse": "ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ", "stats": "ᠪᠠᠢᠯᠠᠭᠤᠯᠤ", "createGroupChat": "ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ ᠪᠠᠢᠯᠠᠭᠤᠯᠤ", @@ -4279,7 +4276,7 @@ "joinPublicTrip": "མི་ཚེས་ལ་ལོག་འབད།", "startOwnTrip": "ངེད་རང་གི་ལོག་ལ་སྦྱོར་བཅོས།", "@@locale": "bo", - "@@last_modified": "2026-01-07 14:27:54.438001", + "@@last_modified": "2026-02-05 10:10:06.262776", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -6725,14 +6722,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9183,10 +9172,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10579,5 +10564,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Oi saíste do chat", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Download initiated", + "webDownloadPermissionMessage": "If your browser blocks downloads, please enable downloads for this site.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Ndae bɔkɔɔ a wopɛ no, wo nsɛm a wopɛ sɛ woyɛ no bɛyɛ a, ɛrenyɛ.", + "practiceGrammar": "Bɔ mmara", + "notEnoughToPractice": "Sɛ wopɛ sɛ woyɛ bɔ mmara a, fa nsɛm pii to mu", + "constructUseCorGCDesc": "Nokware mmara kategorie bɔ mmara", + "constructUseIncGCDesc": "Nnokwa mmara kategorie bɔ mmara", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Praktik kesalahan tata bahasa yang benar", + "constructUseIncGEDesc": "Praktik kesalahan tata bahasa yang salah", + "fillInBlank": "Isi kekosongan dengan pilihan yang benar", + "learn": "Belajar", + "languageUpdated": "Bahasa target diperbarui!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Bot voz", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Yor requst has been sent to course admin! Yu'll be let in if dey approve.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Do you have an invite code or link to a public course?", + "welcomeUser": "Welcome {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Searc for users to invite them to this chat.", + "publicInviteDescSpace": "Searc for users to invite them to this space.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat je aplikacija za slanje poruka, pa su obaveštenja važna!", + "enableNotificationsDesc": "Dozvoli obaveštenja", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Bruk aktivitetsbilde som chatbakgrunn", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Chat with Support", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Biy default, kursi biyo publicly searchable e biyo require admin approval to join. Yu can edit these settings at any time.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Kedua bahasa apa yang Anda pelajari?", + "searchLanguagesHint": "Cari bahasa target", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Pytania? Jesteśmy tutaj, aby pomóc!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Nǐng bǐng wǒng, yǐng wǒng bǐng wǒng. Cǐng bǐng yǐng bǐng.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Buka bantuan nulis", + "autoIGCToolDescription": "Secara otomatis menjalankan alat Pangea Chat untuk memperbaiki pesan yang dikirim ke bahasa target.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Recording failed. Please check your audio permissions and try again.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Phrasal Verb", + "grammarCopyPOScompn": "Compound", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index 0291e8daf..e2777e39a 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:26:27.395689", + "@@last_modified": "2026-02-05 10:09:47.712187", "about": "Quant a", "@about": { "type": "String", @@ -3757,8 +3757,6 @@ "noPaymentInfo": "No cal informació de pagament!", "updatePhoneOS": "Pot ser que necessitis actualitzar la versió del sistema operatiu del teu dispositiu.", "wordsPerMinute": "Paraules per minut", - "autoIGCToolName": "Executa automàticament l'assistència d'escriptura Pangea", - "autoIGCToolDescription": "Executa automàticament l'assistència d'escriptura de gramàtica i traducció de Pangea abans d'enviar el meu missatge.", "tooltipInstructionsTitle": "No estàs segur de què fa això?", "tooltipInstructionsMobileBody": "Prem i mantén premut per veure les eines d'informació sobre les opcions.", "tooltipInstructionsBrowserBody": "Passa el cursor sobre els elements per veure les eines d'informació.", @@ -4386,7 +4384,6 @@ "numModules": "{num} mòduls", "coursePlan": "Pla de curs", "editCourseLater": "Pots editar el títol de la plantilla, les descripcions i la imatge del curs més tard.", - "newCourseAccess": "Per defecte, els cursos són privats i requereixen l'aprovació de l'administrador per unir-se. Pots editar aquests paràmetres en qualsevol moment.", "createCourse": "Crear curs", "stats": "Estadístiques", "createGroupChat": "Crear xat de grup", @@ -6230,14 +6227,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -8856,10 +8845,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10849,5 +10834,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Has deixat el xat", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Descàrrega iniciada", + "webDownloadPermissionMessage": "Si el teu navegador bloqueja les descàrregues, si us plau, activa les descàrregues per a aquest lloc.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "El teu progrés de la sessió de pràctica no es desarà.", + "practiceGrammar": "Practica gramàtica", + "notEnoughToPractice": "Envia més missatges per desbloquejar la pràctica", + "constructUseCorGCDesc": "Pràctica de la categoria de gramàtica correcta", + "constructUseIncGCDesc": "Pràctica de la categoria de gramàtica incorrecta", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Pràctica d'errors gramaticals correctes", + "constructUseIncGEDesc": "Pràctica d'errors gramaticals incorrectes", + "fillInBlank": "Omple el buit amb l'elecció correcta", + "learn": "Aprendre", + "languageUpdated": "Idioma objectiu actualitzat!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Veu del bot Pangea", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "La teva sol·licitud s'ha enviat a l'administrador del curs! Et deixaran entrar si ho aproven.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Tens un codi d'invitació o un enllaç a un curs públic?", + "welcomeUser": "Benvingut {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Cerca usuaris per convidar-los a aquest xat.", + "publicInviteDescSpace": "Cerca usuaris per convidar-los a aquest espai.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat és una aplicació de missatgeria, així que les notificacions són importants!", + "enableNotificationsDesc": "Permetre notificacions", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Utilitza la imatge d'activitat com a fons de xat", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Xateja amb el Suport", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Per defecte, els cursos són cercables públicament i requereixen l'aprovació de l'administrador per unir-se. Podeu editar aquestes configuracions en qualsevol moment.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Quina llengua estàs aprenent?", + "searchLanguagesHint": "Cerca llengües objectiu", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Preguntes? Som aquí per ajudar!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Alguna cosa ha anat malament, i estem treballant dur per solucionar-ho. Comprova-ho més tard.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Activar l'assistència d'escriptura", + "autoIGCToolDescription": "Executar automàticament les eines de Pangea Chat per corregir els missatges enviats a l'idioma de destinació.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "La gravació ha fallat. Si us plau, comproveu els vostres permisos d'àudio i torneu-ho a provar.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Verb Phrasal", + "grammarCopyPOScompn": "Compost", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index ca1035047..9e1758776 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -1,6 +1,6 @@ { "@@locale": "cs", - "@@last_modified": "2026-01-07 14:26:03.848423", + "@@last_modified": "2026-02-05 10:09:43.831148", "about": "O aplikaci", "@about": { "type": "String", @@ -3314,8 +3314,6 @@ "noPaymentInfo": "Není třeba žádné platební informace!", "updatePhoneOS": "Možná budete muset aktualizovat verzi operačního systému vašeho zařízení", "wordsPerMinute": "Slov za minutu", - "autoIGCToolName": "Automaticky spustit pomoc s psaním Pangea", - "autoIGCToolDescription": "Automaticky spustit gramatickou kontrolu a překlad pomocí Pangea Chat před odesláním mé zprávy", "tooltipInstructionsTitle": "Nejste si jistí, co to dělá?", "tooltipInstructionsMobileBody": "Podržte položky pro zobrazení nápověd.", "tooltipInstructionsBrowserBody": "Na položky najeďte myší pro zobrazení nápověd.", @@ -3943,7 +3941,6 @@ "numModules": "{num} modulů", "coursePlan": "Plán kurzu", "editCourseLater": "Později můžete upravit název šablony, popisy a obrázek kurzu.", - "newCourseAccess": "Ve výchozím nastavení jsou kurzy soukromé a vyžadují schválení správce pro připojení. Tyto nastavení můžete upravit kdykoli.", "createCourse": "Vytvořit kurz", "stats": "Statistiky", "createGroupChat": "Vytvořit skupinový chat", @@ -6813,14 +6810,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9439,10 +9428,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11432,5 +11417,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Opustil(a) jsi chat", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Stahování zahájeno", + "webDownloadPermissionMessage": "Pokud váš prohlížeč blokuje stahování, povolte prosím stahování pro tuto stránku.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Pokrok vaší cvičební relace nebude uložen.", + "practiceGrammar": "Cvičit gramatiku", + "notEnoughToPractice": "Odešlete více zpráv, abyste odemkli cvičení", + "constructUseCorGCDesc": "Cvičení správné gramatické kategorie", + "constructUseIncGCDesc": "Cvičení nesprávné gramatické kategorie", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Cvičení správné gramatiky", + "constructUseIncGEDesc": "Cvičení nesprávné gramatiky", + "fillInBlank": "Doplňte prázdné místo správnou volbou", + "learn": "Učit se", + "languageUpdated": "Cílový jazyk byl aktualizován!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Hlas Pangea Bota", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Vaše žádost byla odeslána administrátorovi kurzu! Budete vpuštěni, pokud ji schválí.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Máte pozvánkový kód nebo odkaz na veřejný kurz?", + "welcomeUser": "Vítejte {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Hledejte uživatele, které chcete pozvat do tohoto chatu.", + "publicInviteDescSpace": "Hledejte uživatele, které chcete pozvat do tohoto prostoru.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat je aplikace pro zasílání zpráv, takže jsou oznámení důležitá!", + "enableNotificationsDesc": "Povolit oznámení", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Použít obrázek aktivity jako pozadí chatu", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Chat s podporou", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Ve výchozím nastavení jsou kurzy veřejně vyhledatelné a vyžadují schválení administrátora pro připojení. Tyto nastavení můžete kdykoli upravit.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Jaký jazyk se učíte?", + "searchLanguagesHint": "Hledejte cílové jazyky", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Otázky? Jsme tu, abychom pomohli!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Něco se pokazilo a my na tom tvrdě pracujeme. Zkontrolujte to prosím později.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Povolit asistenci při psaní", + "autoIGCToolDescription": "Automaticky spouštět nástroje Pangea Chat pro opravu odeslaných zpráv do cílového jazyka.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Nahrávání se nezdařilo. Zkontrolujte prosím svá oprávnění k audiosouborům a zkuste to znovu.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Frázové sloveso", + "grammarCopyPOScompn": "Složenina", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_da.arb b/lib/l10n/intl_da.arb index 512ab67cf..eef287d6f 100644 --- a/lib/l10n/intl_da.arb +++ b/lib/l10n/intl_da.arb @@ -1206,8 +1206,6 @@ "noPaymentInfo": "Ingen betalingsoplysninger nødvendige!", "updatePhoneOS": "Du kan være nødt til at opdatere din enheds OS-version.", "wordsPerMinute": "Ord pr. minut", - "autoIGCToolName": "Kør Pangea skriveassistance automatisk", - "autoIGCToolDescription": "Kør automatisk Pangea Chat grammatik- og oversættelsesassistance, før jeg sender min besked.", "tooltipInstructionsTitle": "Er du ikke sikker på, hvad det gør?", "tooltipInstructionsMobileBody": "Tryk og hold på elementer for at se værktøjstip.", "tooltipInstructionsBrowserBody": "Hold musen over elementer for at se værktøjstip.", @@ -1835,7 +1833,6 @@ "numModules": "{num} moduler", "coursePlan": "Kursusplan", "editCourseLater": "Du kan redigere skabelonens titel, beskrivelser og kursusbillede senere.", - "newCourseAccess": "Som standard er kurser private og kræver godkendelse fra administrator for at deltage. Du kan redigere disse indstillinger når som helst.", "createCourse": "Opret kursus", "stats": "Statistikker", "createGroupChat": "Opret gruppechat", @@ -1930,7 +1927,7 @@ "playWithAI": "Leg med AI for nu", "courseStartDesc": "Pangea Bot er klar til at starte når som helst!\n\n...men læring er bedre med venner!", "@@locale": "da", - "@@last_modified": "2026-01-07 14:23:47.042043", + "@@last_modified": "2026-02-05 10:09:17.541713", "@aboutHomeserver": { "type": "String", "placeholders": { @@ -7268,14 +7265,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9894,10 +9883,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11886,5 +11871,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Du forlod chatten", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Download påbegyndt", + "webDownloadPermissionMessage": "Hvis din browser blokerer downloads, bedes du aktivere downloads for dette site.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Din praksis session fremskridt vil ikke blive gemt.", + "practiceGrammar": "Øv grammatik", + "notEnoughToPractice": "Send flere beskeder for at låse op for praksis", + "constructUseCorGCDesc": "Korrekt grammatik kategori praksis", + "constructUseIncGCDesc": "Ukorrrekt grammatik kategori praksis", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Korrekt grammatikfejl praksis", + "constructUseIncGEDesc": "Ukorrrekt grammatikfejl praksis", + "fillInBlank": "Udfyld det tomme felt med det korrekte valg", + "learn": "Lær", + "languageUpdated": "Mål sprog opdateret!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Bot stemme", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Din anmodning er sendt til kursusadministratoren! Du vil blive lukket ind, hvis de godkender.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Har du en invitationskode eller et link til et offentligt kursus?", + "welcomeUser": "Velkommen {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Søg efter brugere for at invitere dem til denne chat.", + "publicInviteDescSpace": "Søg efter brugere for at invitere dem til dette rum.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat er en beskedapp, så notifikationer er vigtige!", + "enableNotificationsDesc": "Tillad notifikationer", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Brug aktivitetsbillede som chatbaggrund", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Chat med support", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Som standard er kurser offentligt søgbare og kræver administratorgodkendelse for at deltage. Du kan redigere disse indstillinger når som helst.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Hvilket sprog lærer du?", + "searchLanguagesHint": "Søg efter målsprog", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Spørgsmål? Vi er her for at hjælpe!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Noget gik galt, og vi arbejder hårdt på at løse det. Tjek igen senere.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Aktivér skriveassistance", + "autoIGCToolDescription": "Kør automatisk Pangea Chat-værktøjer for at rette sendte beskeder til målsproget.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Optagelse mislykkedes. Tjek venligst dine lydtilladelser og prøv igen.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Phrasal Verb", + "grammarCopyPOScompn": "Sammensat", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 63d7c56b2..9c51df9e8 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1,6 +1,6 @@ { "@@locale": "de", - "@@last_modified": "2026-01-07 14:25:24.418870", + "@@last_modified": "2026-02-05 10:09:37.665075", "alwaysUse24HourFormat": "true", "@alwaysUse24HourFormat": { "description": "Set to true to always display time of day in 24 hour format." @@ -3792,8 +3792,6 @@ "noPaymentInfo": "Keine Zahlungsinformationen erforderlich!", "updatePhoneOS": "Sie müssen möglicherweise die OS-Version Ihres Geräts aktualisieren.", "wordsPerMinute": "Wörter pro Minute", - "autoIGCToolName": "Pangea Schreibhilfe automatisch ausführen", - "autoIGCToolDescription": "Führen Sie die Pangea Chat-Grammatik- und Übersetzungs-Schreibhilfe automatisch aus, bevor Sie meine Nachricht senden.", "tooltipInstructionsTitle": "Nicht sicher, was das macht?", "tooltipInstructionsMobileBody": "Tippen und halten Sie Elemente, um Tooltips anzuzeigen.", "tooltipInstructionsBrowserBody": "Bewegen Sie den Mauszeiger über Elemente, um Tooltips anzuzeigen.", @@ -4421,7 +4419,6 @@ "numModules": "{num} Module", "coursePlan": "Kursplan", "editCourseLater": "Sie können den Titel, die Beschreibungen und das Kursbild später bearbeiten.", - "newCourseAccess": "Standardmäßig sind Kurse privat und erfordern die Genehmigung eines Administrators, um beizutreten. Sie können diese Einstellungen jederzeit ändern.", "createCourse": "Kurs erstellen", "stats": "Statistiken", "createGroupChat": "Gruppenchats erstellen", @@ -6213,14 +6210,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -8839,10 +8828,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10832,5 +10817,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Du hast den Chat verlassen", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Download gestartet", + "webDownloadPermissionMessage": "Wenn Ihr Browser Downloads blockiert, aktivieren Sie bitte Downloads für diese Seite.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Ihr Fortschritt in der Übungssitzung wird nicht gespeichert.", + "practiceGrammar": "Grammatik üben", + "notEnoughToPractice": "Senden Sie mehr Nachrichten, um die Übung freizuschalten", + "constructUseCorGCDesc": "Übung der korrekten Grammatikkategorie", + "constructUseIncGCDesc": "Übung der inkorrekten Grammatikkategorie", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Korrekte Grammatikfehlerübung", + "constructUseIncGEDesc": "Falsche Grammatikfehlerübung", + "fillInBlank": "Füllen Sie die Lücke mit der richtigen Wahl aus", + "learn": "Lernen", + "languageUpdated": "Zielsprache aktualisiert!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Bot Stimme", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Ihre Anfrage wurde an den Kursadministrator gesendet! Sie werden eingelassen, wenn sie zustimmen.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Haben Sie einen Einladungscode oder einen Link zu einem öffentlichen Kurs?", + "welcomeUser": "Willkommen {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Suchen Sie nach Benutzern, um sie zu diesem Chat einzuladen.", + "publicInviteDescSpace": "Suchen Sie nach Benutzern, um sie zu diesem Raum einzuladen.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat ist eine Messaging-App, daher sind Benachrichtigungen wichtig!", + "enableNotificationsDesc": "Benachrichtigungen erlauben", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Aktivitätsbild als Chat-Hintergrund verwenden", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Chat mit dem Support", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Standardmäßig sind Kurse öffentlich durchsuchbar und erfordern die Genehmigung eines Administrators, um beizutreten. Sie können diese Einstellungen jederzeit bearbeiten.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Welche Sprache lernst du?", + "searchLanguagesHint": "Zielsprachen suchen", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Fragen? Wir sind hier, um zu helfen!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Etwas ist schiefgelaufen, und wir arbeiten hart daran, es zu beheben. Überprüfen Sie es später erneut.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Schreibassistenz aktivieren", + "autoIGCToolDescription": "Automatisch Pangea Chat-Tools ausführen, um gesendete Nachrichten in die Zielsprache zu korrigieren.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Die Aufnahme ist fehlgeschlagen. Bitte überprüfen Sie Ihre Audio-Berechtigungen und versuchen Sie es erneut.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Phrasal Verb", + "grammarCopyPOScompn": "Zusammengesetztes Wort", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_el.arb b/lib/l10n/intl_el.arb index a8651b345..d2aecfddb 100644 --- a/lib/l10n/intl_el.arb +++ b/lib/l10n/intl_el.arb @@ -3732,8 +3732,6 @@ "noPaymentInfo": "Δεν απαιτείται πληροφορία πληρωμής!", "updatePhoneOS": "Ίσως χρειαστεί να ενημερώσετε την έκδοση του λειτουργικού συστήματος της συσκευής σας.", "wordsPerMinute": "Λέξεις ανά λεπτό", - "autoIGCToolName": "Αυτόματη εκτέλεση της βοήθειας γραφής Pangea", - "autoIGCToolDescription": "Αυτόματα εκτελείτε τη γραμματική και τη βοήθεια μετάφρασης του Pangea Chat πριν στείλετε το μήνυμά μου.", "tooltipInstructionsTitle": "Δεν είστε σίγουροι τι κάνει αυτό;", "tooltipInstructionsMobileBody": "Πατήστε και κρατήστε πατημένο αντικείμενα για να δείτε τις συμβουλές εργαλείων.", "tooltipInstructionsBrowserBody": "Τοποθετήστε το δείκτη πάνω από αντικείμενα για να δείτε τις συμβουλές εργαλείων.", @@ -4361,7 +4359,6 @@ "numModules": "{num} ενότητες", "coursePlan": "Πλάνο Μαθήματος", "editCourseLater": "Μπορείτε να επεξεργαστείτε τον τίτλο, τις περιγραφές και την εικόνα του μαθήματος αργότερα.", - "newCourseAccess": "Κατά κανόνα, τα μαθήματα είναι ιδιωτικά και απαιτούν έγκριση διαχειριστή για συμμετοχή. Μπορείτε να επεξεργαστείτε αυτές τις ρυθμίσεις οποτεδήποτε.", "createCourse": "Δημιουργία μαθήματος", "stats": "Στατιστικά", "createGroupChat": "Δημιουργία ομαδικής συνομιλίας", @@ -4456,7 +4453,7 @@ "playWithAI": "Παίξτε με την Τεχνητή Νοημοσύνη προς το παρόν", "courseStartDesc": "Ο Pangea Bot είναι έτοιμος να ξεκινήσει οποιαδήποτε στιγμή!\n\n...αλλά η μάθηση είναι καλύτερη με φίλους!", "@@locale": "el", - "@@last_modified": "2026-01-07 14:28:33.144714", + "@@last_modified": "2026-02-05 10:10:14.390437", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -7264,14 +7261,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9890,10 +9879,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11883,5 +11868,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Αφήσατε τη συνομιλία", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Η λήψη ξεκίνησε", + "webDownloadPermissionMessage": "Εάν ο περιηγητής σας μπλοκάρει τις λήψεις, παρακαλώ ενεργοποιήστε τις λήψεις για αυτόν τον ιστότοπο.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Η πρόοδος της συνεδρίας πρακτικής σας δεν θα αποθηκευτεί.", + "practiceGrammar": "Πρακτική γραμματικής", + "notEnoughToPractice": "Στείλτε περισσότερα μηνύματα για να ξεκλειδώσετε την πρακτική", + "constructUseCorGCDesc": "Πρακτική κατηγορίας σωστής γραμματικής", + "constructUseIncGCDesc": "Πρακτική κατηγορίας λανθαστής γραμματικής", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Πρακτική διόρθωσης γραμματικών λαθών", + "constructUseIncGEDesc": "Πρακτική λανθασμένων γραμματικών λαθών", + "fillInBlank": "Συμπληρώστε το κενό με τη σωστή επιλογή", + "learn": "Μάθετε", + "languageUpdated": "Η γλώσσα στόχος ενημερώθηκε!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Φωνή Pangea Bot", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Το αίτημά σας έχει σταλεί στον διαχειριστή του μαθήματος! Θα σας επιτρέψουν να μπείτε αν το εγκρίνουν.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Έχετε έναν κωδικό πρόσκλησης ή σύνδεσμο για ένα δημόσιο μάθημα;", + "welcomeUser": "Καλώς ήρθατε {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Αναζητήστε χρήστες για να τους προσκαλέσετε σε αυτήν την συνομιλία.", + "publicInviteDescSpace": "Αναζητήστε χρήστες για να τους προσκαλέσετε σε αυτόν τον χώρο.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Η Pangea Chat είναι μια εφαρμογή μηνυμάτων, οπότε οι ειδοποιήσεις είναι σημαντικές!", + "enableNotificationsDesc": "Επιτρέψτε τις ειδοποιήσεις", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Χρησιμοποιήστε την εικόνα δραστηριότητας ως φόντο συνομιλίας", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Συνομιλία με Υποστήριξη", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Από προεπιλογή, τα μαθήματα είναι δημόσια αναζητήσιμα και απαιτούν έγκριση διαχειριστή για να συμμετάσχετε. Μπορείτε να επεξεργαστείτε αυτές τις ρυθμίσεις οποιαδήποτε στιγμή.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Ποια γλώσσα μαθαίνετε;", + "searchLanguagesHint": "Αναζητήστε γλώσσες στόχου", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Ερωτήσεις; Είμαστε εδώ για να βοηθήσουμε!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Κάτι πήγε στραβά και εργαζόμαστε σκληρά για να το διορθώσουμε. Έλεγξε ξανά αργότερα.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Ενεργοποίηση βοήθειας γραφής", + "autoIGCToolDescription": "Αυτόματα εκτελέστε τα εργαλεία Pangea Chat για να διορθώσετε τα αποσταλμένα μηνύματα στη γλώσσα στόχο.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Η ηχογράφηση απέτυχε. Παρακαλώ ελέγξτε τις άδειες ήχου σας και δοκιμάστε ξανά.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Ιδιωματισμός", + "grammarCopyPOSphrasalv": "Φραστικό Ρήμα", + "grammarCopyPOScompn": "Σύνθετο", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 7cdbd28e1..a47f1a03e 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -3730,8 +3730,6 @@ "noPaymentInfo": "No payment info necessary!", "updatePhoneOS": "You may need to update your device's OS version.", "wordsPerMinute": "Words per minute", - "autoIGCToolName": "Run Pangea writing assistance automatically", - "autoIGCToolDescription": "Automatically run Pangea Chat grammar and translation writing assistance before sending my message.", "chatCapacity": "Chat capacity", "roomFull": "This room is already at capacity.", "chatCapacityHasBeenChanged": "Chat capacity changed", @@ -3811,6 +3809,9 @@ "grammarCopyPOSpropn": "Proper Noun", "grammarCopyPOSnoun": "Noun", "grammarCopyPOSintj": "Interjection", + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Phrasal Verb", + "grammarCopyPOScompn": "Compound", "grammarCopyPOSx": "Other", "grammarCopyGENDERfem": "Feminine", "grammarCopyPERSON2": "Second Person", @@ -4563,7 +4564,6 @@ }, "coursePlan": "Course Plan", "editCourseLater": "You can edit template title, descriptions, and course image later.", - "newCourseAccess": "By default, courses are private and require admin approval to join. You can edit these settings at any time.", "createCourse": "Create course", "stats": "Stats", "createGroupChat": "Create group chat", @@ -5029,19 +5029,49 @@ "noActivityRequest": "No current activity request.", "quit": "Quit", "congratulationsYouveCompletedPractice": "Congratulations! You've completed the practice session.", - "noSavedActivitiesYet": "Activities will appear here once they are completed and saved.", - "practiceActivityCompleted": "Practice activity completed", - "changeCourse": "Change course", - "changeCourseDesc": "Here you can change this course's course plan.", - "introChatTitle": "Create Introductions Chat", - "introChatDesc": "Anyone in the space can post.", - "announcementsChatTitle": "Announcements Chat", - "announcementsChatDesc": "Only space admin can post.", - "inOngoingActivity": "You have an ongoing activity!", "activitiesToUnlockTopicTitle": "Activities to Unlock Next Topic", "activitiesToUnlockTopicDesc": "Set the number of activities to unlock the next course topic", - "mustHave10Words" : "You must have at least 10 vocab words to practice them. Try talking to a friend or Pangea Bot to discover more!", + "mustHave10Words": "You must have at least 10 vocab words to practice them. Try talking to a friend or Pangea Bot to discover more!", "botSettings": "Bot Settings", "activitySettingsOverrideWarning": "Language and language level determined by activity plan", - "voice": "Voice" + "voice": "Voice", + "youLeftTheChat": "🚪 You left the chat", + "downloadInitiated": "Download initiated", + "webDownloadPermissionMessage": "If your browser blocks downloads, please enable downloads for this site.", + "exitPractice": "Your practice session progress won't be saved.", + "practiceGrammar": "Practice grammar", + "notEnoughToPractice": "Send more messages to unlock practice", + "constructUseCorGCDesc": "Correct grammar category practice", + "constructUseIncGCDesc": "Incorrect grammar category practice", + "constructUseCorGEDesc": "Correct grammar error practice", + "constructUseIncGEDesc": "Incorrect grammar error practice", + "fillInBlank": "Fill in the blank with the correct choice", + "learn": "Learn", + "languageUpdated": "Target language updated!", + "voiceDropdownTitle": "Pangea Bot voice", + "knockDesc": "Your request has been sent to course admin! You'll be let in if they approve.", + "joinSpaceOnboardingDesc": "Do you have an invite code or link to a public course?", + "welcomeUser": "Welcome {user}", + "@welcomeUser": { + "placeholders": { + "user": { + "type": "String" + } + } + }, + "findCourse": "Find a course", + "publicInviteDescChat": "Search for users to invite them to this chat.", + "publicInviteDescSpace": "Search for users to invite them to this space.", + "enableNotificationsTitle": "Pangea Chat is a texting app so notifications are important!", + "enableNotificationsDesc": "Allow notifications", + "useActivityImageAsChatBackground": "Use activity image as chat background", + "chatWithSupport": "Chat with Support", + "newCourseAccess": "By default, courses are publicly searchable and require admin approval to join. You can edit these settings at any time.", + "courseLoadingError": "Something went wrong, and we're hard at work fixing it. Check again later.", + "onboardingLanguagesTitle": "What language are you learning?", + "searchLanguagesHint": "Search target languages", + "supportSubtitle": "Questions? We're here to help!", + "autoIGCToolName": "Enable writing assistance", + "autoIGCToolDescription": "Automatically run Pangea Chat tools to correct sent messages to target language.", + "emptyAudioError": "Recording failed. Please check your audio permissions and try again." } diff --git a/lib/l10n/intl_eo.arb b/lib/l10n/intl_eo.arb index 3776e3eb4..2c6c6ed93 100644 --- a/lib/l10n/intl_eo.arb +++ b/lib/l10n/intl_eo.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:28:53.013525", + "@@last_modified": "2026-02-05 10:10:17.896498", "about": "Prio", "@about": { "type": "String", @@ -2911,8 +2911,6 @@ "noPaymentInfo": "Ne necesas paginformoj!", "updatePhoneOS": "Eble vi bezonas ĝisdatigi la version de la operaciumo de via aparato.", "wordsPerMinute": "Vortoj po minuto", - "autoIGCToolName": "Kurigu Pangea verkhelpilo aŭtomate", - "autoIGCToolDescription": "Aŭtomate funkciigu Pangea Chat gramatikon kaj tradukadon por helpi vin verki antaŭ ol sendi vian mesaĝon.", "tooltipInstructionsTitle": "Ne certas kio tio faras?", "tooltipInstructionsMobileBody": "Premu kaj teni objektojn por vidi ilustraĵojn.", "tooltipInstructionsBrowserBody": "Hovru super objektoj por vidi ilustraĵojn.", @@ -3540,7 +3538,6 @@ "numModules": "{num} moduloj", "coursePlan": "Kurso Plano", "editCourseLater": "Vi povas redakti la titolon, priskribojn, kaj bildon de la kurso poste.", - "newCourseAccess": "Ĝis nun, kursoj estas private kaj postulas administran aprobon por aliĝi. Vi povas ŝanĝi ĉi tiujn agordojn iam ajn.", "createCourse": "Krei kurson", "stats": "Statistikoj", "createGroupChat": "Krei grupan babiladon", @@ -7295,14 +7292,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9921,10 +9910,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11914,5 +11899,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Vi forlasis la konversacion", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Elŝuto iniciatita", + "webDownloadPermissionMessage": "Se via retumilo blokas elŝutojn, bonvolu ebligi elŝutojn por ĉi tiu retejo.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Via praktika sesio progreso ne estos konservita.", + "practiceGrammar": "Praktiku gramatikon", + "notEnoughToPractice": "Sendu pli da mesaĝoj por malŝlosi praktikon", + "constructUseCorGCDesc": "Praktiko de ĝusta gramatika kategorio", + "constructUseIncGCDesc": "Praktiko de malĝusta gramatika kategorio", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Praktiko pri ĝusta gramatika eraro", + "constructUseIncGEDesc": "Praktiko pri malĝusta gramatika eraro", + "fillInBlank": "Plenigu la malplenan lokon per la ĝusta elekto", + "learn": "Lerni", + "languageUpdated": "Celo lingvo ĝisdatigita!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Voĉo de Pangea Bot", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Via peto estis sendita al la kursa administranto! Vi estos enirita se ili aprobas.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Ĉu vi havas invitkodon aŭ ligon al publika kurso?", + "welcomeUser": "Bonvenon {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Serĉu uzantojn por inviti ilin al ĉi tiu konversacio.", + "publicInviteDescSpace": "Serĉu uzantojn por inviti ilin al ĉi tiu spaco.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat estas aplikaĵo por mesaĝado, do notifikoj estas gravaj!", + "enableNotificationsDesc": "Permesi notifikojn", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Uzu aktivan bildon kiel ĉatfondon", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Babili kun Subteno", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Laŭ la defaŭlto, kursoj estas publike serĉeblaj kaj postulas administran aprobon por aliĝi. Vi povas redakti ĉi tiujn agordojn iam ajn.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Kian lingvon vi lernas?", + "searchLanguagesHint": "Serĉu celajn lingvojn", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Demandoj? Ni ĉi tie por helpi!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Io malĝuste okazis, kaj ni diligente laboras por ripari ĝin. Kontrolu denove poste.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Ebligi skriban asistadon", + "autoIGCToolDescription": "Aŭtomate funkciigi Pangea Chat-ilojn por korekti senditajn mesaĝojn al la cellingvo.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Registrado malsukcesis. Bonvolu kontroli viajn aŭdajn permesojn kaj provi denove.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Phrasal Verb", + "grammarCopyPOScompn": "Kunmetita", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index b170a1e28..8458be45b 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1,6 +1,6 @@ { "@@locale": "es", - "@@last_modified": "2026-01-07 14:23:22.356161", + "@@last_modified": "2026-02-05 10:09:12.250951", "about": "Acerca de", "@about": { "type": "String", @@ -4251,8 +4251,6 @@ "wordsPerMinute": "Palabras por minuto", "roomFull": "Esta sala ya está al límite de su capacidad.", "enterNumber": "Introduzca un valor numérico entero.", - "autoIGCToolName": "Ejecutar automáticamente la asistencia lingüística", - "autoIGCToolDescription": "Ejecutar automáticamente la asistencia lingüística después de escribir mensajes", "buildTranslation": "Construye tu traducción a partir de las opciones anteriores", "languageSettings": "Ajustes de idioma", "selectSpaceDominantLanguage": "¿Cuál es la lengua más común de los miembros del espacio?", @@ -5689,7 +5687,6 @@ "numModules": "{num} módulos", "coursePlan": "Plan de curso", "editCourseLater": "Puedes editar el título, las descripciones y la imagen del curso más tarde.", - "newCourseAccess": "Por defecto, los cursos son privados y requieren aprobación del administrador para unirse. Puedes editar estos ajustes en cualquier momento.", "createCourse": "Crear curso", "stats": "Estadísticas", "createGroupChat": "Crear chat grupal", @@ -6035,10 +6032,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -8059,5 +8052,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Has salido del chat", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Descarga iniciada", + "webDownloadPermissionMessage": "Si tu navegador bloquea las descargas, por favor habilita las descargas para este sitio.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "El progreso de tu sesión de práctica no se guardará.", + "practiceGrammar": "Practicar gramática", + "notEnoughToPractice": "Envía más mensajes para desbloquear la práctica", + "constructUseCorGCDesc": "Práctica de categoría de gramática correcta", + "constructUseIncGCDesc": "Práctica de categoría de gramática incorrecta", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Práctica de errores gramaticales correctos", + "constructUseIncGEDesc": "Práctica de errores gramaticales incorrectos", + "fillInBlank": "Completa el espacio en blanco con la opción correcta", + "learn": "Aprender", + "languageUpdated": "¡Idioma objetivo actualizado!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Voz del bot Pangea", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "¡Tu solicitud ha sido enviada al administrador del curso! Te dejarán entrar si la aprueban.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "¿Tienes un código de invitación o un enlace a un curso público?", + "welcomeUser": "Bienvenido {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Busca usuarios para invitarlos a este chat.", + "publicInviteDescSpace": "Busca usuarios para invitarlos a este espacio.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "¡Pangea Chat es una aplicación de mensajería, así que las notificaciones son importantes!", + "enableNotificationsDesc": "Permitir notificaciones", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Usar imagen de actividad como fondo de chat", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Chatear con Soporte", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Por defecto, los cursos son buscables públicamente y requieren aprobación del administrador para unirse. Puedes editar estas configuraciones en cualquier momento.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "¿Qué idioma estás aprendiendo?", + "searchLanguagesHint": "Buscar idiomas objetivo", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "¿Preguntas? ¡Estamos aquí para ayudar!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Algo salió mal y estamos trabajando arduamente para solucionarlo. Revisa de nuevo más tarde.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Habilitar asistencia de escritura", + "autoIGCToolDescription": "Ejecutar automáticamente las herramientas de Pangea Chat para corregir los mensajes enviados al idioma de destino.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "La grabación falló. Por favor, verifica tus permisos de audio y vuelve a intentarlo.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Modismo", + "grammarCopyPOSphrasalv": "Verbo Frasal", + "grammarCopyPOScompn": "Compuesto", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_et.arb b/lib/l10n/intl_et.arb index 5eb052ca0..ddb897208 100644 --- a/lib/l10n/intl_et.arb +++ b/lib/l10n/intl_et.arb @@ -1,6 +1,6 @@ { "@@locale": "et", - "@@last_modified": "2026-01-07 14:25:18.173924", + "@@last_modified": "2026-02-05 10:09:36.127342", "about": "Rakenduse teave", "@about": { "type": "String", @@ -3811,8 +3811,6 @@ "noPaymentInfo": "Makseteadet pole vaja!", "updatePhoneOS": "Võib olla vajalik uuendada oma seadme operatsioonisüsteemi versiooni.", "wordsPerMinute": "Sõnad minutis", - "autoIGCToolName": "Käivita Pangea kirjutamisabi automaatselt", - "autoIGCToolDescription": "Käivita automaatselt Pangea vestluse grammatika- ja tõlkeabi enne sõnumi saatmist.", "addSpaceToSpaceDescription": "Vali ruum, mida lisada vanemaks", "chatCapacity": "Vestluse maht", "spaceCapacity": "Ruumimaht", @@ -6229,14 +6227,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@addSpaceToSpaceDescription": { "type": "String", "placeholders": {} @@ -8815,7 +8805,6 @@ "numModules": "{num} moodulit", "coursePlan": "Kursuse plaan", "editCourseLater": "Saate hiljem redigeerida mallide pealkirju, kirjelduid ja kursuse pilti.", - "newCourseAccess": "Vaikimisi on kursused privaatsed ning nõuavad administraatori kinnitust, et liituda. Saate neid seadeid igal ajal muuta.", "createCourse": "Loo kursus", "stats": "Statistika", "createGroupChat": "Loo grupivestlus", @@ -9098,10 +9087,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11096,5 +11081,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Sa lahkusid vestlusest", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Allalaadimine algatatud", + "webDownloadPermissionMessage": "Kui teie brauser blokeerib allalaadimisi, lubage palun selle saidi jaoks allalaadimised.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Teie harjut seansi edusamme ei salvestata.", + "practiceGrammar": "Harjuta grammatikat", + "notEnoughToPractice": "Saada rohkem sõnumeid, et harjutust avada", + "constructUseCorGCDesc": "Õige grammatika kategooria harjutus", + "constructUseIncGCDesc": "Vale grammatika kategooria harjutus", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Õige grammatika vea harjutamine", + "constructUseIncGEDesc": "Vale grammatika vea harjutamine", + "fillInBlank": "Täida tühik õige valikuga", + "learn": "Õpi", + "languageUpdated": "Sihtkeel on uuendatud!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Boti hääl", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Teie taotlus on saadetud kursuse administraatorile! Teid lastakse sisse, kui nad heaks kiidavad.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Kas sul on kutsekood või link avalikule kursusele?", + "welcomeUser": "Tere tulemast {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Otsi kasutajaid, et neid sellesse vestlusse kutsuda.", + "publicInviteDescSpace": "Otsi kasutajaid, et neid sellesse ruumi kutsuda.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat on sõnumite rakendus, seega on teavitused olulised!", + "enableNotificationsDesc": "Luba teavitused", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Kasuta tegevuse pilti vestluse taustana", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Vestle Toega", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Vaikimisi on kursused avalikult otsitavad ja liitumiseks on vajalik administraatori heakskiit. Sa saad neid seadeid igal ajal muuta.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Millist keelt sa õpid?", + "searchLanguagesHint": "Otsi sihtkeeli", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Küsimused? Me oleme siin, et aidata!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Midagi läks valesti ja me teeme kõvasti tööd, et see parandada. Kontrolli hiljem uuesti.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Luba kirjutamise abi", + "autoIGCToolDescription": "Käivita automaatselt Pangea Chat tööriistad, et parandada saadetud sõnumid sihtkeelde.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Salvestamine ebaõnnestus. Palun kontrollige oma heliõigusi ja proovige uuesti.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idioom", + "grammarCopyPOSphrasalv": "Fraasi Verb", + "grammarCopyPOScompn": "Kompleks", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_eu.arb b/lib/l10n/intl_eu.arb index 80f3ffc6e..9bab7424f 100644 --- a/lib/l10n/intl_eu.arb +++ b/lib/l10n/intl_eu.arb @@ -1,6 +1,6 @@ { "@@locale": "eu", - "@@last_modified": "2026-01-07 14:25:02.696896", + "@@last_modified": "2026-02-05 10:09:33.401642", "about": "Honi buruz", "@about": { "type": "String", @@ -3785,8 +3785,6 @@ "noPaymentInfo": "Ez dago ordainketa informaziorik behar!", "updatePhoneOS": "Baliteke zure gailuaren OS bertsioa eguneratu behar izatea.", "wordsPerMinute": "Hitz minutuko", - "autoIGCToolName": "Exekutatu Pangea idazketa laguntza automatikoki", - "autoIGCToolDescription": "Exekutatu automatikoki Pangea Txataren gramatika eta itzulpen idazketa laguntza mezu bat bidali aurretik.", "tooltipInstructionsTitle": "Ez da ziur zer den hori?", "tooltipInstructionsMobileBody": "Elementuak sakatu eta eutsi tresna-txartelak ikusteko.", "tooltipInstructionsBrowserBody": "Elementuak gainean mugitu eta ikusi tresna-txartelak.", @@ -4414,7 +4412,6 @@ "numModules": "{num} modulua", "coursePlan": "Ikastaro Plana", "editCourseLater": "Eman dezakezu geroago txantiloiaren izena, deskribapenak eta ikastaroaren irudia editatzeko.", - "newCourseAccess": "Lehenetsiz, ikastaroak pribatutasunekoak dira eta administratzailearen onespena behar dute parte hartzeko. Edozein momentutan aldatu ditzakezu ezarpen hauek.", "createCourse": "Sortu ikastaroa", "stats": "Datuak", "createGroupChat": "Sortu talde txat bat", @@ -6206,14 +6203,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -8832,10 +8821,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10825,5 +10810,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Txatetik irten zara", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Deskarga hasi da", + "webDownloadPermissionMessage": "Zure nabigatzaileak deskargak blokeatzen baditu, mesedez, gaitza itxaroteko deskargak webgune honentzat.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Zure praktika saioaren aurrerapena ez da gorde.", + "practiceGrammar": "Gramatika praktikatu", + "notEnoughToPractice": "Praktika desblokeatzeko gehiago mezu bidali", + "constructUseCorGCDesc": "Gramatika kategoriako praktika zuzena", + "constructUseIncGCDesc": "Gramatika kategoriako praktika okerra", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Gramatika akats zuzenketa praktika", + "constructUseIncGEDesc": "Gramatika akats okerra praktika", + "fillInBlank": "Betekoa bete aukerarik egokienarekin", + "learn": "Ikasi", + "languageUpdated": "Helmuga hizkuntza eguneratua!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Bot ahotsa", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Zure eskaera ikastaroaren administratzaileari bidali zaio! Onartzen badute, sartuko zara.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Baduzu gonbidapen kodea edo lotura publiko baten ikastaroarentzako?", + "welcomeUser": "Ongi etorri {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Bilatu erabiltzaileak txat honetara gonbidatzeko.", + "publicInviteDescSpace": "Bilatu erabiltzaileak espazio honetara gonbidatzeko.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat mezularitza aplikazio bat da, beraz jakinarazpenak garrantzitsuak dira!", + "enableNotificationsDesc": "Baimendu jakinarazpenak", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Erabili jarduera irudia txat atzeko plano gisa", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Txatatu Laguntzarekin", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Lehenetsitako, ikastaroak publikoan bilatzeko modukoak dira eta administratzailearen onarpena behar dute bat egiteko. Ezarpen hauek edonon aldatu ditzakezu.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Zer hizkuntza ikasten ari zara?", + "searchLanguagesHint": "Bilatu helburu hizkuntzak", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Galderak? Hemen gaude laguntzeko!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Zerbait oker joan da, eta horren konponketan lan gogorra egiten ari gara. Begiratu berriro geroago.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Idazteko laguntza aktibatu", + "autoIGCToolDescription": "Automatikoki exekutatu Pangea Chat tresnak helburu hizkuntzara bidalitako mezuak zuzentzeko.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Grabaketa huts egin da. Mesedez, egiaztatu zure audio baimenak eta saiatu berriro.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Phrasal Verb", + "grammarCopyPOScompn": "Konposatu", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fa.arb b/lib/l10n/intl_fa.arb index e82c668ba..45ee57de3 100644 --- a/lib/l10n/intl_fa.arb +++ b/lib/l10n/intl_fa.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:28:12.371773", + "@@last_modified": "2026-02-05 10:10:10.668033", "repeatPassword": "تکرار رمزعبور", "@repeatPassword": {}, "about": "درباره", @@ -3205,8 +3205,6 @@ "noPaymentInfo": "نیازی به اطلاعات پرداخت نیست!", "updatePhoneOS": "ممکن است نیاز باشد نسخه سیستم‌عامل دستگاه خود را به‌روزرسانی کنید.", "wordsPerMinute": "کلمات در دقیقه", - "autoIGCToolName": "اجرای خودکار کمک نوشتن پنگئا", - "autoIGCToolDescription": "به طور خودکار قبل از ارسال پیام من، کمک نگارش گرامر و ترجمه چت پنگئا را اجرا کنید.", "tooltipInstructionsTitle": "مطمئن نیستید چه کاری انجام می‌دهد؟", "tooltipInstructionsMobileBody": "برای مشاهده راهنما، آیتم‌ها را نگه دارید.", "tooltipInstructionsBrowserBody": "برای مشاهده راهنما، روی آیتم‌ها هاور کنید.", @@ -3834,7 +3832,6 @@ "numModules": "{num} ماژول", "coursePlan": "برنامه دوره", "editCourseLater": "می‌توانید بعداً عنوان، توضیحات و تصویر دوره را ویرایش کنید.", - "newCourseAccess": "به طور پیش‌فرض، دوره‌ها خصوصی هستند و نیاز به تایید مدیر برای پیوستن دارند. شما می‌توانید این تنظیمات را در هر زمان ویرایش کنید.", "createCourse": "ایجاد دوره", "stats": "آمار", "createGroupChat": "ایجاد چت گروهی", @@ -6938,14 +6935,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9564,10 +9553,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11557,5 +11542,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 شما از چت خارج شدید", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "دانلود آغاز شد", + "webDownloadPermissionMessage": "اگر مرورگر شما دانلودها را مسدود می‌کند، لطفاً دانلودها را برای این سایت فعال کنید.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "پیشرفت جلسه تمرین شما ذخیره نخواهد شد.", + "practiceGrammar": "تمرین گرامر", + "notEnoughToPractice": "پیام‌های بیشتری ارسال کنید تا تمرین را باز کنید", + "constructUseCorGCDesc": "تمرین دسته گرامر صحیح", + "constructUseIncGCDesc": "تمرین دسته گرامر نادرست", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "تمرین خطای گرامری صحیح", + "constructUseIncGEDesc": "تمرین خطای گرامری نادرست", + "fillInBlank": "جای خالی را با گزینه صحیح پر کنید", + "learn": "یاد بگیرید", + "languageUpdated": "زبان هدف به‌روزرسانی شد!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "صدای ربات پانژیا", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "درخواست شما به مدیر دوره ارسال شده است! اگر آنها تأیید کنند، شما وارد خواهید شد.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "آیا کد دعوت یا لینکی به یک دوره عمومی دارید؟", + "welcomeUser": "خوش آمدید {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "برای دعوت کاربران به این چت، جستجو کنید.", + "publicInviteDescSpace": "برای دعوت کاربران به این فضا، جستجو کنید.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "چت پانگه‌آ یک اپلیکیشن پیام‌رسان است، بنابراین اعلان‌ها مهم هستند!", + "enableNotificationsDesc": "اجازه دادن به اعلان‌ها", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "از تصویر فعالیت به عنوان پس‌زمینه چت استفاده کنید", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "چت با پشتیبانی", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "به طور پیش‌فرض، دوره‌ها به صورت عمومی قابل جستجو هستند و برای پیوستن به آن‌ها نیاز به تأیید مدیر دارند. شما می‌توانید این تنظیمات را در هر زمان ویرایش کنید.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "شما در حال یادگیری چه زبانی هستید؟", + "searchLanguagesHint": "زبان‌های هدف را جستجو کنید", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "سوالات؟ ما اینجا هستیم تا کمک کنیم!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "مشکلی پیش آمده و ما در حال تلاش برای رفع آن هستیم. بعداً دوباره بررسی کنید.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "فعال‌سازی کمک‌نویس", + "autoIGCToolDescription": "به‌طور خودکار ابزارهای چت پانژیا را برای اصلاح پیام‌های ارسال‌شده به زبان هدف اجرا کنید.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "ضبط صدا ناموفق بود. لطفاً مجوزهای صوتی خود را بررسی کرده و دوباره تلاش کنید.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "اصطلاح", + "grammarCopyPOSphrasalv": "فعل عبارتی", + "grammarCopyPOScompn": "ترکیب", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fi.arb b/lib/l10n/intl_fi.arb index 95155473a..475407fe6 100644 --- a/lib/l10n/intl_fi.arb +++ b/lib/l10n/intl_fi.arb @@ -3285,8 +3285,6 @@ "noPaymentInfo": "Maksutietoja ei tarvita!", "updatePhoneOS": "Saattaa olla, että sinun täytyy päivittää laitteesi käyttöjärjestelmän versio.", "wordsPerMinute": "Sanoja minuutissa", - "autoIGCToolName": "Aja Pangea kirjoitusavustusta automaattisesti", - "autoIGCToolDescription": "Aja automaattisesti Pangea Chatin kieliopin ja käännöksen kirjoitusavustusta ennen viestini lähettämistä.", "tooltipInstructionsTitle": "Et ole varma, mitä tämä tekee?", "tooltipInstructionsMobileBody": "Pidä painettuna kohteita nähdäksesi työkaluvihjeet.", "tooltipInstructionsBrowserBody": "Vie hiiri kohteen päälle nähdäksesi työkaluvihjeet.", @@ -3914,7 +3912,6 @@ "numModules": "{num} moduulia", "coursePlan": "Kurssisuunnitelma", "editCourseLater": "Voit muokata mallin otsikkoa, kuvauksia ja kurssikuvaa myöhemmin.", - "newCourseAccess": "Oletuksena kurssit ovat yksityisiä ja vaativat ylläpitäjän hyväksynnän liittyäksesi. Voit muokata näitä asetuksia milloin tahansa.", "createCourse": "Luo kurssi", "stats": "Tilastot", "createGroupChat": "Luo ryhmäkeskustelu", @@ -4009,7 +4006,7 @@ "playWithAI": "Leiki tekoälyn kanssa nyt", "courseStartDesc": "Pangea Bot on valmis milloin tahansa!\n\n...mutta oppiminen on parempaa ystävien kanssa!", "@@locale": "fi", - "@@last_modified": "2026-01-07 14:23:39.963677", + "@@last_modified": "2026-02-05 10:09:16.239112", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -6829,14 +6826,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9455,10 +9444,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11448,5 +11433,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Poistit itsesi keskustelusta", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Lataus aloitettu", + "webDownloadPermissionMessage": "Jos selaimesi estää lataukset, ota lataukset käyttöön tälle sivustolle.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Harjoitussession edistystäsi ei tallenneta.", + "practiceGrammar": "Harjoittele kielioppia", + "notEnoughToPractice": "Lähetä lisää viestejä avataksesi harjoituksen", + "constructUseCorGCDesc": "Oikean kielioppikategorian harjoittelu", + "constructUseIncGCDesc": "Väärän kielioppikategorian harjoittelu", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Oikean kielioppivirheen harjoittelu", + "constructUseIncGEDesc": "Väärän kielioppivirheen harjoittelu", + "fillInBlank": "Täytä tyhjä kohta oikealla valinnalla", + "learn": "Oppia", + "languageUpdated": "Kohdekieli päivitetty!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Botin ääni", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Pyyntösi on lähetetty kurssin ylläpitäjälle! Sinut päästetään sisään, jos he hyväksyvät sen.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Onko sinulla kutsukoodia tai linkkiä julkiseen kurssiin?", + "welcomeUser": "Tervetuloa {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Etsi käyttäjiä kutsuaksesi heidät tähän keskusteluun.", + "publicInviteDescSpace": "Etsi käyttäjiä kutsuaksesi heidät tähän tilaan.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat on viestintäsovellus, joten ilmoitukset ovat tärkeitä!", + "enableNotificationsDesc": "Salli ilmoitukset", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Käytä aktiviteettikuvaa chat-taustana", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Chattaa tuen kanssa", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Oletusarvoisesti kurssit ovat julkisesti haettavissa ja vaativat ylläpitäjän hyväksynnän liittymiseen. Voit muokata näitä asetuksia milloin tahansa.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Mitä kieltä opit?", + "searchLanguagesHint": "Etsi kohdekieliä", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Kysymyksiä? Olemme täällä auttamassa!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Jotain meni pieleen, ja teemme kovasti töitä sen korjaamiseksi. Tarkista myöhemmin uudelleen.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Ota käyttöön kirjoitusapu", + "autoIGCToolDescription": "Suorita automaattisesti Pangea Chat -työkaluja korjataksesi lähetetyt viestit kohdekielelle.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Äänityksen tallentaminen epäonnistui. Tarkista äänioikeutesi ja yritä uudelleen.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idioomi", + "grammarCopyPOSphrasalv": "Fraasiverbi", + "grammarCopyPOScompn": "Yhdistelmä", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fil.arb b/lib/l10n/intl_fil.arb index f70476b4e..d1c809870 100644 --- a/lib/l10n/intl_fil.arb +++ b/lib/l10n/intl_fil.arb @@ -2051,8 +2051,6 @@ "noPaymentInfo": "Hindi kailangan ng impormasyon sa pagbabayad!", "updatePhoneOS": "Maaaring kailangan mong i-update ang bersyon ng OS ng iyong device.", "wordsPerMinute": "Mga salita kada minuto", - "autoIGCToolName": "Awtomatikong patakbuhin ang Pangea writing assistance", - "autoIGCToolDescription": "Awtomatikong patakbuhin ang Pangea Chat grammar at translation writing assistance bago ipadala ang aking mensahe.", "tooltipInstructionsTitle": "Hindi sigurado kung ano ang ginagawa niyan?", "tooltipInstructionsMobileBody": "Pindutin at hawakan ang mga item upang makita ang mga tooltip.", "tooltipInstructionsBrowserBody": "I-hover ang cursor sa mga item upang makita ang mga tooltip.", @@ -2680,7 +2678,6 @@ "numModules": "{num} mga module", "coursePlan": "Plano ng Kurso", "editCourseLater": "Maaari mong i-edit ang pamagat ng template, mga paglalarawan, at larawan ng kurso sa ibang pagkakataon.", - "newCourseAccess": "Sa default, ang mga kurso ay pribado at nangangailangan ng pag-apruba ng admin upang makasali. Maaari mong i-edit ang mga setting na ito anumang oras.", "createCourse": "Lumikha ng kurso", "stats": "Mga Estadistika", "createGroupChat": "Lumikha ng pangkat na usapan", @@ -2787,7 +2784,7 @@ "selectAll": "Piliin lahat", "deselectAll": "Huwag piliin lahat", "@@locale": "fil", - "@@last_modified": "2026-01-07 14:26:57.612933", + "@@last_modified": "2026-02-05 10:09:53.428313", "@setCustomPermissionLevel": { "type": "String", "placeholders": {} @@ -7199,14 +7196,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9825,10 +9814,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11801,5 +11786,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Umalis ka sa chat", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Nagsimula ang pag-download", + "webDownloadPermissionMessage": "Kung hinaharang ng iyong browser ang mga pag-download, mangyaring paganahin ang mga pag-download para sa site na ito.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Hindi mase-save ang iyong progreso sa sesyon ng pagsasanay.", + "practiceGrammar": "Magsanay ng gramatika", + "notEnoughToPractice": "Magpadala ng higit pang mga mensahe upang i-unlock ang pagsasanay", + "constructUseCorGCDesc": "Pagsasanay sa tamang kategorya ng gramatika", + "constructUseIncGCDesc": "Pagsasanay sa maling kategorya ng gramatika", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Pagsasanay sa tamang pagkakamali sa gramatika", + "constructUseIncGEDesc": "Pagsasanay sa maling pagkakamali sa gramatika", + "fillInBlank": "Punan ang blangko ng tamang pagpipilian", + "learn": "Matuto", + "languageUpdated": "Na-update ang target na wika!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Boses ng Pangea Bot", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Ang iyong kahilingan ay naipadala sa admin ng kurso! Papayagan ka nilang pumasok kung sila ay mag-aapruba.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Mayroon ka bang invite code o link sa isang pampublikong kurso?", + "welcomeUser": "Maligayang pagdating {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Maghanap ng mga gumagamit upang imbitahan sila sa chat na ito.", + "publicInviteDescSpace": "Maghanap ng mga gumagamit upang imbitahan sila sa espasyong ito.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Ang Pangea Chat ay isang texting app kaya't mahalaga ang mga notification!", + "enableNotificationsDesc": "Pahintulutan ang mga notification", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Gamitin ang larawan ng aktibidad bilang background ng chat", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Makipag-chat sa Suporta", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Sa default, ang mga kurso ay pampublikong searchable at nangangailangan ng pag-apruba ng admin upang sumali. Maaari mong i-edit ang mga setting na ito anumang oras.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Anong wika ang iyong pinag-aaralan?", + "searchLanguagesHint": "Maghanap ng mga target na wika", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "May mga tanong? Nandito kami para tumulong!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "May nangyaring mali, at abala kami sa pag-aayos nito. Suriin muli mamaya.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Paganahin ang tulong sa pagsusulat", + "autoIGCToolDescription": "Awtomatikong patakbuhin ang mga tool ng Pangea Chat upang ituwid ang mga ipinadalang mensahe sa target na wika.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Nabigo ang pag-record. Pakisuri ang iyong mga pahintulot sa audio at subukan muli.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idyoma", + "grammarCopyPOSphrasalv": "Phrasal Verb", + "grammarCopyPOScompn": "Pinagsama", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 39234cc32..30464e9eb 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1,6 +1,6 @@ { "@@locale": "fr", - "@@last_modified": "2026-01-07 14:29:28.310920", + "@@last_modified": "2026-02-05 10:10:24.987990", "about": "À propos", "@about": { "type": "String", @@ -3615,8 +3615,6 @@ "noPaymentInfo": "Aucune information de paiement nécessaire !", "updatePhoneOS": "Vous devrez peut-être mettre à jour la version du système d'exploitation de votre appareil.", "wordsPerMinute": "Mots par minute", - "autoIGCToolName": "Exécuter automatiquement l'assistance à l'écriture Pangea", - "autoIGCToolDescription": "Exécuter automatiquement l'assistance à la grammaire et à la traduction de Pangea Chat avant d'envoyer mon message.", "tooltipInstructionsTitle": "Vous ne savez pas ce que cela fait ?", "tooltipInstructionsMobileBody": "Appuyez longuement sur les éléments pour voir les infobulles.", "tooltipInstructionsBrowserBody": "Survolez les éléments pour voir les infobulles.", @@ -4244,7 +4242,6 @@ "numModules": "{num} modules", "coursePlan": "Plan de cours", "editCourseLater": "Vous pouvez modifier le titre du modèle, les descriptions et l'image du cours plus tard.", - "newCourseAccess": "Par défaut, les cours sont privés et nécessitent l'approbation de l'administrateur pour rejoindre. Vous pouvez modifier ces paramètres à tout moment.", "createCourse": "Créer un cours", "stats": "Statistiques", "createGroupChat": "Créer un chat de groupe", @@ -6530,14 +6527,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9156,10 +9145,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11149,5 +11134,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Vous avez quitté le chat", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Téléchargement initié", + "webDownloadPermissionMessage": "Si votre navigateur bloque les téléchargements, veuillez activer les téléchargements pour ce site.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Les progrès de votre session de pratique ne seront pas enregistrés.", + "practiceGrammar": "Pratiquer la grammaire", + "notEnoughToPractice": "Envoyez plus de messages pour débloquer la pratique", + "constructUseCorGCDesc": "Pratique de la catégorie de grammaire correcte", + "constructUseIncGCDesc": "Pratique de la catégorie de grammaire incorrecte", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Pratique de correction des erreurs grammaticales", + "constructUseIncGEDesc": "Pratique des erreurs grammaticales incorrectes", + "fillInBlank": "Remplissez le blanc avec le choix correct", + "learn": "Apprendre", + "languageUpdated": "Langue cible mise à jour !", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Voix du bot Pangea", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Votre demande a été envoyée à l'administrateur du cours ! Vous serez admis s'ils approuvent.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Avez-vous un code d'invitation ou un lien vers un cours public ?", + "welcomeUser": "Bienvenue {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Recherchez des utilisateurs pour les inviter à ce chat.", + "publicInviteDescSpace": "Recherchez des utilisateurs pour les inviter à cet espace.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat est une application de messagerie, donc les notifications sont importantes !", + "enableNotificationsDesc": "Autoriser les notifications", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Utiliser l'image d'activité comme arrière-plan de chat", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Discuter avec le support", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Par défaut, les cours sont recherchables publiquement et nécessitent l'approbation d'un administrateur pour rejoindre. Vous pouvez modifier ces paramètres à tout moment.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Quelle langue apprenez-vous ?", + "searchLanguagesHint": "Recherchez des langues cibles", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Des questions ? Nous sommes là pour vous aider !", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Quelque chose a mal tourné, et nous travaillons dur pour le réparer. Vérifiez à nouveau plus tard.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Activer l'assistance à l'écriture", + "autoIGCToolDescription": "Exécutez automatiquement les outils de Pangea Chat pour corriger les messages envoyés dans la langue cible.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "L'enregistrement a échoué. Veuillez vérifier vos autorisations audio et réessayer.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Verbe à particule", + "grammarCopyPOScompn": "Composé", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ga.arb b/lib/l10n/intl_ga.arb index 0fbd10091..ac8fb9bf9 100644 --- a/lib/l10n/intl_ga.arb +++ b/lib/l10n/intl_ga.arb @@ -3793,8 +3793,6 @@ "noPaymentInfo": "Níl aon eolas íocaíochta de dhíth!", "updatePhoneOS": "D'fhéadfadh go mbeadh ort do leagan OS a nuashonrú ar do ghléas.", "wordsPerMinute": "Focail in aghaidh na nóimead", - "autoIGCToolName": "Rith cabhair scríbhneoireachta Pangea go huathoibríoch", - "autoIGCToolDescription": "Rith uathoibríoch cabhair gramadaí agus aistriúcháin Pangea Chat sula seolfaidh mé mo theachtaireacht.", "tooltipInstructionsTitle": "Níl tú cinnte cad a dhéanann sé sin?", "tooltipInstructionsMobileBody": "Bain agus coinnigh ar na míreanna chun treoracha a fheiceáil.", "tooltipInstructionsBrowserBody": "Cliceáil agus coinnigh ar na míreanna chun treoracha a fheiceáil.", @@ -4422,7 +4420,6 @@ "numModules": "{num} modúl", "coursePlan": "Plean Cúrsa", "editCourseLater": "Is féidir leat teideal an phlean, cur síos, agus íomhá an chúrsa a chur in eagar níos déanaí.", - "newCourseAccess": "De réir réamhshocraithe, tá cúrsaí príobháideach agus iarrtar cead riarthóra chun páirt a ghlacadh. Is féidir leat na socruithe seo a chur in eagar ag am ar bith.", "createCourse": "Cruthaigh cúrsa", "stats": "Staitisticí", "createGroupChat": "Cruthaigh comhrá grúpa", @@ -4517,7 +4514,7 @@ "playWithAI": "Imir le AI faoi láthair", "courseStartDesc": "Tá Bot Pangea réidh chun dul am ar bith!\n\n...ach is fearr foghlaim le cairde!", "@@locale": "ga", - "@@last_modified": "2026-01-07 14:29:21.686769", + "@@last_modified": "2026-02-05 10:10:23.901035", "@customReaction": { "type": "String", "placeholders": {} @@ -6204,14 +6201,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -8830,10 +8819,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10823,5 +10808,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 D'fhág tú an comhrá", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Tosaíodh an íoslódáil", + "webDownloadPermissionMessage": "Más blocann do bhrabhsálaí íoslódálacha, le do thoil, gníomhachtaigh íoslódálacha don suíomh seo.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Ní shábhálfar do dhul chun cinn sa seisiún cleachtaidh.", + "practiceGrammar": "Cleachtaigh gramadach", + "notEnoughToPractice": "Seol níos mó teachtaireachtaí chun cleachtadh a dhíghlasáil", + "constructUseCorGCDesc": "Cleachtadh catagóir gramadaí ceart", + "constructUseIncGCDesc": "Cleachtadh catagóir gramadaí mícheart", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Cleachtadh ar earráidí gramadaí ceart", + "constructUseIncGEDesc": "Cleachtadh ar earráidí gramadaí míchruinn", + "fillInBlank": "Líon isteach an folt le rogha cheart", + "learn": "Foghlaim", + "languageUpdated": "Teanga sprioc nuashonraithe!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "guth Pangea Bot", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Tá do hiarratas curtha chuig an riarachán cúrsa! Cuirfear isteach thú má cheadaíonn siad é.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "An bhfuil cód cuireadh nó nasc agat do chúrsa poiblí?", + "welcomeUser": "Fáilte {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Cuardaigh úsáideoirí le cuireadh a thabhairt dóibh chuig an gcomhrá seo.", + "publicInviteDescSpace": "Cuardaigh úsáideoirí le cuireadh a thabhairt dóibh chuig an spás seo.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Is aip téacsála í Pangea Chat mar sin tá fógraí tábhachtach!", + "enableNotificationsDesc": "Cuir fógraí ar cead", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Úsáid íomhá gníomhaíochta mar chúlra comhrá", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Comhrá le Tacaíocht", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "De réir réamhshocraithe, tá cúrsaí inrochtana go poiblí agus éilíonn siad cead ó riarachán chun páirt a ghlacadh. Is féidir leat na socruithe seo a chur in eagar ag am ar bith.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Cén teanga atá á foghlaim agat?", + "searchLanguagesHint": "Cuardaigh teangacha sprioc", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Ceisteanna? Táimid anseo chun cabhrú!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Tharla rud éigin mícheart, agus táimid ag obair go dian chun é a shocrú. Seiceáil arís níos déanaí.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Cuir ar chumas cúnamh scríbhneoireachta", + "autoIGCToolDescription": "Rith uathoibríoch uirlisí Pangea Chat chun teachtaireachtaí a sheoladh a cheartú go teanga sprioc.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Theip ar an taifeadadh. Seiceáil do cheadanna gutháin le do thoil agus déan iarracht arís.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Frása", + "grammarCopyPOSphrasalv": "Gníomhhacht Phrásúil", + "grammarCopyPOScompn": "Comhoibriú", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_gl.arb b/lib/l10n/intl_gl.arb index 75e246ad8..44263a6c5 100644 --- a/lib/l10n/intl_gl.arb +++ b/lib/l10n/intl_gl.arb @@ -1,6 +1,6 @@ { "@@locale": "gl", - "@@last_modified": "2026-01-07 14:23:32.582541", + "@@last_modified": "2026-02-05 10:09:14.434046", "about": "Acerca de", "@about": { "type": "String", @@ -3786,8 +3786,6 @@ "noPaymentInfo": "Non é necesaria información de pagamento!", "updatePhoneOS": "Pode que necesites actualizar a versión do sistema operativo do teu dispositivo.", "wordsPerMinute": "Palabras por minuto", - "autoIGCToolName": "Executar a asistencia de escritura Pangea automaticamente", - "autoIGCToolDescription": "Executar automaticamente a asistencia de gramática e tradución de Pangea Chat antes de enviar a miña mensaxe.", "tooltipInstructionsTitle": "Non estás seguro de para que serve iso?", "tooltipInstructionsMobileBody": "Prema e mantén os elementos para ver as pistas.", "tooltipInstructionsBrowserBody": "Pasa o rato por riba dos elementos para ver as pistas.", @@ -4415,7 +4413,6 @@ "numModules": "{num} módulos", "coursePlan": "Plan de curso", "editCourseLater": "Podes editar o título da plantilla, as descricións e a imaxe do curso máis tarde.", - "newCourseAccess": "Por defecto, os cursos son privados e requiren a aprobación do administrador para unirse. Podes editar estas configuracións en calquera momento.", "createCourse": "Crear curso", "stats": "Estadísticas", "createGroupChat": "Crear chat de grupo", @@ -6203,14 +6200,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -8829,10 +8818,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10822,5 +10807,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Saíches do chat", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Descarga iniciada", + "webDownloadPermissionMessage": "Se o teu navegador bloquea descargas, por favor, habilita as descargas para este sitio.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "O progreso da túa sesión de práctica non se gardará.", + "practiceGrammar": "Practicar gramática", + "notEnoughToPractice": "Envía máis mensaxes para desbloquear a práctica", + "constructUseCorGCDesc": "Práctica da categoría de gramática correcta", + "constructUseIncGCDesc": "Práctica da categoría de gramática incorrecta", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Práctica de erro gramatical correcto", + "constructUseIncGEDesc": "Práctica de erro gramatical incorrecto", + "fillInBlank": "Completa o espazo en branco coa opción correcta", + "learn": "Aprender", + "languageUpdated": "Idioma de destino actualizado!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Voz do bot Pangea", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "A túa solicitude foi enviada ao administrador do curso! Serás admitido se a aproban.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Tes un código de invitación ou un enlace a un curso público?", + "welcomeUser": "Benvido {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Busca usuarios para convidalos a este chat.", + "publicInviteDescSpace": "Busca usuarios para convidalos a este espazo.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat é unha aplicación de mensaxería, así que as notificacións son importantes!", + "enableNotificationsDesc": "Permitir notificacións", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Usa a imaxe de actividade como fondo de chat", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Chatear co Soporte", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Por defecto, os cursos son buscables públicamente e requiren aprobación do administrador para unirse. Podes editar estas configuracións en calquera momento.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Que idioma estás aprendendo?", + "searchLanguagesHint": "Busca idiomas de destino", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "¿Preguntas? Estamos aquí para axudar!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Algo saíu mal e estamos traballando duro para solucionalo. Comproba de novo máis tarde.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Activar a asistencia de escritura", + "autoIGCToolDescription": "Executar automaticamente as ferramentas de Pangea Chat para corrixir os mensaxes enviados á lingua de destino.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "A gravación fallou. Por favor, verifica os teus permisos de audio e intenta de novo.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Verbo Frasal", + "grammarCopyPOScompn": "Composto", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_he.arb b/lib/l10n/intl_he.arb index 06e395102..63f1dae8f 100644 --- a/lib/l10n/intl_he.arb +++ b/lib/l10n/intl_he.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:24:41.103817", + "@@last_modified": "2026-02-05 10:09:28.796405", "about": "אודות", "@about": { "type": "String", @@ -2371,8 +2371,6 @@ "noPaymentInfo": "אין צורך במידע תשלום!", "updatePhoneOS": "ייתכן שתצטרך לעדכן את גרסת מערכת ההפעלה של המכשיר שלך.", "wordsPerMinute": "מילים בדקה", - "autoIGCToolName": "הרץ אוטומטית את עזר הכתיבה של פאנגיאה", - "autoIGCToolDescription": "הרץ אוטומטית את עזר הכתיבה של דקדוק ותרגום של פאנגיאה לפני שליחת ההודעה שלי.", "tooltipInstructionsTitle": "לא בטוח מה זה עושה?", "tooltipInstructionsMobileBody": "החזק והחלק על פריטים כדי לצפות בטיפים.", "tooltipInstructionsBrowserBody": "החלק מעל פריטים כדי לצפות בטיפים.", @@ -3000,7 +2998,6 @@ "numModules": "{num} מודולים", "coursePlan": "תכנית קורס", "editCourseLater": "אתה יכול לערוך את כותרת התבנית, תיאורים ותמונת הקורס מאוחר יותר.", - "newCourseAccess": "ברירת מחדל, קורסים הם פרטיים ודורשים אישור מנהל להצטרפות. תוכל לערוך הגדרות אלה בכל עת.", "createCourse": "צור קורס", "stats": "סטטיסטיקות", "createGroupChat": "צור שיחת קבוצתית", @@ -7255,14 +7252,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9881,10 +9870,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11874,5 +11859,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 עזבת את הצ'אט", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "ההורדה החלה", + "webDownloadPermissionMessage": "אם הדפדפן שלך חוסם הורדות, אנא אפשר הורדות לאתר זה.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "ההתקדמות שלך במפגש האימון לא תישמר.", + "practiceGrammar": "אימון דקדוק", + "notEnoughToPractice": "שלח יותר הודעות כדי לפתוח אימון", + "constructUseCorGCDesc": "אימון בקטגוריית דקדוק נכון", + "constructUseIncGCDesc": "אימון בקטגוריית דקדוק לא נכון", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "תרגול תיקון שגיאות דקדוק", + "constructUseIncGEDesc": "תרגול שגיאות דקדוק לא נכונות", + "fillInBlank": "מלא את החסר עם הבחירה הנכונה", + "learn": "ללמוד", + "languageUpdated": "שפת היעד עודכנה!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "קול של פנגיאה בוט", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "הבקשה שלך נשלחה למנהל הקורס! תורשה להיכנס אם הם יאשרו.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "האם יש לך קוד הזמנה או קישור לקורס ציבורי?", + "welcomeUser": "ברוך הבא {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "חפש משתמשים כדי להזמין אותם לצ'אט הזה.", + "publicInviteDescSpace": "חפש משתמשים כדי להזמין אותם למקום הזה.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat היא אפליקציית הודעות, ולכן התראות הן חשובות!", + "enableNotificationsDesc": "אפשר התראות", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "השתמש בתמונה של הפעילות כרקע לצ'אט", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "צ'אט עם תמיכה", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "ברירת מחדל, קורסים ניתנים לחיפוש ציבורי ודורשים אישור מנהל כדי להצטרף. אתה יכול לערוך את ההגדרות הללו בכל עת.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "איזו שפה אתה לומד?", + "searchLanguagesHint": "חפש שפות יעד", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "שאלות? אנחנו כאן כדי לעזור!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "משהו השתבש, ואנחנו עובדים קשה על תיקון זה. בדוק שוב מאוחר יותר.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "אפשר סיוע בכתיבה", + "autoIGCToolDescription": "הרץ אוטומטית את כלי Pangea Chat כדי לתקן הודעות שנשלחו לשפה היעד.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "הקלטה נכשלה. אנא בדוק את הרשאות האודיו שלך ונסה שוב.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "ביטוי", + "grammarCopyPOSphrasalv": "פועל פיזי", + "grammarCopyPOScompn": "מורכב", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_hi.arb b/lib/l10n/intl_hi.arb index f0a80fd80..d7758c041 100644 --- a/lib/l10n/intl_hi.arb +++ b/lib/l10n/intl_hi.arb @@ -3759,8 +3759,6 @@ "noPaymentInfo": "कोई भुगतान जानकारी आवश्यक नहीं!", "updatePhoneOS": "आपको अपने डिवाइस का OS संस्करण अपडेट करने की आवश्यकता हो सकती है।", "wordsPerMinute": "मिनट में शब्द", - "autoIGCToolName": "स्वचालित रूप से पैंजिया लेखन सहायता चलाएँ", - "autoIGCToolDescription": "मेरे संदेश भेजने से पहले स्वचालित रूप से पैंजिया चैट व्याकरण और अनुवाद लेखन सहायता चलाएँ।", "tooltipInstructionsTitle": "क्या यह करता है, इसके बारे में सुनिश्चित नहीं?", "tooltipInstructionsMobileBody": "आइटम को दबाकर रखें और टूलटिप देखने के लिए होवर करें।", "tooltipInstructionsBrowserBody": "आइटम पर होवर करें और टूलटिप देखने के लिए होवर करें।", @@ -4388,7 +4386,6 @@ "numModules": "{num} मॉड्यूल", "coursePlan": "कोर्स योजना", "editCourseLater": "आप बाद में टेम्पलेट का शीर्षक, विवरण और कोर्स छवि संपादित कर सकते हैं।", - "newCourseAccess": "डिफ़ॉल्ट रूप से, कोर्स निजी होते हैं और शामिल होने के लिए व्यवस्थापक अनुमोदन की आवश्यकता होती है। आप इन सेटिंग्स को कभी भी संपादित कर सकते हैं।", "createCourse": "कोर्स बनाएं", "stats": "आंकड़े", "createGroupChat": "समूह चैट बनाएं", @@ -4483,7 +4480,7 @@ "playWithAI": "अभी के लिए एआई के साथ खेलें", "courseStartDesc": "पैंजिया बॉट कभी भी जाने के लिए तैयार है!\n\n...लेकिन दोस्तों के साथ सीखना बेहतर है!", "@@locale": "hi", - "@@last_modified": "2026-01-07 14:28:46.662693", + "@@last_modified": "2026-02-05 10:10:16.696075", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -7291,14 +7288,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9917,10 +9906,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11910,5 +11895,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 आप चैट छोड़ चुके हैं", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "डाउनलोड शुरू किया गया", + "webDownloadPermissionMessage": "यदि आपका ब्राउज़र डाउनलोड को ब्लॉक करता है, तो कृपया इस साइट के लिए डाउनलोड सक्षम करें।", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "आपकी प्रैक्टिस सत्र की प्रगति सहेजी नहीं जाएगी।", + "practiceGrammar": "व्याकरण का अभ्यास करें", + "notEnoughToPractice": "अभ्यास अनलॉक करने के लिए अधिक संदेश भेजें", + "constructUseCorGCDesc": "सही व्याकरण श्रेणी का अभ्यास", + "constructUseIncGCDesc": "गलत व्याकरण श्रेणी का अभ्यास", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "व्याकरण त्रुटि सुधार अभ्यास", + "constructUseIncGEDesc": "व्याकरण त्रुटि गलत अभ्यास", + "fillInBlank": "सही विकल्प के साथ रिक्त स्थान भरें", + "learn": "सीखें", + "languageUpdated": "लक्षित भाषा अपडेट की गई!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "पैंगिया बॉट की आवाज़", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "आपका अनुरोध पाठ्यक्रम प्रशासन को भेज दिया गया है! यदि वे स्वीकृत करते हैं, तो आपको अंदर जाने दिया जाएगा।", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "क्या आपके पास एक आमंत्रण कोड या सार्वजनिक पाठ्यक्रम के लिए लिंक है?", + "welcomeUser": "स्वागत है {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "इस चैट में आमंत्रित करने के लिए उपयोगकर्ताओं की खोज करें।", + "publicInviteDescSpace": "इस स्थान में आमंत्रित करने के लिए उपयोगकर्ताओं की खोज करें।", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea चैट एक टेक्स्टिंग ऐप है इसलिए सूचनाएँ महत्वपूर्ण हैं!", + "enableNotificationsDesc": "सूचनाएँ अनुमति दें", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "चैट पृष्ठभूमि के रूप में गतिविधि छवि का उपयोग करें", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "सहायता से चैट करें", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "डिफ़ॉल्ट रूप से, पाठ्यक्रम सार्वजनिक रूप से खोजे जा सकते हैं और शामिल होने के लिए प्रशासक की स्वीकृति की आवश्यकता होती है। आप किसी भी समय इन सेटिंग्स को संपादित कर सकते हैं।", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "आप कौन सी भाषा सीख रहे हैं?", + "searchLanguagesHint": "लक्षित भाषाएँ खोजें", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "प्रश्न? हम आपकी मदद के लिए यहाँ हैं!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "कुछ गलत हो गया है, और हम इसे ठीक करने में कड़ी मेहनत कर रहे हैं। बाद में फिर से जांचें।", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "लेखन सहायता सक्षम करें", + "autoIGCToolDescription": "लक्षित भाषा में भेजे गए संदेशों को सही करने के लिए स्वचालित रूप से Pangea चैट उपकरण चलाएँ।", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "रिकॉर्डिंग विफल हो गई। कृपया अपनी ऑडियो अनुमति की जांच करें और फिर से प्रयास करें।", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "मुहावरा", + "grammarCopyPOSphrasalv": "फ्रेज़ल वर्ब", + "grammarCopyPOScompn": "संयुक्त", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_hr.arb b/lib/l10n/intl_hr.arb index a60833d29..fa3e6d919 100644 --- a/lib/l10n/intl_hr.arb +++ b/lib/l10n/intl_hr.arb @@ -1,6 +1,6 @@ { "@@locale": "hr", - "@@last_modified": "2026-01-07 14:24:33.836325", + "@@last_modified": "2026-02-05 10:09:27.459987", "about": "Informacije", "@about": { "type": "String", @@ -3541,8 +3541,6 @@ "noPaymentInfo": "Nije potrebna informacija o plaćanju!", "updatePhoneOS": "Možda ćete morati ažurirati verziju OS-a na svom uređaju.", "wordsPerMinute": "Riječi po minuti", - "autoIGCToolName": "Automatski pokreni pomoć za pisanje Pangea", - "autoIGCToolDescription": "Automatski pokreni pomoć za gramatiku i prijevod Pangea Chat prije slanja moje poruke.", "tooltipInstructionsTitle": "Niste sigurni što to radi?", "tooltipInstructionsMobileBody": "Dugim pritiskom na stavke prikazuju se alati za pomoć.", "tooltipInstructionsBrowserBody": "Pomičite mišem preko stavki za prikazivanje saveta.", @@ -4170,7 +4168,6 @@ "numModules": "{num} modula", "coursePlan": "Plan tečaja", "editCourseLater": "Možete kasnije urediti naslov predloška, opise i sliku tečaja.", - "newCourseAccess": "Prema zadanim postavkama, tečajevi su privatni i zahtijevaju odobrenje administratora za pridruživanje. Možete ove postavke urediti u bilo koje vrijeme.", "createCourse": "Stvori tečaj", "stats": "Statistike", "createGroupChat": "Stvori grupni razgovor", @@ -6578,14 +6575,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9204,10 +9193,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11197,5 +11182,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Napustili ste chat", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Preuzimanje pokrenuto", + "webDownloadPermissionMessage": "Ako vaš preglednik blokira preuzimanja, molimo omogućite preuzimanja za ovu stranicu.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Vaš napredak u vježbi neće biti spremljen.", + "practiceGrammar": "Vježbajte gramatiku", + "notEnoughToPractice": "Pošaljite više poruka da otključate vježbu", + "constructUseCorGCDesc": "Vježba ispravne gramatičke kategorije", + "constructUseIncGCDesc": "Vježba neispravne gramatičke kategorije", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Prakticiranje ispravne gramatičke greške", + "constructUseIncGEDesc": "Prakticiranje pogrešne gramatičke greške", + "fillInBlank": "Ispunite prazno mjesto s ispravnim izborom", + "learn": "Učite", + "languageUpdated": "Ciljani jezik ažuriran!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Bot glas", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Vaš zahtjev je poslan administratoru tečaja! Bit ćete primljeni ako odobre.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Imate li pozivni kod ili link za javni tečaj?", + "welcomeUser": "Dobrodošli {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Pretražite korisnike kako biste ih pozvali u ovaj chat.", + "publicInviteDescSpace": "Pretražite korisnike kako biste ih pozvali u ovaj prostor.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat je aplikacija za slanje poruka, stoga su obavijesti važne!", + "enableNotificationsDesc": "Dopusti obavijesti", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Koristi sliku aktivnosti kao pozadinu chata", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Razgovarajte s podrškom", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Prema zadanim postavkama, tečajevi su javno pretraživi i zahtijevaju odobrenje administratora za pridruživanje. Ove postavke možete urediti u bilo kojem trenutku.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Koji jezik učite?", + "searchLanguagesHint": "Pretraži ciljne jezike", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Imate pitanja? Tu smo da pomognemo!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Nešto je pošlo po zlu i marljivo radimo na rješavanju problema. Provjerite ponovo kasnije.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Omogući pomoć pri pisanju", + "autoIGCToolDescription": "Automatski pokreni Pangea Chat alate za ispravljanje poslanih poruka na ciljni jezik.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Snimanje nije uspjelo. Provjerite svoja audio dopuštenja i pokušajte ponovo.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Phrasalni Glagol", + "grammarCopyPOScompn": "Složenica", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_hu.arb b/lib/l10n/intl_hu.arb index 3683bcbe0..7cdb4f9a1 100644 --- a/lib/l10n/intl_hu.arb +++ b/lib/l10n/intl_hu.arb @@ -1,6 +1,6 @@ { "@@locale": "hu", - "@@last_modified": "2026-01-07 14:23:56.196432", + "@@last_modified": "2026-02-05 10:09:19.675804", "about": "Névjegy", "@about": { "type": "String", @@ -3786,8 +3786,6 @@ "noPaymentInfo": "Nincs szükség fizetési adatokra!", "updatePhoneOS": "Előfordulhat, hogy frissítenie kell az eszköz operációs rendszerét.", "wordsPerMinute": "Szavak száma percenként", - "autoIGCToolName": "A Pangea írássegéd automatikus futtatása", - "autoIGCToolDescription": "Automatikusan futtassa a Pangea Chat nyelvtani és fordítási írássegédet az üzenetem küldése előtt.", "tooltipInstructionsTitle": "Nem biztos benne, mit csinál ez?", "tooltipInstructionsMobileBody": "Hosszan nyomja meg az elemeket a súgók megtekintéséhez.", "tooltipInstructionsBrowserBody": "Húzza az egérrel az elemek fölé a súgók megtekintéséhez.", @@ -4415,7 +4413,6 @@ "numModules": "{num} modul", "coursePlan": "Tanfolyamterv", "editCourseLater": "Később szerkesztheti a sablon címet, leírásokat és a tanfolyam képét.", - "newCourseAccess": "Alapértelmezés szerint a tanfolyamok privátak, és admin jóváhagyását igénylik a csatlakozáshoz. Ezeket a beállításokat bármikor szerkesztheti.", "createCourse": "Tanfolyam létrehozása", "stats": "Statisztika", "createGroupChat": "Csoportos beszélgetés létrehozása", @@ -6207,14 +6204,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -8833,10 +8822,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10826,5 +10811,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Elhagytad a csevegést", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Letöltés megkezdődött", + "webDownloadPermissionMessage": "Ha a böngésződ blokkolja a letöltéseket, kérlek engedélyezd a letöltéseket ezen az oldalon.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "A gyakorlási session előrehaladása nem lesz mentve.", + "practiceGrammar": "Nyelvtan gyakorlása", + "notEnoughToPractice": "Több üzenetet kell küldeni a gyakorlás feloldásához", + "constructUseCorGCDesc": "Helyes nyelvtani kategória gyakorlása", + "constructUseIncGCDesc": "Helytelen nyelvtani kategória gyakorlása", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Helyes nyelvtani hiba gyakorlás", + "constructUseIncGEDesc": "Helytelen nyelvtani hiba gyakorlás", + "fillInBlank": "Töltsd ki a hiányzó részt a helyes választással", + "learn": "Tanulj", + "languageUpdated": "Cél nyelv frissítve!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Bot hang", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "A kérésed el lett küldve a kurzus adminisztrátorának! Be fogsz engedni, ha jóváhagyják.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Van meghívó kódod vagy linked egy nyilvános kurzushoz?", + "welcomeUser": "Üdvözöljük {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Keresd meg a felhasználókat, hogy meghívd őket erre a csevegésre.", + "publicInviteDescSpace": "Keresd meg a felhasználókat, hogy meghívd őket erre a térre.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "A Pangea Chat egy üzenetküldő alkalmazás, így a értesítések fontosak!", + "enableNotificationsDesc": "Értesítések engedélyezése", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Használja az aktivitás képet csevegési háttérként", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Csevegés a Támogatással", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Alapértelmezés szerint a kurzusok nyilvánosan kereshetők, és adminisztrátori jóváhagyás szükséges a csatlakozáshoz. Ezeket a beállításokat bármikor módosíthatja.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Milyen nyelvet tanulsz?", + "searchLanguagesHint": "Keresd a célnyelveket", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Kérdése van? Itt vagyunk, hogy segítsünk!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Valami hiba történt, és keményen dolgozunk a javításon. Kérlek, nézd meg később.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Írássegítő engedélyezése", + "autoIGCToolDescription": "Automatikusan futtassa a Pangea Chat eszközöket a küldött üzenetek célnyelvre történő javításához.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "A felvétel nem sikerült. Kérjük, ellenőrizze az audio engedélyeit, és próbálja újra.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idióma", + "grammarCopyPOSphrasalv": "Frazális ige", + "grammarCopyPOScompn": "Összetett", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ia.arb b/lib/l10n/intl_ia.arb index 40ca83dba..3321a7c8a 100644 --- a/lib/l10n/intl_ia.arb +++ b/lib/l10n/intl_ia.arb @@ -1234,8 +1234,6 @@ "noPaymentInfo": "Nulle information de pagamento necessari!", "updatePhoneOS": "Es possibile que tu necesse actualisar le versione del sistema operative de tu dispositivo.", "wordsPerMinute": "Palabras per minuto", - "autoIGCToolName": "Execute automaticemente le assistance de scriber Pangea", - "autoIGCToolDescription": "Execute automaticamente le assistance de grammatica e traduction de Pangea Chat ante de inviar mi message.", "tooltipInstructionsTitle": "Nescite que illo face?", "tooltipInstructionsMobileBody": "Pressa e tene le items pro vider le tooltip.", "tooltipInstructionsBrowserBody": "Survole le items pro vider le tooltip.", @@ -1863,7 +1861,6 @@ "numModules": "{num} modulo(s)", "coursePlan": "Plan de curso", "editCourseLater": "Tu pote modificar le titulo del curso, descriptiones, e imagine del curso postea.", - "newCourseAccess": "A default, le cursos es private e require approbation del administrator pro aderir. Tu pote modificar iste configurationes a omne momento.", "createCourse": "Create un curso", "stats": "Statisticas", "createGroupChat": "Create un chat de grupo", @@ -1958,7 +1955,7 @@ "playWithAI": "Joca con le IA pro ora", "courseStartDesc": "Pangea Bot es preste a comenzar a qualunque momento!\n\n...ma apprender es melior con amicos!", "@@locale": "ia", - "@@last_modified": "2026-01-07 14:24:47.787013", + "@@last_modified": "2026-02-05 10:09:29.962506", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -7284,14 +7281,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9910,10 +9899,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11903,5 +11888,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Tu lëvë chatin", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Download inisiati", + "webDownloadPermissionMessage": "Se o teu navegador bloqueia descargas, por favor habilita descargas para este site.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Progresul sesiunii tale de practică nu va fi salvat.", + "practiceGrammar": "Exersează gramatică", + "notEnoughToPractice": "Trimite mai multe mesaje pentru a debloca practica", + "constructUseCorGCDesc": "Practică categoria de gramatică corectă", + "constructUseIncGCDesc": "Practică categoria de gramatică incorectă", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Praktiko de ĝusta gramatika eraro", + "constructUseIncGEDesc": "Praktiko de malĝusta gramatika eraro", + "fillInBlank": "Plenigu la malplenan lokon kun la ĝusta elekto", + "learn": "Lerni", + "languageUpdated": "Celo lingvo ĝisdatigita!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Voix du bot Pangea", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Tua peticio est missa ad administratorem cursuum! Te admittent si illi approbant.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "¿Tienes un código de invitación o un enlace a un curso público?", + "welcomeUser": "Bienvenido {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Cerca per utenti per invitarli a questa chat.", + "publicInviteDescSpace": "Cerca per utenti per invitarli a questo spazio.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat est un application de messagerie donc les notifications sont importantes !", + "enableNotificationsDesc": "Autoriser les notifications", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Usa l'immagine dell'attività come sfondo della chat", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Fala com o Suporte", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Per default, kursusene er offentlig søkbare og krever admin-godkjenning for å bli med. Du kan redigere disse innstillingene når som helst.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Kia lingvo vi lernas?", + "searchLanguagesHint": "Serĉu celajn lingvojn", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Kwestyon? Nou la pou ede!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "N'ayen a fau, e n'ayen a t'awen a t'awen a t'awen. T'awen a t'awen a t'awen.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Abilita l'assistenza alla scrittura", + "autoIGCToolDescription": "Esegui automaticamente gli strumenti di Pangea Chat per correggere i messaggi inviati nella lingua target.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Fala falhou. Por favor, verifique suas permissões de áudio e tente novamente.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Verbo Phrasal", + "grammarCopyPOScompn": "Compuesto", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_id.arb b/lib/l10n/intl_id.arb index 64cf24c41..cb48fe670 100644 --- a/lib/l10n/intl_id.arb +++ b/lib/l10n/intl_id.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:24:04.023956", + "@@last_modified": "2026-02-05 10:09:21.065759", "setAsCanonicalAlias": "Atur sebagai alias utama", "@setAsCanonicalAlias": { "type": "String", @@ -3787,8 +3787,6 @@ "noPaymentInfo": "Tidak perlu info pembayaran!", "updatePhoneOS": "Anda mungkin perlu memperbarui versi OS perangkat Anda.", "wordsPerMinute": "Kata per menit", - "autoIGCToolName": "Jalankan otomatis bantuan penulisan Pangea", - "autoIGCToolDescription": "Jalankan otomatis bantuan penulisan tata bahasa dan terjemahan Chat Pangea sebelum mengirim pesan saya.", "tooltipInstructionsTitle": "Tidak yakin apa itu?", "tooltipInstructionsMobileBody": "Tekan dan tahan item untuk melihat tooltip.", "tooltipInstructionsBrowserBody": "Arahkan kursor ke item untuk melihat tooltip.", @@ -4416,7 +4414,6 @@ "numModules": "{num} modul", "coursePlan": "Rencana Kursus", "editCourseLater": "Anda dapat mengedit judul template, deskripsi, dan gambar kursus nanti.", - "newCourseAccess": "Secara default, kursus bersifat pribadi dan memerlukan persetujuan admin untuk bergabung. Anda dapat mengedit pengaturan ini kapan saja.", "createCourse": "Buat kursus", "stats": "Statistik", "createGroupChat": "Buat obrolan grup", @@ -6197,14 +6194,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -8823,10 +8812,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10816,5 +10801,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Anda meninggalkan obrolan", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Unduhan dimulai", + "webDownloadPermissionMessage": "Jika browser Anda memblokir unduhan, silakan aktifkan unduhan untuk situs ini.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Kemajuan sesi latihan Anda tidak akan disimpan.", + "practiceGrammar": "Latihan tata bahasa", + "notEnoughToPractice": "Kirim lebih banyak pesan untuk membuka latihan", + "constructUseCorGCDesc": "Latihan kategori tata bahasa yang benar", + "constructUseIncGCDesc": "Latihan kategori tata bahasa yang salah", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Latihan kesalahan tata bahasa yang benar", + "constructUseIncGEDesc": "Latihan kesalahan tata bahasa yang salah", + "fillInBlank": "Isi kekosongan dengan pilihan yang benar", + "learn": "Belajar", + "languageUpdated": "Bahasa target diperbarui!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Suara Pangea Bot", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Permintaan Anda telah dikirim ke admin kursus! Anda akan diizinkan masuk jika mereka menyetujuinya.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Apakah Anda memiliki kode undangan atau tautan ke kursus publik?", + "welcomeUser": "Selamat datang {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Cari pengguna untuk mengundang mereka ke obrolan ini.", + "publicInviteDescSpace": "Cari pengguna untuk mengundang mereka ke ruang ini.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat adalah aplikasi pesan, jadi notifikasi itu penting!", + "enableNotificationsDesc": "Izinkan notifikasi", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Gunakan gambar aktivitas sebagai latar belakang obrolan", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Chat dengan Dukungan", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Secara default, kursus dapat dicari secara publik dan memerlukan persetujuan admin untuk bergabung. Anda dapat mengedit pengaturan ini kapan saja.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Bahasa apa yang Anda pelajari?", + "searchLanguagesHint": "Cari bahasa target", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Pertanyaan? Kami di sini untuk membantu!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Ada yang tidak beres, dan kami sedang bekerja keras untuk memperbaikinya. Periksa lagi nanti.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Aktifkan bantuan penulisan", + "autoIGCToolDescription": "Secara otomatis menjalankan alat Pangea Chat untuk memperbaiki pesan yang dikirim ke bahasa target.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Perekaman gagal. Silakan periksa izin audio Anda dan coba lagi.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Kata Kerja Phrasal", + "grammarCopyPOScompn": "Kombinasi", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ie.arb b/lib/l10n/intl_ie.arb index af33f1f37..d473493ff 100644 --- a/lib/l10n/intl_ie.arb +++ b/lib/l10n/intl_ie.arb @@ -3648,8 +3648,6 @@ "noPaymentInfo": "Nulle information de pagamento necessari!", "updatePhoneOS": "Tu pote haber de actualizar la version del sistema operative de tu dispositivo", "wordsPerMinute": "Palabras per minutu", - "autoIGCToolName": "Execute automaticamente le assistent de scriber Pangea", - "autoIGCToolDescription": "Execute automaticamente le grammatica e traduction del chat Pangea ante de inviar mi message", "tooltipInstructionsTitle": "Non es secur de que isto face?", "tooltipInstructionsMobileBody": "Pressa e tenea items pro vider le tooltip.", "tooltipInstructionsBrowserBody": "Survole items pro vider le tooltip.", @@ -4277,7 +4275,6 @@ "numModules": "{num} modulo(s)", "coursePlan": "Plan de cors", "editCourseLater": "Tu pote editar le titulo, descriptiones e imagine del cors plus tarde.", - "newCourseAccess": "Per defaut, los cors es privat e require approbation del admin pro aderir. Tu pote modificar iste parametros a qualunque momento.", "createCourse": "Createar cors", "stats": "Statisticas", "createGroupChat": "Createar chat de gruppo", @@ -4372,7 +4369,7 @@ "playWithAI": "Joca con AI pro ora", "courseStartDesc": "Pangea Bot es preste a partir a qualunque momento!\n\n...ma apprender es melior con amicos!", "@@locale": "ie", - "@@last_modified": "2026-01-07 14:24:25.335678", + "@@last_modified": "2026-02-05 10:09:26.195275", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -7180,14 +7177,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9806,10 +9795,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11799,5 +11784,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 T'adhair tú an comhrá", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Íoslódáil tosaíodh", + "webDownloadPermissionMessage": "Más blocann do bhrabhsálaí íoslódálacha, le do thoil, gníomhachtaigh íoslódálacha don suíomh seo.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Tua progressio sessionis exercitationis non servabitur.", + "practiceGrammar": "Exercitia grammatica", + "notEnoughToPractice": "Mitte plura nuntia ad exercitium aperiendum", + "constructUseCorGCDesc": "Correcta grammaticae categoriae exercitium", + "constructUseIncGCDesc": "Incorrecta grammaticae categoriae exercitium", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Praktika korrekta gramatikfehler", + "constructUseIncGEDesc": "Praktika inkorrekt gramatikfehler", + "fillInBlank": "Fyll i tomrummet med det korrekta valget", + "learn": "Lær", + "languageUpdated": "Mål sprog opdateret!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Bot guth", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Tua iarrtas a chaidh a chur gu rianachd a' chùrsa! Thèid thu a leigeil a-steach ma tha iad a' freagairt gu math.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "¿Tienes un código de invitación o un enlace a un curso público?", + "welcomeUser": "Bienvenido {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Cuir fiosrúcháin ar úsáideoirí chun iad a gcuir isteach sa chomhrá seo.", + "publicInviteDescSpace": "Cuir fiosrúcháin ar úsáideoirí chun iad a gcuir isteach sa spás seo.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat é uma aplicação de mensagens, por isso as notificações são importantes!", + "enableNotificationsDesc": "Permitir notificações", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Úsáid íomhá gníomhaíochta mar chúlra comhrá", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Chat le Tacaíocht", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "De fao, is eardh a tha ann an coircean a tha ri fhaighinn gu poblach agus tha feum air aontachadh bho rianadair gus freagairt. Faodaidh tu na suidheachaidhean sin a dheasachadh aig àm sam bith.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Cén teanga atá á foghlaim agat?", + "searchLanguagesHint": "Cuardaigh teangacha sprioc", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Ceisteanna? Táimid anseo chun cabhrú!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Níl aon rud ag dul i gceart, agus táimid ag obair go dian chun é a shocrú. Seiceáil arís níos déanaí.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Aktivoi kirjoitusavustaja", + "autoIGCToolDescription": "Suorita automaattisesti Pangea Chat -työkaluja korjataksesi lähetetyt viestit kohdekielelle.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Faillí an taifeadadh. Seiceáil do cheadanna gutháin agus déan iarracht arís.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Phrasal Verb", + "grammarCopyPOScompn": "Composé", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index ca4d47338..066e58676 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:25:44.793254", + "@@last_modified": "2026-02-05 10:09:41.124551", "about": "Informazioni", "@about": { "type": "String", @@ -3764,8 +3764,6 @@ "noPaymentInfo": "Nessuna informazione di pagamento necessaria!", "updatePhoneOS": "Potresti dover aggiornare la versione del sistema operativo del tuo dispositivo.", "wordsPerMinute": "Parole al minuto", - "autoIGCToolName": "Esegui automaticamente l'assistenza alla scrittura Pangea", - "autoIGCToolDescription": "Esegui automaticamente l'assistenza alla grammatica e alla traduzione di Pangea Chat prima di inviare il mio messaggio.", "tooltipInstructionsTitle": "Non sei sicuro di cosa faccia?", "tooltipInstructionsMobileBody": "Tieni premuti gli elementi per visualizzare i suggerimenti.", "tooltipInstructionsBrowserBody": "Passa il mouse sugli elementi per visualizzare i suggerimenti.", @@ -4393,7 +4391,6 @@ "numModules": "{num} moduli", "coursePlan": "Piano del corso", "editCourseLater": "Puoi modificare in seguito il titolo del corso, le descrizioni e l'immagine del corso.", - "newCourseAccess": "Per impostazione predefinita, i corsi sono privati e richiedono l'approvazione dell'amministratore per parteciparvi. Puoi modificare queste impostazioni in qualsiasi momento.", "createCourse": "Crea corso", "stats": "Statistiche", "createGroupChat": "Crea chat di gruppo", @@ -6209,14 +6206,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -8835,10 +8824,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10828,5 +10813,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Hai lasciato la chat", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Download avviato", + "webDownloadPermissionMessage": "Se il tuo browser blocca i download, abilita i download per questo sito.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "I progressi della tua sessione di pratica non verranno salvati.", + "practiceGrammar": "Pratica la grammatica", + "notEnoughToPractice": "Invia più messaggi per sbloccare la pratica", + "constructUseCorGCDesc": "Pratica della categoria grammaticale corretta", + "constructUseIncGCDesc": "Pratica della categoria grammaticale scorretta", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Pratica degli errori grammaticali corretti", + "constructUseIncGEDesc": "Pratica degli errori grammaticali scorretti", + "fillInBlank": "Compila lo spazio vuoto con la scelta corretta", + "learn": "Impara", + "languageUpdated": "Lingua target aggiornata!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Voce del bot Pangea", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "La tua richiesta è stata inviata all'amministratore del corso! Sarai ammesso se approvano.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Hai un codice di invito o un link per un corso pubblico?", + "welcomeUser": "Benvenuto {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Cerca utenti per invitarli a questa chat.", + "publicInviteDescSpace": "Cerca utenti per invitarli a questo spazio.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat è un'app di messaggistica, quindi le notifiche sono importanti!", + "enableNotificationsDesc": "Consenti notifiche", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Usa l'immagine dell'attività come sfondo della chat", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Chatta con il supporto", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Per impostazione predefinita, i corsi sono ricercabili pubblicamente e richiedono l'approvazione dell'amministratore per unirsi. Puoi modificare queste impostazioni in qualsiasi momento.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Quale lingua stai imparando?", + "searchLanguagesHint": "Cerca lingue target", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Domande? Siamo qui per aiutarti!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Qualcosa è andato storto e stiamo lavorando duramente per risolverlo. Controlla di nuovo più tardi.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Abilita assistenza alla scrittura", + "autoIGCToolDescription": "Esegui automaticamente gli strumenti di Pangea Chat per correggere i messaggi inviati nella lingua target.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Registrazione fallita. Controlla le tue autorizzazioni audio e riprova.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Verbo Frazionale", + "grammarCopyPOScompn": "Composto", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ja.arb b/lib/l10n/intl_ja.arb index b2c854382..360e48bfa 100644 --- a/lib/l10n/intl_ja.arb +++ b/lib/l10n/intl_ja.arb @@ -1,6 +1,6 @@ { "@@locale": "ja", - "@@last_modified": "2026-01-07 14:28:40.112996", + "@@last_modified": "2026-02-05 10:10:15.587333", "about": "このアプリについて", "@about": { "type": "String", @@ -3128,8 +3128,6 @@ "noPaymentInfo": "支払い情報は不要です!", "updatePhoneOS": "デバイスのOSバージョンを更新する必要があるかもしれません。", "wordsPerMinute": "1分あたりの単語数", - "autoIGCToolName": "Pangeaのライティング支援を自動的に実行", - "autoIGCToolDescription": "メッセージ送信前にPangeaチャットの文法と翻訳のライティング支援を自動的に実行します。", "tooltipInstructionsTitle": "それは何のためか分からない?", "tooltipInstructionsMobileBody": "アイテムを長押ししてツールチップを表示します。", "tooltipInstructionsBrowserBody": "アイテムにカーソルを合わせてツールチップを表示します。", @@ -3757,7 +3755,6 @@ "numModules": "{num} モジュール", "coursePlan": "コースプラン", "editCourseLater": "テンプレートのタイトル、説明、コース画像は後で編集できます。", - "newCourseAccess": "デフォルトでは、コースはプライベートで、参加には管理者の承認が必要です。これらの設定はいつでも編集できます。", "createCourse": "コースを作成", "stats": "統計", "createGroupChat": "グループチャットを作成", @@ -6996,14 +6993,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9622,10 +9611,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11615,5 +11600,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 チャットを退出しました", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "ダウンロードが開始されました", + "webDownloadPermissionMessage": "ブラウザがダウンロードをブロックしている場合は、このサイトのダウンロードを有効にしてください。", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "あなたの練習セッションの進捗は保存されません。", + "practiceGrammar": "文法を練習する", + "notEnoughToPractice": "練習を解除するにはもっとメッセージを送信してください", + "constructUseCorGCDesc": "正しい文法カテゴリの練習", + "constructUseIncGCDesc": "間違った文法カテゴリの練習", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "文法エラーの正しい練習", + "constructUseIncGEDesc": "文法エラーの不正確な練習", + "fillInBlank": "正しい選択肢で空欄を埋めてください", + "learn": "学ぶ", + "languageUpdated": "ターゲット言語が更新されました!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "パンゲアボットの声", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "あなたのリクエストはコース管理者に送信されました! 彼らが承認すれば、入ることができます。", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "招待コードまたは公開コースへのリンクはありますか?", + "welcomeUser": "ようこそ {user} さん", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "このチャットに招待するユーザーを検索します。", + "publicInviteDescSpace": "このスペースに招待するユーザーを検索します。", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chatはメッセージアプリなので、通知は重要です!", + "enableNotificationsDesc": "通知を許可する", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "アクティビティ画像をチャットの背景として使用", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "サポートとチャット", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "デフォルトでは、コースは公開検索可能で、参加するには管理者の承認が必要です。これらの設定はいつでも編集できます。", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "どの言語を学んでいますか?", + "searchLanguagesHint": "ターゲット言語を検索", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "質問がありますか?私たちはお手伝いします!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "何かがうまくいかなかったため、私たちは修正作業に取り組んでいます。後で再度確認してください。", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "ライティングアシスタントを有効にする", + "autoIGCToolDescription": "送信されたメッセージをターゲット言語に修正するために、Pangea Chatツールを自動的に実行します。", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "録音に失敗しました。オーディオの権限を確認して、再試行してください。", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "イディオム", + "grammarCopyPOSphrasalv": "句動詞", + "grammarCopyPOScompn": "複合語", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ka.arb b/lib/l10n/intl_ka.arb index 9b6f413e3..78003f70e 100644 --- a/lib/l10n/intl_ka.arb +++ b/lib/l10n/intl_ka.arb @@ -1870,8 +1870,6 @@ "noPaymentInfo": "გადახდის ინფორმაცია საჭირო არაა!", "updatePhoneOS": "შესაძლოა დაგჭირდეთ თქვენი მოწყობილობის ოპერაციული სისტემის განახლება.", "wordsPerMinute": "სიტყვები წუთში", - "autoIGCToolName": "ავტომატურად ჩართეთ პანგეა წერის დახმარება", - "autoIGCToolDescription": "ავტომატურად ჩართეთ პანგეა ჩატის გრამატიკა და თარგმნის დახმარება ჩემი შეტყობინების გაგზავნამდე.", "tooltipInstructionsTitle": "არ იცით რა აკეთებს ეს?", "tooltipInstructionsMobileBody": "დაჭერით და შეინახეთ ინსტრუმენტების სანახავად.", "tooltipInstructionsBrowserBody": "მოძრაობით მერყეობთ ნივთებზე ინსტრუმენტების სანახავად.", @@ -2499,7 +2497,6 @@ "numModules": "{num} მოდული", "coursePlan": "კურსის გეგმა", "editCourseLater": "შეგიძლიათ მოგვიანებით შეცვალოთ ტემპლეტის სათაური, აღწერები და კურსის სურათი.", - "newCourseAccess": "ჩაშენებული წესით, კურსები პირადია და საჭიროებს ადმინისტრატორის დამტკიცებას შესასვლელად. შეგიძლიათ ნებისმიერ დროს შეცვალოთ ეს პარამეტრები.", "createCourse": "შექმენით კურსი", "stats": "სტატისტიკა", "createGroupChat": "შექმენით ჯგუფური ჩათი", @@ -2594,7 +2591,7 @@ "playWithAI": "ამ დროისთვის ითამაშეთ AI-თან", "courseStartDesc": "Pangea Bot მზადაა ნებისმიერ დროს გასასვლელად!\n\n...მაგრამ სწავლა უკეთესია მეგობრებთან ერთად!", "@@locale": "ka", - "@@last_modified": "2026-01-07 14:29:07.353656", + "@@last_modified": "2026-02-05 10:10:20.523925", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -7236,14 +7233,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9862,10 +9851,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11855,5 +11840,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 თქვენ დატოვეთ ჩატი", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "ჩამოტვირთვა დაწყებულია", + "webDownloadPermissionMessage": "თუ თქვენი ბრაუზერი ბლოკავს ჩამოტვირთვებს, გთხოვთ გააქტიუროთ ჩამოტვირთვები ამ ვებსაიტისთვის.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "თქვენი პრაქტიკის სესიის პროგრესი არ დაიშლება.", + "practiceGrammar": "პრაქტიკა გრამატიკა", + "notEnoughToPractice": "პრაქტიკის გასახსნელად მეტი შეტყობინება გამოაგზავნეთ", + "constructUseCorGCDesc": "სწორი გრამატიკული კატეგორიის პრაქტიკა", + "constructUseIncGCDesc": "არასწორი გრამატიკული კატეგორიის პრაქტიკა", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "მართებული გრამატიკული შეცდომების პრაქტიკა", + "constructUseIncGEDesc": "არასწორი გრამატიკული შეცდომების პრაქტიკა", + "fillInBlank": "შეავსეთ ცარიელი ადგილი სწორი არჩევანით", + "learn": "სწავლა", + "languageUpdated": "მიზნობრივი ენა განახლებულია!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "პანჯეა ბოტის ხმა", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "თქვენი მოთხოვნა გაგზავნილია კურსის ადმინისტრატორთან! თქვენ შეგიშვებენ, თუ ისინი დაამტკიცებენ.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "გაქვთ თუ არა მოწვევის კოდი ან ბმული საჯარო კურსზე?", + "welcomeUser": "კეთილი იყოს თქვენი მობრძანება {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "მომხმარებლების ძიება, რათა მათ ამ ჩატში მოიწვიოთ.", + "publicInviteDescSpace": "მომხმარებლების ძიება, რათა მათ ამ სივრცეში მოიწვიოთ.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat არის ტექსტური აპლიკაცია, ამიტომ შეტყობინებები მნიშვნელოვანია!", + "enableNotificationsDesc": "შეტყობინებების დაშვება", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "გამოიყენეთ აქტივობის სურათი ჩეთის ფონად", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "ჩატი მხარდაჭერასთან", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "ნაგულისხმევად, კურსები საჯაროდ საძიებელია და საჭიროებს ადმინისტრატორის დამტკიცებას გაწვდვისთვის. შეგიძლიათ ამ პარამეტრების რედაქტირება ნებისმიერ დროს.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "რომელი ენა სწავლობთ?", + "searchLanguagesHint": "ძებნა მიზნობრივი ენების", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "კითხვები? ჩვენ აქ ვართ, რომ დაგეხმაროთ!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "რამე არასწორად მოხდა, და ჩვენ აქტიურად ვმუშაობთ ამის გამოსასწორებლად. შეამოწმეთ მოგვიანებით.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "წერის დახმარების ჩართვა", + "autoIGCToolDescription": "ავტომატურად გაწვდეთ Pangea Chat ინსტრუმენტები გაგზავნილი შეტყობინებების მიზნობრივი ენაზე გასასწორებლად.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "ჩაწერა ვერ მოხერხდა. გთხოვთ, შეამოწმოთ თქვენი აუდიო უფლებები და სცადოთ კიდევ ერთხელ.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "იდიომი", + "grammarCopyPOSphrasalv": "ფრაზული ზმნა", + "grammarCopyPOScompn": "კომპლექსური", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ko.arb b/lib/l10n/intl_ko.arb index dc4bac579..63b504369 100644 --- a/lib/l10n/intl_ko.arb +++ b/lib/l10n/intl_ko.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:23:16.118299", + "@@last_modified": "2026-02-05 10:09:10.325473", "about": "소개", "@about": { "type": "String", @@ -3721,8 +3721,6 @@ "noPaymentInfo": "결제 정보가 필요 없습니다!", "updatePhoneOS": "기기의 OS 버전을 업데이트해야 할 수 있습니다.", "wordsPerMinute": "분당 단어 수", - "autoIGCToolName": "판게아 작문 지원 자동 실행", - "autoIGCToolDescription": "메시지를 보내기 전에 판게아 채팅 문법 및 번역 작문 지원을 자동으로 실행합니다.", "tooltipInstructionsTitle": "이게 무슨 기능인지 잘 모르겠나요?", "tooltipInstructionsMobileBody": "항목을 길게 눌러 툴팁을 볼 수 있습니다.", "tooltipInstructionsBrowserBody": "항목 위에 마우스를 올려 툴팁을 볼 수 있습니다.", @@ -4350,7 +4348,6 @@ "numModules": "{num}개 모듈", "coursePlan": "과정 계획", "editCourseLater": "템플릿 제목, 설명, 과정 이미지는 나중에 편집할 수 있습니다.", - "newCourseAccess": "기본적으로 과정은 비공개이며 관리자 승인 후 참여할 수 있습니다. 언제든지 이 설정을 변경할 수 있습니다.", "createCourse": "과정 생성", "stats": "통계", "createGroupChat": "단체 채팅 만들기", @@ -6314,14 +6311,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -8940,10 +8929,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10933,5 +10918,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 채팅을 나갔습니다", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "다운로드가 시작되었습니다", + "webDownloadPermissionMessage": "브라우저가 다운로드를 차단하는 경우, 이 사이트에 대한 다운로드를 활성화해 주세요.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "연습 세션 진행 상황이 저장되지 않습니다.", + "practiceGrammar": "문법 연습", + "notEnoughToPractice": "연습을 잠금 해제하려면 더 많은 메시지를 보내세요.", + "constructUseCorGCDesc": "올바른 문법 카테고리 연습", + "constructUseIncGCDesc": "잘못된 문법 카테고리 연습", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "문법 오류 수정 연습", + "constructUseIncGEDesc": "문법 오류 비정상 연습", + "fillInBlank": "올바른 선택으로 빈칸을 채우세요", + "learn": "배우다", + "languageUpdated": "목표 언어가 업데이트되었습니다!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "판게아 봇 음성", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "귀하의 요청이 과정 관리자에게 전송되었습니다! 그들이 승인하면 들어갈 수 있습니다.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "공개 과정에 대한 초대 코드나 링크가 있습니까?", + "welcomeUser": "환영합니다 {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "이 채팅에 초대할 사용자를 검색하세요.", + "publicInviteDescSpace": "이 공간에 초대할 사용자를 검색하세요.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat은 문자 메시지 앱이므로 알림이 중요합니다!", + "enableNotificationsDesc": "알림 허용", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "활동 이미지를 채팅 배경으로 사용", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "지원팀과 채팅하기", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "기본적으로 과정은 공개적으로 검색 가능하며 참여하려면 관리자 승인이 필요합니다. 언제든지 이러한 설정을 수정할 수 있습니다.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "어떤 언어를 배우고 있나요?", + "searchLanguagesHint": "목표 언어 검색", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "질문이 있으신가요? 저희가 도와드리겠습니다!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "문제가 발생했으며, 우리는 이를 해결하기 위해 열심히 작업하고 있습니다. 나중에 다시 확인해 주세요.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "쓰기 지원 활성화", + "autoIGCToolDescription": "전송된 메시지를 목표 언어로 수정하기 위해 Pangea Chat 도구를 자동으로 실행합니다.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "녹음에 실패했습니다. 오디오 권한을 확인하고 다시 시도해 주세요.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "관용구", + "grammarCopyPOSphrasalv": "구동사", + "grammarCopyPOScompn": "복합어", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_lt.arb b/lib/l10n/intl_lt.arb index fd8eadc44..8a69923f6 100644 --- a/lib/l10n/intl_lt.arb +++ b/lib/l10n/intl_lt.arb @@ -3137,8 +3137,6 @@ "noPaymentInfo": "Apmokėjimo informacija nereikalinga!", "updatePhoneOS": "Gali būti, kad reikės atnaujinti įrenginio operacinės sistemos versiją.", "wordsPerMinute": "Žodžių per minutę", - "autoIGCToolName": "Automatiškai paleisti Pangea rašymo pagalbą", - "autoIGCToolDescription": "Automatiškai paleisti Pangea pokalbio gramatikos ir vertimo rašymo pagalbą prieš išsiunčiant žinutę.", "tooltipInstructionsTitle": "Nesate tikri, ką tai daro?", "tooltipInstructionsMobileBody": "Paspauskite ir palaikykite elementus, kad pamatytumėte įrankių patarimus.", "tooltipInstructionsBrowserBody": "Užveskite pelės žymeklį ant elementų, kad pamatytumėte įrankių patarimus.", @@ -3766,7 +3764,6 @@ "numModules": "{num} moduliai", "coursePlan": "Kurso planas", "editCourseLater": "Vėliau galite redaguoti šablono pavadinimą, aprašymus ir kurso vaizdą.", - "newCourseAccess": "Pagal numatytuosius nustatymus kursai yra privatūs ir reikalauja administratoriaus patvirtinimo prisijungiant. Šiuos nustatymus galite redaguoti bet kuriuo metu.", "createCourse": "Sukurti kursą", "stats": "Statistika", "createGroupChat": "Sukurti grupinį pokalbį", @@ -3861,7 +3858,7 @@ "playWithAI": "Žaiskite su dirbtiniu intelektu dabar", "courseStartDesc": "Pangea botas pasiruošęs bet kada pradėti!\n\n...bet mokymasis yra geresnis su draugais!", "@@locale": "lt", - "@@last_modified": "2026-01-07 14:27:30.418081", + "@@last_modified": "2026-02-05 10:10:01.069181", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -7011,14 +7008,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9637,10 +9626,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11630,5 +11615,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Jūs palikote pokalbį", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Atsisiuntimas pradėtas", + "webDownloadPermissionMessage": "Jei jūsų naršyklė blokuoja atsisiuntimus, prašome įgalinti atsisiuntimus šiam tinklalapiui.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Jūsų praktikos sesijos pažanga nebus išsaugota.", + "practiceGrammar": "Praktikuoti gramatiką", + "notEnoughToPractice": "Siųskite daugiau žinučių, kad atrakintumėte praktiką", + "constructUseCorGCDesc": "Teisingos gramatikos kategorijos praktika", + "constructUseIncGCDesc": "Neteisingos gramatikos kategorijos praktika", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Teisingos gramatikos klaidų praktika", + "constructUseIncGEDesc": "Neteisingos gramatikos klaidų praktika", + "fillInBlank": "Užpildykite tuščią vietą teisingu pasirinkimu", + "learn": "Mokytis", + "languageUpdated": "Tikslo kalba atnaujinta!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Bot balsas", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Jūsų prašymas buvo išsiųstas kurso administratoriui! Būsite įleistas, jei jie patvirtins.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Ar turite kvietimo kodą arba nuorodą į viešą kursą?", + "welcomeUser": "Sveiki atvykę, {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Ieškokite vartotojų, kad juos pakviestumėte į šį pokalbį.", + "publicInviteDescSpace": "Ieškokite vartotojų, kad juos pakviestumėte į šią erdvę.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat yra žinučių programa, todėl pranešimai yra svarbūs!", + "enableNotificationsDesc": "Leisti pranešimus", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Naudoti veiklos vaizdą kaip pokalbio foną", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Pokalbis su palaikymu", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Pagal numatytuosius nustatymus, kursai yra viešai ieškomi ir reikalauja administratoriaus patvirtinimo prisijungti. Šiuos nustatymus galite redaguoti bet kada.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Kokią kalbą mokotės?", + "searchLanguagesHint": "Ieškoti tikslo kalbų", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Klausimai? Mes čia, kad padėtume!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Kažkas nepavyko, ir mes sunkiai dirbame, kad tai išspręstume. Patikrinkite vėliau.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Įgalinti rašymo pagalbą", + "autoIGCToolDescription": "Automatiškai paleisti Pangea Chat įrankius, kad ištaisytumėte išsiųstas žinutes į tikslinę kalbą.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Įrašymas nepavyko. Patikrinkite savo garso teises ir bandykite dar kartą.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Phrasal Verb", + "grammarCopyPOScompn": "Sudėtinis", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_lv.arb b/lib/l10n/intl_lv.arb index a52336e79..55e1affc6 100644 --- a/lib/l10n/intl_lv.arb +++ b/lib/l10n/intl_lv.arb @@ -3773,8 +3773,6 @@ "noPaymentInfo": "Nav nepieciešama maksājuma informācija!", "updatePhoneOS": "Var būt nepieciešams atjaunināt ierīces operētājsistēmas versiju.", "wordsPerMinute": "Vārdi minūtē", - "autoIGCToolName": "Automātiski palaist Pangea rakstīšanas palīgu", - "autoIGCToolDescription": "Automātiski palaist Pangea Čata gramatikas un tulkošanas rakstīšanas palīgu pirms mana ziņojuma nosūtīšanas.", "tooltipInstructionsTitle": "Neesat pārliecināts, kas tas dara?", "tooltipInstructionsMobileBody": "Turiet un turiet vienumus, lai skatītu rīku padomus.", "tooltipInstructionsBrowserBody": "Novietojiet peles kursoru virs vienumiem, lai skatītu rīku padomus.", @@ -4482,7 +4480,7 @@ "playWithAI": "Tagad spēlējiet ar AI", "courseStartDesc": "Pangea bots ir gatavs jebkurā laikā!\n\n...bet mācīties ir labāk ar draugiem!", "@@locale": "lv", - "@@last_modified": "2026-01-07 14:27:04.364429", + "@@last_modified": "2026-02-05 10:09:54.766036", "analyticsInactiveTitle": "Pieprasījumi neaktīviem lietotājiem nevar tikt nosūtīti", "analyticsInactiveDesc": "Neaktīvi lietotāji, kuri nav pieteikušies kopš šīs funkcijas ieviešanas, neredzēs jūsu pieprasījumu.\n\nPieprasījuma poga parādīsies, kad viņi atgriezīsies. Jūs varat atkārtoti nosūtīt pieprasījumu vēlāk, noklikšķinot uz pieprasījuma pogas viņu vārdā, kad tā būs pieejama.", "accessRequestedTitle": "Pieprasījums piekļūt analītikai", @@ -4495,7 +4493,6 @@ "numModules": "{num} moduļi", "coursePlan": "Kursa plāns", "editCourseLater": "Jūs varat vēlāk rediģēt šablona nosaukumu, aprakstus un kursa attēlu.", - "newCourseAccess": "Pēc noklusējuma kursi ir privāti un prasa administratora apstiprinājumu, lai pievienotos. Jūs varat šīs iestatījumus mainīt jebkurā laikā.", "createCourse": "Izveidot kursu", "stats": "Statistika", "@customReaction": { @@ -6192,14 +6189,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -8818,10 +8807,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10811,5 +10796,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Jūs pametāt čatu", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Lejupielāde uzsākta", + "webDownloadPermissionMessage": "Ja jūsu pārlūkprogramma bloķē lejupielādes, lūdzu, iespējot lejupielādes šai vietnei.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Jūsu prakses sesijas progress netiks saglabāts.", + "practiceGrammar": "Praktizēt gramatiku", + "notEnoughToPractice": "Sūtiet vairāk ziņojumu, lai atbloķētu praksi", + "constructUseCorGCDesc": "Pareizas gramatikas kategorijas prakse", + "constructUseIncGCDesc": "Nepareizas gramatikas kategorijas prakse", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Pareiza gramatikas kļūdu prakse", + "constructUseIncGEDesc": "Nepareiza gramatikas kļūdu prakse", + "fillInBlank": "Aizpildiet tukšo vietu ar pareizo izvēli", + "learn": "Mācīties", + "languageUpdated": "Mērķa valoda atjaunota!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Bot balss", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Jūsu pieprasījums ir nosūtīts kursa administratoram! Jūs tiksiet iekšā, ja viņi apstiprinās.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Vai jums ir uzaicinājuma kods vai saite uz publisku kursu?", + "welcomeUser": "Laipni lūdzam, {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Meklējiet lietotājus, lai viņus aicinātu uz šo čatu.", + "publicInviteDescSpace": "Meklējiet lietotājus, lai viņus aicinātu uz šo telpu.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat ir ziņojumapmaiņas lietotne, tāpēc paziņojumi ir svarīgi!", + "enableNotificationsDesc": "Atļaut paziņojumus", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Izmantojiet aktivitātes attēlu kā čata fona", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Sarunāties ar atbalstu", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Pēc noklusējuma kursi ir publiski meklējami un prasa administratora apstiprinājumu pievienošanai. Jūs varat rediģēt šos iestatījumus jebkurā laikā.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Kuru valodu tu mācies?", + "searchLanguagesHint": "Meklēt mērķa valodas", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Jautājumi? Mēs esam šeit, lai palīdzētu!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Kaut kas nogāja greizi, un mēs smagi strādājam, lai to labotu. Pārbaudiet vēlāk.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Iespējot rakstīšanas palīdzību", + "autoIGCToolDescription": "Automātiski palaist Pangea Chat rīkus, lai labotu nosūtītās ziņas mērķa valodā.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Ieraksts neizdevās. Lūdzu, pārbaudiet savas audio atļaujas un mēģiniet vēlreiz.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Frazēts darbības vārds", + "grammarCopyPOScompn": "Savienojums", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_nb.arb b/lib/l10n/intl_nb.arb index 8c947d498..9b520323a 100644 --- a/lib/l10n/intl_nb.arb +++ b/lib/l10n/intl_nb.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:26:11.727122", + "@@last_modified": "2026-02-05 10:09:45.325631", "about": "Om", "@about": { "type": "String", @@ -2700,8 +2700,6 @@ "noPaymentInfo": "Ingen betalingsinformasjon nødvendig!", "updatePhoneOS": "Du kan trenge å oppdatere enhetens OS-versjon.", "wordsPerMinute": "Ord per minutt", - "autoIGCToolName": "Kjør Pangea skrivehjelp automatisk", - "autoIGCToolDescription": "Kjør automatisk Pangea Chat grammatikk- og oversettelsesstøtte før jeg sender meldingen min.", "tooltipInstructionsTitle": "Usikker på hva det gjør?", "tooltipInstructionsMobileBody": "Trykk og hold på elementer for å se verktøytips.", "tooltipInstructionsBrowserBody": "Hold musepekeren over elementer for å se verktøytips.", @@ -3329,7 +3327,6 @@ "numModules": "{num} moduler", "coursePlan": "Kursplan", "editCourseLater": "Du kan redigere malens tittel, beskrivelser og kursbilde senere.", - "newCourseAccess": "Som standard er kurs private og krever administratorgodkjenning for å bli med. Du kan endre disse innstillingene når som helst.", "createCourse": "Opprett kurs", "stats": "Statistikk", "createGroupChat": "Opprett gruppechat", @@ -7299,14 +7296,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9925,10 +9914,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11918,5 +11903,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Du forlot chatten", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Nedlasting initiert", + "webDownloadPermissionMessage": "Hvis nettleseren din blokkerer nedlastinger, vennligst aktiver nedlastinger for dette nettstedet.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Fremdriften i økten din vil ikke bli lagret.", + "practiceGrammar": "Øv på grammatikk", + "notEnoughToPractice": "Send flere meldinger for å låse opp øving", + "constructUseCorGCDesc": "Øvelse i korrekt grammatikkategori", + "constructUseIncGCDesc": "Øvelse i ukorrekt grammatikkategori", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Korrekt grammatikkfeil praksis", + "constructUseIncGEDesc": "Feil grammatikkfeil praksis", + "fillInBlank": "Fyll inn blanketten med riktig valg", + "learn": "Lær", + "languageUpdated": "Mål språk oppdatert!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Bot-stemme", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Din forespørsel har blitt sendt til kursadministratoren! Du vil bli sluppet inn hvis de godkjenner.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Har du en invitasjonskode eller lenke til et offentlig kurs?", + "welcomeUser": "Velkommen {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Søk etter brukere for å invitere dem til denne chatten.", + "publicInviteDescSpace": "Søk etter brukere for å invitere dem til dette rommet.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat er en tekstmelding-app, så varsler er viktige!", + "enableNotificationsDesc": "Tillat varsler", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Bruk aktivitetsbilde som chatbakgrunn", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Chat med støtte", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Som standard er kurs offentlig søkbare og krever administratortillatelse for å bli med. Du kan redigere disse innstillingene når som helst.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Hvilket språk lærer du?", + "searchLanguagesHint": "Søk etter målspråk", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Spørsmål? Vi er her for å hjelpe!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Noe gikk galt, og vi jobber hardt med å fikse det. Sjekk igjen senere.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Aktiver skriveassistent", + "autoIGCToolDescription": "Kjør Pangea Chat-verktøy automatisk for å korrigere sendte meldinger til målspråket.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Opptak mislyktes. Vennligst sjekk lydinnstillingene dine og prøv igjen.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Phrasal Verb", + "grammarCopyPOScompn": "Sammensatt", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_nl.arb b/lib/l10n/intl_nl.arb index e55bad11f..6dea520bb 100644 --- a/lib/l10n/intl_nl.arb +++ b/lib/l10n/intl_nl.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:27:48.189369", + "@@last_modified": "2026-02-05 10:10:04.986596", "about": "Over ons", "@about": { "type": "String", @@ -3784,8 +3784,6 @@ "noPaymentInfo": "Geen betalingsinformatie nodig!", "updatePhoneOS": "U moet mogelijk de OS-versie van uw apparaat bijwerken.", "wordsPerMinute": "Woorden per minuut", - "autoIGCToolName": "Voer Pangea schrijfhulp automatisch uit", - "autoIGCToolDescription": "Voer automatisch Pangea Chat grammatica- en vertaalhulp uit voordat ik mijn bericht verstuur.", "tooltipInstructionsTitle": "Weet je niet wat dat doet?", "tooltipInstructionsMobileBody": "Houd items ingedrukt om tooltips te bekijken.", "tooltipInstructionsBrowserBody": "Houd de muisaanwijzer over items om tooltips te bekijken.", @@ -4413,7 +4411,6 @@ "numModules": "{num} modules", "coursePlan": "Cursusplan", "editCourseLater": "U kunt de titel, beschrijvingen en cursusafbeelding later bewerken.", - "newCourseAccess": "Standaard zijn cursussen privé en vereist goedkeuring van een beheerder om deel te nemen. U kunt deze instellingen op elk moment aanpassen.", "createCourse": "Cursus maken", "stats": "Statistieken", "createGroupChat": "Groepschat maken", @@ -6206,14 +6203,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -8832,10 +8821,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10825,5 +10810,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Je hebt de chat verlaten", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Download gestart", + "webDownloadPermissionMessage": "Als uw browser downloads blokkeert, schakel dan downloads voor deze site in.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Uw voortgang in de oefensessie wordt niet opgeslagen.", + "practiceGrammar": "Oefen grammatica", + "notEnoughToPractice": "Stuur meer berichten om de oefening te ontgrendelen", + "constructUseCorGCDesc": "Oefening in de juiste grammaticacategorie", + "constructUseIncGCDesc": "Oefening in de onjuiste grammaticacategorie", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Oefening voor correcte grammatica", + "constructUseIncGEDesc": "Oefening voor onjuiste grammatica", + "fillInBlank": "Vul de lege ruimte in met de juiste keuze", + "learn": "Leren", + "languageUpdated": "Doeltaal bijgewerkt!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Bot stem", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Je verzoek is verzonden naar de cursusbeheerder! Je wordt toegelaten als ze goedkeuren.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Heb je een uitnodigingscode of link naar een openbare cursus?", + "welcomeUser": "Welkom {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Zoek naar gebruikers om ze uit te nodigen voor deze chat.", + "publicInviteDescSpace": "Zoek naar gebruikers om ze uit te nodigen voor deze ruimte.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat is een berichten-app, dus meldingen zijn belangrijk!", + "enableNotificationsDesc": "Sta meldingen toe", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Gebruik activiteit afbeelding als chatachtergrond", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Chat met Ondersteuning", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Standaard zijn cursussen openbaar doorzoekbaar en is goedkeuring van de beheerder vereist om deel te nemen. Je kunt deze instellingen op elk moment bewerken.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Welke taal ben je aan het leren?", + "searchLanguagesHint": "Zoek doeltalen", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Vragen? We zijn hier om te helpen!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Er is iets misgegaan en we zijn hard aan het werk om het op te lossen. Kijk later nog eens.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Schrijfassistentie inschakelen", + "autoIGCToolDescription": "Voer automatisch Pangea Chat-tools uit om verzonden berichten naar de doeltaal te corrigeren.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Opname mislukt. Controleer uw audiorechten en probeer het opnieuw.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idioom", + "grammarCopyPOSphrasalv": "Frazal Werkwoord", + "grammarCopyPOScompn": "Samenstelling", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index 75961bf4d..aaa6f96a0 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -1,6 +1,6 @@ { "@@locale": "pl", - "@@last_modified": "2026-01-07 14:28:18.089845", + "@@last_modified": "2026-02-05 10:10:11.791789", "about": "O aplikacji", "@about": { "type": "String", @@ -3785,8 +3785,6 @@ "noPaymentInfo": "Brak konieczności podawania informacji o płatności!", "updatePhoneOS": "Możesz potrzebować zaktualizować wersję systemu operacyjnego swojego urządzenia.", "wordsPerMinute": "Słów na minutę", - "autoIGCToolName": "Uruchom automatycznie pomoc w pisaniu Pangea", - "autoIGCToolDescription": "Automatycznie uruchom pomoc w gramatyce i tłumaczeniu Pangea Chat przed wysłaniem mojej wiadomości.", "tooltipInstructionsTitle": "Nie jesteś pewien, co to robi?", "tooltipInstructionsMobileBody": "Przytrzymaj elementy, aby wyświetlić podpowiedzi.", "tooltipInstructionsBrowserBody": "Najedź kursorem na elementy, aby wyświetlić podpowiedzi.", @@ -4414,7 +4412,6 @@ "numModules": "{num} modułów", "coursePlan": "Plan kursu", "editCourseLater": "Możesz edytować tytuł szablonu, opisy i obraz kursu później.", - "newCourseAccess": "Domyślnie kursy są prywatne i wymagają zatwierdzenia administratora, aby do nich dołączyć. Możesz edytować te ustawienia w dowolnym momencie.", "createCourse": "Utwórz kurs", "stats": "Statystyki", "createGroupChat": "Utwórz czat grupowy", @@ -6206,14 +6203,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -8832,10 +8821,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10823,5 +10808,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Opuszczono czat", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Pobieranie zainicjowane", + "webDownloadPermissionMessage": "Jeśli Twoja przeglądarka blokuje pobieranie, włącz pobieranie dla tej strony.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Postęp Twojej sesji ćwiczeń nie zostanie zapisany.", + "practiceGrammar": "Ćwicz gramatykę", + "notEnoughToPractice": "Wyślij więcej wiadomości, aby odblokować ćwiczenia", + "constructUseCorGCDesc": "Ćwiczenie poprawnej kategorii gramatycznej", + "constructUseIncGCDesc": "Ćwiczenie niepoprawnej kategorii gramatycznej", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Ćwiczenie poprawnych błędów gramatycznych", + "constructUseIncGEDesc": "Ćwiczenie niepoprawnych błędów gramatycznych", + "fillInBlank": "Uzupełnij lukę poprawnym wyborem", + "learn": "Ucz się", + "languageUpdated": "Język docelowy zaktualizowany!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Głos bota Pangea", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Twoja prośba została wysłana do administratora kursu! Zostaniesz wpuszczony, jeśli ją zatwierdzą.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Czy masz kod zaproszenia lub link do publicznego kursu?", + "welcomeUser": "Witaj {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Szukaj użytkowników, aby zaprosić ich do tej rozmowy.", + "publicInviteDescSpace": "Szukaj użytkowników, aby zaprosić ich do tej przestrzeni.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat to aplikacja do wiadomości, więc powiadomienia są ważne!", + "enableNotificationsDesc": "Zezwól na powiadomienia", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Użyj obrazu aktywności jako tła czatu", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Czat z pomocą", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Domyślnie kursy są publicznie wyszukiwalne i wymagają zatwierdzenia przez administratora, aby do nich dołączyć. Możesz edytować te ustawienia w dowolnym momencie.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Jakiego języka się uczysz?", + "searchLanguagesHint": "Szukaj języków docelowych", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Pytania? Jesteśmy tutaj, aby pomóc!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Coś poszło nie tak, a my ciężko pracujemy nad naprawą. Sprawdź ponownie później.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Włącz pomoc w pisaniu", + "autoIGCToolDescription": "Automatycznie uruchom narzędzia Pangea Chat, aby poprawić wysłane wiadomości na docelowy język.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Nagrywanie nie powiodło się. Sprawdź swoje uprawnienia audio i spróbuj ponownie.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Czasownik frazowy", + "grammarCopyPOScompn": "Złożony", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 34e340d40..9b8ecb9b7 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:25:09.370204", + "@@last_modified": "2026-02-05 10:09:34.897101", "copiedToClipboard": "Copiada para a área de transferência", "@copiedToClipboard": { "type": "String", @@ -3775,8 +3775,6 @@ "noPaymentInfo": "Nenhuma informação de pagamento necessária!", "updatePhoneOS": "Pode ser necessário atualizar a versão do sistema operacional do seu dispositivo.", "wordsPerMinute": "Palavras por minuto", - "autoIGCToolName": "Executar assistência de escrita Pangea automaticamente", - "autoIGCToolDescription": "Executar automaticamente a assistência de escrita de gramática e tradução do Pangea Chat antes de enviar minha mensagem.", "tooltipInstructionsTitle": "Não tem certeza do que isso faz?", "tooltipInstructionsMobileBody": "Pressione e segure itens para ver dicas de ferramenta.", "tooltipInstructionsBrowserBody": "Passe o mouse sobre os itens para ver dicas de ferramenta.", @@ -4404,7 +4402,6 @@ "numModules": "{num} módulos", "coursePlan": "Plano do Curso", "editCourseLater": "Você pode editar o título do modelo, descrições e imagem do curso posteriormente.", - "newCourseAccess": "Por padrão, os cursos são privados e requerem aprovação do administrador para participar. Você pode editar essas configurações a qualquer momento.", "createCourse": "Criar curso", "stats": "Estatísticas", "createGroupChat": "Criar chat em grupo", @@ -7306,14 +7303,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9932,10 +9921,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11925,5 +11910,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Você saiu do chat", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Download iniciado", + "webDownloadPermissionMessage": "Se o seu navegador bloquear downloads, por favor, habilite downloads para este site.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "O progresso da sua sessão de prática não será salvo.", + "practiceGrammar": "Praticar gramática", + "notEnoughToPractice": "Envie mais mensagens para desbloquear a prática", + "constructUseCorGCDesc": "Prática da categoria de gramática correta", + "constructUseIncGCDesc": "Prática da categoria de gramática incorreta", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Prática de erro gramatical correto", + "constructUseIncGEDesc": "Prática de erro gramatical incorreto", + "fillInBlank": "Preencha a lacuna com a escolha correta", + "learn": "Aprender", + "languageUpdated": "Idioma de destino atualizado!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Voz do Bot Pangea", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Sua solicitação foi enviada ao administrador do curso! Você será admitido se eles aprovarem.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Você tem um código de convite ou link para um curso público?", + "welcomeUser": "Bem-vindo {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Procure usuários para convidá-los para este chat.", + "publicInviteDescSpace": "Procure usuários para convidá-los para este espaço.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat é um aplicativo de mensagens, então as notificações são importantes!", + "enableNotificationsDesc": "Permitir notificações", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Usar imagem da atividade como fundo do chat", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Converse com o Suporte", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Por padrão, os cursos são pesquisáveis publicamente e requerem aprovação do administrador para participar. Você pode editar essas configurações a qualquer momento.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Qual idioma você está aprendendo?", + "searchLanguagesHint": "Pesquise idiomas-alvo", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Dúvidas? Estamos aqui para ajudar!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Algo deu errado, e estamos trabalhando arduamente para corrigir isso. Verifique novamente mais tarde.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Ativar assistência de escrita", + "autoIGCToolDescription": "Executar automaticamente as ferramentas do Pangea Chat para corrigir mensagens enviadas para o idioma de destino.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "A gravação falhou. Verifique suas permissões de áudio e tente novamente.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Verbo Frasal", + "grammarCopyPOScompn": "Composto", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pt_BR.arb b/lib/l10n/intl_pt_BR.arb index a2fdf12cf..cbe1ca38b 100644 --- a/lib/l10n/intl_pt_BR.arb +++ b/lib/l10n/intl_pt_BR.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:24:55.031821", + "@@last_modified": "2026-02-05 10:09:31.755690", "about": "Sobre", "@about": { "type": "String", @@ -3532,8 +3532,6 @@ "noPaymentInfo": "Nenhuma informação de pagamento necessária!", "updatePhoneOS": "Você pode precisar atualizar a versão do sistema operacional do seu dispositivo.", "wordsPerMinute": "Palavras por minuto", - "autoIGCToolName": "Executar assistência de escrita Pangea automaticamente", - "autoIGCToolDescription": "Executar automaticamente a assistência de gramática e tradução do Pangea Chat antes de enviar minha mensagem.", "tooltipInstructionsTitle": "Não tem certeza do que isso faz?", "tooltipInstructionsMobileBody": "Pressione e segure os itens para ver dicas de ferramenta.", "tooltipInstructionsBrowserBody": "Passe o mouse sobre os itens para ver dicas de ferramenta.", @@ -4161,7 +4159,6 @@ "numModules": "{num} módulos", "coursePlan": "Plano do curso", "editCourseLater": "Você pode editar o título do modelo, descrições e a imagem do curso posteriormente.", - "newCourseAccess": "Por padrão, os cursos são privados e requerem aprovação do administrador para ingressar. Você pode editar essas configurações a qualquer momento.", "createCourse": "Criar curso", "stats": "Estatísticas", "createGroupChat": "Criar chat em grupo", @@ -6581,14 +6578,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9207,10 +9196,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11183,5 +11168,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Você saiu do chat", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Download iniciado", + "webDownloadPermissionMessage": "Se o seu navegador bloquear downloads, por favor, habilite downloads para este site.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "O progresso da sua sessão de prática não será salvo.", + "practiceGrammar": "Praticar gramática", + "notEnoughToPractice": "Envie mais mensagens para desbloquear a prática", + "constructUseCorGCDesc": "Prática da categoria de gramática correta", + "constructUseIncGCDesc": "Prática da categoria de gramática incorreta", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Prática de erro gramatical correto", + "constructUseIncGEDesc": "Prática de erro gramatical incorreto", + "fillInBlank": "Preencha a lacuna com a escolha correta", + "learn": "Aprender", + "languageUpdated": "Idioma de destino atualizado!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Voz do Bot Pangea", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Sua solicitação foi enviada ao administrador do curso! Você será admitido se eles aprovarem.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Você tem um código de convite ou link para um curso público?", + "welcomeUser": "Bem-vindo {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Procure usuários para convidá-los para este chat.", + "publicInviteDescSpace": "Procure usuários para convidá-los para este espaço.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat é um aplicativo de mensagens, então as notificações são importantes!", + "enableNotificationsDesc": "Permitir notificações", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Usar imagem da atividade como fundo do chat", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Converse com o Suporte", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Por padrão, os cursos são pesquisáveis publicamente e requerem aprovação do administrador para ingressar. Você pode editar essas configurações a qualquer momento.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Qual idioma você está aprendendo?", + "searchLanguagesHint": "Pesquise idiomas-alvo", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Dúvidas? Estamos aqui para ajudar!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Algo deu errado, e estamos trabalhando duro para consertar. Verifique novamente mais tarde.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Ativar assistência de escrita", + "autoIGCToolDescription": "Executar automaticamente as ferramentas do Pangea Chat para corrigir mensagens enviadas para o idioma de destino.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Gravação falhou. Por favor, verifique suas permissões de áudio e tente novamente.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Verbo Frasal", + "grammarCopyPOScompn": "Composto", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pt_PT.arb b/lib/l10n/intl_pt_PT.arb index 3af737159..c74ddde52 100644 --- a/lib/l10n/intl_pt_PT.arb +++ b/lib/l10n/intl_pt_PT.arb @@ -2595,8 +2595,6 @@ "noPaymentInfo": "Nenhuma informação de pagamento necessária!", "updatePhoneOS": "Pode ser necessário atualizar a versão do sistema operacional do seu dispositivo.", "wordsPerMinute": "Palavras por minuto", - "autoIGCToolName": "Executar assistência de escrita Pangea automaticamente", - "autoIGCToolDescription": "Executar automaticamente a assistência de escrita de gramática e tradução do Pangea Chat antes de enviar minha mensagem.", "tooltipInstructionsTitle": "Não tem certeza do que isso faz?", "tooltipInstructionsMobileBody": "Pressione e segure itens para visualizar dicas de ferramenta.", "tooltipInstructionsBrowserBody": "Passe o mouse sobre os itens para visualizar dicas de ferramenta.", @@ -3224,7 +3222,6 @@ "numModules": "{num} módulos", "coursePlan": "Plano do Curso", "editCourseLater": "Pode editar o título do modelo, descrições e imagem do curso mais tarde.", - "newCourseAccess": "Por padrão, os cursos são privados e requerem aprovação do administrador para participar. Pode editar estas configurações a qualquer momento.", "createCourse": "Criar curso", "stats": "Estatísticas", "createGroupChat": "Criar chat de grupo", @@ -3331,7 +3328,7 @@ "selectAll": "Selecionar tudo", "deselectAll": "Desmarcar tudo", "@@locale": "pt_PT", - "@@last_modified": "2026-01-07 14:26:44.705293", + "@@last_modified": "2026-02-05 10:09:50.725651", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -7252,14 +7249,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9878,10 +9867,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11854,5 +11839,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Você saiu do chat", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Download iniciado", + "webDownloadPermissionMessage": "Se o seu navegador bloquear downloads, por favor, habilite downloads para este site.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "O progresso da sua sessão de prática não será salvo.", + "practiceGrammar": "Praticar gramática", + "notEnoughToPractice": "Envie mais mensagens para desbloquear a prática", + "constructUseCorGCDesc": "Prática da categoria de gramática correta", + "constructUseIncGCDesc": "Prática da categoria de gramática incorreta", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Prática de erro gramatical correto", + "constructUseIncGEDesc": "Prática de erro gramatical incorreto", + "fillInBlank": "Preencha a lacuna com a escolha correta", + "learn": "Aprender", + "languageUpdated": "Idioma de destino atualizado!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Voz do Bot Pangea", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Sua solicitação foi enviada ao administrador do curso! Você será admitido se eles aprovarem.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Você tem um código de convite ou link para um curso público?", + "welcomeUser": "Bem-vindo {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Procure usuários para convidá-los para este chat.", + "publicInviteDescSpace": "Procure usuários para convidá-los para este espaço.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat é um aplicativo de mensagens, então as notificações são importantes!", + "enableNotificationsDesc": "Permitir notificações", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Usar imagem da atividade como fundo do chat", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Converse com o Suporte", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Por padrão, os cursos são pesquisáveis publicamente e requerem aprovação do administrador para ingressar. Você pode editar essas configurações a qualquer momento.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Qual idioma você está aprendendo?", + "searchLanguagesHint": "Pesquise idiomas-alvo", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Dúvidas? Estamos aqui para ajudar!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Algo deu errado, e estamos trabalhando arduamente para corrigir isso. Verifique novamente mais tarde.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Ativar assistência de escrita", + "autoIGCToolDescription": "Executar automaticamente as ferramentas do Pangea Chat para corrigir mensagens enviadas para o idioma de destino.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "A gravação falhou. Por favor, verifique suas permissões de áudio e tente novamente.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Verbo Frasal", + "grammarCopyPOScompn": "Composto", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ro.arb b/lib/l10n/intl_ro.arb index 7ab1f3c39..8dc92a39b 100644 --- a/lib/l10n/intl_ro.arb +++ b/lib/l10n/intl_ro.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:24:11.005786", + "@@last_modified": "2026-02-05 10:09:23.119007", "about": "Despre", "@about": { "type": "String", @@ -3204,8 +3204,6 @@ "noPaymentInfo": "Nicio informație de plată necesară!", "updatePhoneOS": "Poate fi necesar să actualizați versiunea sistemului de operare al dispozitivului dvs.", "wordsPerMinute": "Cuvinte pe minut", - "autoIGCToolName": "Rulează automat asistența de scriere Pangea", - "autoIGCToolDescription": "Porniți automat asistența pentru scrierea gramaticii și traducerii în Pangea Chat înainte de a trimite mesajul meu.", "tooltipInstructionsTitle": "Nu ești sigur ce face asta?", "tooltipInstructionsMobileBody": "Ține apăsat pe elemente pentru a vizualiza sfaturi.", "tooltipInstructionsBrowserBody": "Poziționează cursorul peste elemente pentru a vizualiza sfaturi.", @@ -3833,7 +3831,6 @@ "numModules": "{num} module", "coursePlan": "Plan de curs", "editCourseLater": "Poți edita titlul, descrierile și imaginea cursului mai târziu.", - "newCourseAccess": "În mod implicit, cursurile sunt private și necesită aprobarea administratorului pentru a te alătura. Poți edita aceste setări oricând.", "createCourse": "Creează curs", "stats": "Statistici", "createGroupChat": "Creează chat de grup", @@ -6941,14 +6938,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9567,10 +9556,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11560,5 +11545,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Ai părăsit chat-ul", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Descărcare inițiată", + "webDownloadPermissionMessage": "Dacă browserul dvs. blochează descărcările, vă rugăm să activați descărcările pentru acest site.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Progresul sesiunii tale de practică nu va fi salvat.", + "practiceGrammar": "Exersează gramatică", + "notEnoughToPractice": "Trimite mai multe mesaje pentru a debloca practica", + "constructUseCorGCDesc": "Practică categoria de gramatică corectă", + "constructUseIncGCDesc": "Practică categoria de gramatică incorectă", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Practică corectă a erorilor de gramatică", + "constructUseIncGEDesc": "Practică incorectă a erorilor de gramatică", + "fillInBlank": "Completați spațiul gol cu alegerea corectă", + "learn": "Învățați", + "languageUpdated": "Limba țintă a fost actualizată!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Vocea Pangea Bot", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Cererea ta a fost trimisă administratorului cursului! Vei fi lăsat să intri dacă ei aprobă.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Ai un cod de invitație sau un link pentru un curs public?", + "welcomeUser": "Bine ai venit {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Caută utilizatori pentru a-i invita în acest chat.", + "publicInviteDescSpace": "Caută utilizatori pentru a-i invita în acest spațiu.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat este o aplicație de mesagerie, așa că notificările sunt importante!", + "enableNotificationsDesc": "Permite notificările", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Folosește imaginea activității ca fundal pentru chat", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Chat cu Suportul", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "În mod implicit, cursurile sunt căutabile public și necesită aprobată de administrator pentru a se alătura. Puteți edita aceste setări în orice moment.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Ce limbă înveți?", + "searchLanguagesHint": "Caută limbi țintă", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Întrebări? Suntem aici să ajutăm!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Ceva a mers prost și lucrăm din greu pentru a remedia problema. Verifică din nou mai târziu.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Activare asistență la scriere", + "autoIGCToolDescription": "Rulați automat instrumentele Pangea Chat pentru a corecta mesajele trimise în limba țintă.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Înregistrarea a eșuat. Vă rugăm să verificați permisiunile audio și să încercați din nou.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Verb Phrastic", + "grammarCopyPOScompn": "Compus", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 720485908..777cd260a 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -1,6 +1,6 @@ { "@@locale": "ru", - "@@last_modified": "2026-01-07 14:28:59.154241", + "@@last_modified": "2026-02-05 10:10:19.334362", "about": "О проекте", "@about": { "type": "String", @@ -3132,8 +3132,6 @@ "@invalidUrl": {}, "addLink": "Добавить ссылку", "@addLink": {}, - "italicText": "Italic", - "@italicText": {}, "unableToJoinChat": "Невозможно присоединиться к чату. Возможно, другая сторона уже закончила разговор.", "@unableToJoinChat": {}, "serverLimitReached": "Ограничения сервера. Ожидайте{seconds} секунд...", @@ -3689,13 +3687,6 @@ "acceptSelection": "Принять исправление", "why": "Почему?", "definition": "Определение", - "exampleSentence": "Приклад речення", - "reportToTeacher": "Кому ви хочете повідомити про це повідомлення?", - "reportMessageTitle": "{reportingUserId} повідомив про повідомлення від {reportedUserId} у чаті {roomName}", - "reportMessageBody": "Повідомлення: {reportedMessage}\nПричина: {reason}", - "noTeachersFound": "Вчителі не знайдені для повідомлення", - "trialExpiration": "Ваша безкоштовна пробна версія закінчується {expiration}", - "freeTrialDesc": "Нові користувачі отримують тижневу безкоштовну пробну версію Pangea Chat", "activateTrial": "Безкоштовна 7-денна пробна версія", "successfullySubscribed": "Ви успішно підписалися!", "clickToManageSubscription": "Натисніть тут, щоб керувати підпискою.", @@ -3723,8 +3714,6 @@ "noPaymentInfo": "Информация о платеже не требуется!", "updatePhoneOS": "Возможно, потребуется обновить версию ОС вашего устройства.", "wordsPerMinute": "Слова в минуту", - "autoIGCToolName": "Автоматически запускать помощь в написании Pangea", - "autoIGCToolDescription": "Автоматически запускать помощь в грамматике и переводе Pangea Chat перед отправкой моего сообщения.", "tooltipInstructionsTitle": "Не уверены, что это делает?", "tooltipInstructionsMobileBody": "Нажмите и удерживайте элементы, чтобы просмотреть подсказки.", "tooltipInstructionsBrowserBody": "Наведите курсор на элементы, чтобы просмотреть подсказки.", @@ -4078,13 +4067,6 @@ "constructUseIncMDesc": "Некорректно в деятельности по грамматике", "constructUseIgnMDesc": "Игнорируется в деятельности по грамматике", "constructUseEmojiDesc": "Правильно в деятельности по эмодзи", - "constructUseCollected": "Thu thập trong trò chuyện", - "constructUseNanDesc": "Không áp dụng được", - "xpIntoLevel": "{currentXP} / {maxXP} XP", - "enableTTSToolName": "Bật chuyển đổi văn bản thành giọng nói", - "enableTTSToolDescription": "Cho phép ứng dụng tạo ra đầu ra chuyển đổi văn bản thành giọng nói cho các phần của văn bản bằng ngôn ngữ mục tiêu của bạn.", - "yourUsername": "Tên người dùng của bạn", - "yourEmail": "Email của bạn", "iWantToLearn": "Я хочу учиться", "pleaseEnterEmail": "Пожалуйста, введите действительный адрес электронной почты.", "myBaseLanguage": "Мой основной язык", @@ -4352,7 +4334,6 @@ "numModules": "{num} модулей", "coursePlan": "План курса", "editCourseLater": "Вы можете редактировать название шаблона, описания и изображение курса позже.", - "newCourseAccess": "По умолчанию курсы являются приватными и требуют одобрения администратора для присоединения. Вы можете изменить эти настройки в любое время.", "createCourse": "Создать курс", "stats": "Статистика", "createGroupChat": "Создать групповой чат", @@ -6311,14 +6292,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -8937,10 +8910,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10930,5 +10899,198 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Вы покинули чат", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Загрузка начата", + "webDownloadPermissionMessage": "Если ваш браузер блокирует загрузки, пожалуйста, разрешите загрузки для этого сайта.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Ваш прогресс в сессии практики не будет сохранен.", + "practiceGrammar": "Практика грамматики", + "notEnoughToPractice": "Отправьте больше сообщений, чтобы разблокировать практику", + "constructUseCorGCDesc": "Практика правильной грамматики", + "constructUseIncGCDesc": "Практика неправильной грамматики", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Практика исправления грамматических ошибок", + "constructUseIncGEDesc": "Практика неправильных грамматических ошибок", + "fillInBlank": "Заполните пропуск правильным вариантом", + "learn": "Учить", + "languageUpdated": "Целевой язык обновлен!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Голос бота Pangea", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Ваш запрос отправлен администратору курса! Вы будете допущены, если они одобрят.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "italicText": "Курсивный текст", + "exampleSentence": "Пример предложения", + "reportToTeacher": "Кому вы хотите пожаловаться на это сообщение?", + "reportMessageTitle": "{reportingUserId} пожаловался на сообщение от {reportedUserId} в чате {roomName}", + "reportMessageBody": "Сообщение: {reportedMessage}\nПричина: {reason}", + "noTeachersFound": "Учителя для жалобы не найдены", + "trialExpiration": "Ваш бесплатный пробный период истекает {expiration}", + "freeTrialDesc": "Новые пользователи получают бесплатную пробную неделю в Pangea Chat", + "constructUseCollected": "Собрано в чате", + "constructUseNanDesc": "Не применимо", + "xpIntoLevel": "{currentXP} / {maxXP} XP", + "enableTTSToolName": "Включен преобразователь текста в речь", + "enableTTSToolDescription": "Позволяет приложению генерировать озвучивание текста на вашем целевом языке.", + "yourUsername": "Ваше имя пользователя", + "yourEmail": "Ваш email", + "@italicText": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "У вас есть код приглашения или ссылка на публичный курс?", + "welcomeUser": "Добро пожаловать {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Ищите пользователей, чтобы пригласить их в этот чат.", + "publicInviteDescSpace": "Ищите пользователей, чтобы пригласить их в это пространство.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat - это приложение для обмена сообщениями, поэтому уведомления важны!", + "enableNotificationsDesc": "Разрешить уведомления", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Использовать изображение активности в качестве фона чата", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Чат с поддержкой", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "По умолчанию курсы доступны для публичного поиска и требуют одобрения администратора для присоединения. Вы можете изменить эти настройки в любое время.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Какой язык вы изучаете?", + "searchLanguagesHint": "Поиск целевых языков", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Вопросы? Мы здесь, чтобы помочь!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Что-то пошло не так, и мы усердно работаем над исправлением. Проверьте позже.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Включить помощь в написании", + "autoIGCToolDescription": "Автоматически запускать инструменты Pangea Chat для исправления отправленных сообщений на целевой язык.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Запись не удалась. Пожалуйста, проверьте свои аудиоразрешения и попробуйте снова.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Идиома", + "grammarCopyPOSphrasalv": "Фразовый глагол", + "grammarCopyPOScompn": "Составное", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sk.arb b/lib/l10n/intl_sk.arb index ab85fb852..d6437f578 100644 --- a/lib/l10n/intl_sk.arb +++ b/lib/l10n/intl_sk.arb @@ -1,6 +1,6 @@ { "@@locale": "sk", - "@@last_modified": "2026-01-07 14:24:18.684168", + "@@last_modified": "2026-02-05 10:09:24.752898", "about": "O aplikácii", "@about": { "type": "String", @@ -2408,8 +2408,6 @@ "noPaymentInfo": "Nie sú potrebné žiadne platobné údaje!", "updatePhoneOS": "Možno budete musieť aktualizovať verziu operačného systému zariadenia", "wordsPerMinute": "Slová za minútu", - "autoIGCToolName": "Automaticky spustiť pomoc pri písaní Pangea", - "autoIGCToolDescription": "Automaticky spustiť gramatickú kontrolu a preklad pomocníka Pangea Chat pred odoslaním správy.", "tooltipInstructionsTitle": "Neviete, čo to robí?", "tooltipInstructionsMobileBody": "Stlačte a podržte položky na zobrazenie návodov.", "tooltipInstructionsBrowserBody": "Nájdite myšou nad položkami na zobrazenie návodov.", @@ -3037,7 +3035,6 @@ "numModules": "{num} modulov", "coursePlan": "Plán kurzu", "editCourseLater": "Môžete neskôr upraviť názov kurzu, popisy a obrázok kurzu.", - "newCourseAccess": "Štandardne sú kurzy súkromné a vyžadujú schválenie správcu na pripojenie. Tieto nastavenia môžete upraviť kedykoľvek.", "createCourse": "Vytvoriť kurz", "stats": "Štatistiky", "createGroupChat": "Vytvoriť skupinový chat", @@ -7290,14 +7287,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9916,10 +9905,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11909,5 +11894,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Opustili ste chat", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Stiahnutie bolo zahájené", + "webDownloadPermissionMessage": "Ak váš prehliadač blokuje sťahovanie, prosím, povolte sťahovanie pre túto stránku.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Pokrok vo vašej cvičebnej relácii nebude uložený.", + "practiceGrammar": "Cvičiť gramatiku", + "notEnoughToPractice": "Odošlite viac správ na odomknutie cvičenia", + "constructUseCorGCDesc": "Cvičenie správnej gramatickej kategórie", + "constructUseIncGCDesc": "Cvičenie nesprávnej gramatickej kategórie", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Cvičenie na opravu gramatických chýb", + "constructUseIncGEDesc": "Cvičenie na nesprávne gramatické chyby", + "fillInBlank": "Doplňte prázdne miesto správnou voľbou", + "learn": "Učte sa", + "languageUpdated": "Cieľový jazyk bol aktualizovaný!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Hlas Pangea Bota", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Vaša žiadosť bola odoslaná administrátorovi kurzu! Budete vpustený, ak ju schvália.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Máte pozývací kód alebo odkaz na verejný kurz?", + "welcomeUser": "Vitaj {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Hľadajte používateľov, aby ste ich pozvali do tohto chatu.", + "publicInviteDescSpace": "Hľadajte používateľov, aby ste ich pozvali do tohto priestoru.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat je aplikácia na posielanie správ, takže notifikácie sú dôležité!", + "enableNotificationsDesc": "Povoliť notifikácie", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Použiť obrázok aktivity ako pozadie chatu", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Chatovať s podporou", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Predvolene sú kurzy verejne vyhľadateľné a vyžadujú schválenie administrátora na pripojenie. Tieto nastavenia môžete kedykoľvek upraviť.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Aký jazyk sa učíte?", + "searchLanguagesHint": "Hľadajte cieľové jazyky", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Otázky? Sme tu, aby sme pomohli!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Niečo sa pokazilo a my na tom tvrdo pracujeme, aby sme to opravili. Skontrolujte to neskôr.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Povoliť pomoc pri písaní", + "autoIGCToolDescription": "Automaticky spustiť nástroje Pangea Chat na opravu odoslaných správ do cieľového jazyka.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Nahrávanie zlyhalo. Skontrolujte svoje povolenia na zvuk a skúste to znova.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idióm", + "grammarCopyPOSphrasalv": "Frázové sloveso", + "grammarCopyPOScompn": "Zložené", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sl.arb b/lib/l10n/intl_sl.arb index f82b3a3a5..9743b3040 100644 --- a/lib/l10n/intl_sl.arb +++ b/lib/l10n/intl_sl.arb @@ -1740,8 +1740,6 @@ "noPaymentInfo": "Ni potrebnih plačilnih informacij!", "updatePhoneOS": "Morda boste morali posodobiti različico operacijskega sistema naprave", "wordsPerMinute": "Besed na minuto", - "autoIGCToolName": "Samodejno zaženi pomoč pri pisanju Pangea", - "autoIGCToolDescription": "Samodejno zaženi pomoč pri slovnici in prevajanju v klepetu Pangea pred pošiljanjem sporočila", "tooltipInstructionsTitle": "Niste prepričani, kaj to naredi?", "tooltipInstructionsMobileBody": "Podrsajte in držite elemente za ogled nasvetov orodja", "tooltipInstructionsBrowserBody": "Premaknite kazalec nad elemente za ogled nasvetov orodja", @@ -2369,7 +2367,6 @@ "numModules": "{num} modulov", "coursePlan": "Načrt tečaja", "editCourseLater": "Lahko kasneje uredite naslov predloge, opise in sliko tečaja.", - "newCourseAccess": "Privzeto so tečaji zasebni in zahtevajo odobritev administratorja za vstop. Te nastavitve lahko kadar koli uredite.", "createCourse": "Ustvari tečaj", "stats": "Statistika", "createGroupChat": "Ustvari skupinski klepet", @@ -2464,7 +2461,7 @@ "playWithAI": "Za zdaj igrajte z AI-jem", "courseStartDesc": "Pangea Bot je pripravljen kadarkoli!\n\n...ampak je bolje učiti se s prijatelji!", "@@locale": "sl", - "@@last_modified": "2026-01-07 14:25:29.847675", + "@@last_modified": "2026-02-05 10:09:38.721866", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -7287,14 +7284,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9913,10 +9902,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11906,5 +11891,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Zapustili ste klepet", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Prenos je bil začet", + "webDownloadPermissionMessage": "Če vaš brskalnik blokira prenose, prosimo, omogočite prenose za to spletno mesto.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Napredek vaše seje vadbe ne bo shranjen.", + "practiceGrammar": "Vadite slovnico", + "notEnoughToPractice": "Pošljite več sporočil, da odklenete vadbo", + "constructUseCorGCDesc": "Vadba pravilne slovnice", + "constructUseIncGCDesc": "Vadba nepravilne slovnice", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Praksa pravilne rabe slovnice", + "constructUseIncGEDesc": "Praksa nepravilne rabe slovnice", + "fillInBlank": "Izpolnite prazno mesto s pravilno izbiro", + "learn": "Učite se", + "languageUpdated": "Ciljni jezik je posodobljen!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Glas Pangea Bota", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Vaša zahteva je bila poslana skrbniku tečaja! Vstopili boste, če jo odobrijo.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Imate kodo za povabilo ali povezavo do javnega tečaja?", + "welcomeUser": "Dobrodošli {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Išči uporabnike, da jih povabiš v ta klepet.", + "publicInviteDescSpace": "Išči uporabnike, da jih povabiš v ta prostor.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat je aplikacija za sporočanje, zato so obvestila pomembna!", + "enableNotificationsDesc": "Dovoli obvestila", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Uporabi sliko dejavnosti kot ozadje klepeta", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Pogovorite se s podporo", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Privzeto so tečaji javno iskalni in zahtevajo odobritev skrbnika za pridružitev. Te nastavitve lahko kadar koli spremenite.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Katero jezika se učiš?", + "searchLanguagesHint": "Išči ciljne jezike", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Vprašanja? Tu smo, da pomagamo!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Nekaj je šlo narobe in trdo delamo na tem, da to popravimo. Preverite znova kasneje.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Omogoči pomoč pri pisanju", + "autoIGCToolDescription": "Samodejno zaženi orodja Pangea Chat za popravljanje poslanih sporočil v ciljni jezik.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Zapisovanje ni uspelo. Preverite svoje avdio dovoljenja in poskusite znova.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Phrasalni glagol", + "grammarCopyPOScompn": "Sestavljenka", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sr.arb b/lib/l10n/intl_sr.arb index c863983a5..831875ff3 100644 --- a/lib/l10n/intl_sr.arb +++ b/lib/l10n/intl_sr.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:29:14.506248", + "@@last_modified": "2026-02-05 10:10:22.625655", "about": "О програму", "@about": { "type": "String", @@ -2821,8 +2821,6 @@ "noPaymentInfo": "Није потребно информације о плаћању!", "updatePhoneOS": "Можда ће вам бити потребно ажурирати верзију оперативног система уређаја.", "wordsPerMinute": "Речи по минуту", - "autoIGCToolName": "Аутоматски покрените Пангее помоћ за писање", - "autoIGCToolDescription": "Аутоматски покреће Пангее Граматика и помоћ за превођење пре слања моје поруке.", "tooltipInstructionsTitle": "Нисте сигурни шта то ради?", "tooltipInstructionsMobileBody": "Држите дуго на ставкама да бисте видели савете.", "tooltipInstructionsBrowserBody": "Покажите мишем на ставке да бисте видели савете.", @@ -3450,7 +3448,6 @@ "numModules": "{num} modula", "coursePlan": "Plan kursa", "editCourseLater": "Možete kasnije izmeniti naslov šablona, opise i sliku kursa.", - "newCourseAccess": "Podrazumevano, kursevi su privatni i zahtevaju odobrenje administratora za pridruživanje. Možete u bilo kom trenutku izmeniti ove postavke.", "createCourse": "Kreiraj kurs", "stats": "Statistika", "createGroupChat": "Kreiraj grupni chat", @@ -7308,14 +7305,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9934,10 +9923,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11927,5 +11912,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Napustili ste chat", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Preuzimanje je pokrenuto", + "webDownloadPermissionMessage": "Ako vaš pregledač blokira preuzimanja, molimo omogućite preuzimanja za ovu stranicu.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Vaš napredak u vežbanju neće biti sačuvan.", + "practiceGrammar": "Vežbajte gramatiku", + "notEnoughToPractice": "Pošaljite više poruka da otključate vežbanje", + "constructUseCorGCDesc": "Vežbanje ispravne gramatike", + "constructUseIncGCDesc": "Vežbanje nepravilne gramatike", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Vežba ispravne gramatike", + "constructUseIncGEDesc": "Vežba nepravilne gramatike", + "fillInBlank": "Popunite prazno mesto sa ispravnim izborom", + "learn": "Učite", + "languageUpdated": "Ciljni jezik je ažuriran!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Glas Pangea Bota", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Vaš zahtev je poslat administratoru kursa! Bićete primljeni ako odobre.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Imate li pozivni kod ili link za javni kurs?", + "welcomeUser": "Dobrodošli {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Pretražite korisnike da ih pozovete u ovaj čat.", + "publicInviteDescSpace": "Pretražite korisnike da ih pozovete u ovaj prostor.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat je aplikacija za slanje poruka, pa su obaveštenja važna!", + "enableNotificationsDesc": "Dozvoli obaveštenja", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Koristi sliku aktivnosti kao pozadinu za čat", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Razgovarajte sa podrškom", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Po defaultu, kursevi su javno pretraživi i zahtevaju odobrenje administratora za pridruživanje. Ove postavke možete izmeniti u bilo kojem trenutku.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Koji jezik učite?", + "searchLanguagesHint": "Pretraži ciljne jezike", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Pitanja? Tu smo da pomognemo!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Nešto je pošlo po zlu, i mi marljivo radimo na rešenju. Proverite ponovo kasnije.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Omogući pomoć pri pisanju", + "autoIGCToolDescription": "Automatski pokreni Pangea Chat alate za ispravljanje poslatih poruka na ciljni jezik.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Snimanje nije uspelo. Proverite svoja audio dopuštenja i pokušajte ponovo.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Phrasal Verb", + "grammarCopyPOScompn": "Kombinacija", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sv.arb b/lib/l10n/intl_sv.arb index b70f8acbc..721c6fbe0 100644 --- a/lib/l10n/intl_sv.arb +++ b/lib/l10n/intl_sv.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:28:25.359543", + "@@last_modified": "2026-02-05 10:10:13.035755", "about": "Om", "@about": { "type": "String", @@ -3446,8 +3446,6 @@ "noPaymentInfo": "Ingen betalningsinformation behövs!", "updatePhoneOS": "Du kan behöva uppdatera din enhets operativsystemversion.", "wordsPerMinute": "Ord per minut", - "autoIGCToolName": "Kör Pangea skrivhjälp automatiskt", - "autoIGCToolDescription": "Kör automatiskt Pangea Chat grammatik- och översättningshjälp innan jag skickar mitt meddelande.", "tooltipInstructionsTitle": "Inte säker på vad det gör?", "tooltipInstructionsMobileBody": "Håll och tryck på objekt för att visa verktygstips.", "tooltipInstructionsBrowserBody": "Hovra över objekt för att visa verktygstips.", @@ -4075,7 +4073,6 @@ "numModules": "{num} moduler", "coursePlan": "Kursplan", "editCourseLater": "Du kan redigera mallens titel, beskrivningar och kursbild senare.", - "newCourseAccess": "Som standard är kurser privata och kräver administratörsgodkännande för att gå med. Du kan ändra dessa inställningar när som helst.", "createCourse": "Skapa kurs", "stats": "Statistik", "createGroupChat": "Skapa gruppchatt", @@ -6684,14 +6681,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9310,10 +9299,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11303,5 +11288,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Du lämnade chatten", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Nedladdning initierad", + "webDownloadPermissionMessage": "Om din webbläsare blockerar nedladdningar, vänligen aktivera nedladdningar för den här sidan.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Din övningssession kommer inte att sparas.", + "practiceGrammar": "Öva grammatik", + "notEnoughToPractice": "Skicka fler meddelanden för att låsa upp övning", + "constructUseCorGCDesc": "Övning i korrekt grammatikkategori", + "constructUseIncGCDesc": "Övning i inkorrekt grammatikkategori", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Korrekt grammatikfel övning", + "constructUseIncGEDesc": "Inkorrekt grammatikfel övning", + "fillInBlank": "Fyll i det tomma med rätt val", + "learn": "Lär dig", + "languageUpdated": "Målspråk uppdaterat!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Bot röst", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Din begäran har skickats till kursadministratören! Du kommer att släppas in om de godkänner.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Har du en inbjudningskod eller länk till en offentlig kurs?", + "welcomeUser": "Välkommen {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Sök efter användare för att bjuda in dem till den här chatten.", + "publicInviteDescSpace": "Sök efter användare för att bjuda in dem till det här utrymmet.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat är en meddelandeapp så aviseringar är viktiga!", + "enableNotificationsDesc": "Tillåt aviseringar", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Använd aktivitetsbild som chattbakgrund", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Chatta med support", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Som standard är kurser offentligt sökbara och kräver administratörsgodkännande för att gå med. Du kan redigera dessa inställningar när som helst.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Vilket språk lär du dig?", + "searchLanguagesHint": "Sök efter målspråk", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Frågor? Vi är här för att hjälpa till!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Något gick fel, och vi arbetar hårt för att åtgärda det. Kolla igen senare.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Aktivera skrivhjälp", + "autoIGCToolDescription": "Kör automatiskt Pangea Chat-verktyg för att korrigera skickade meddelanden till målspråket.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Inspelningen misslyckades. Kontrollera dina ljudbehörigheter och försök igen.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Idiom", + "grammarCopyPOSphrasalv": "Phrasverb", + "grammarCopyPOScompn": "Sammansatt", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ta.arb b/lib/l10n/intl_ta.arb index 22033e897..744131c01 100644 --- a/lib/l10n/intl_ta.arb +++ b/lib/l10n/intl_ta.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:27:42.869267", + "@@last_modified": "2026-02-05 10:10:03.555298", "acceptedTheInvitation": "👍 {username} அழைப்பை ஏற்றுக்கொண்டது", "@acceptedTheInvitation": { "type": "String", @@ -3661,8 +3661,6 @@ "noPaymentInfo": "பணம் செலுத்தும் தகவல் தேவையில்லை!", "updatePhoneOS": "உங்கள் சாதனத்தின் OS பதிப்பை புதுப்பிக்க வேண்டியிருக்கலாம்.", "wordsPerMinute": "நிமிடத்திற்கு சொற்கள்", - "autoIGCToolName": "பங்கேயா எழுத்து உதவியை தானாக இயக்கவும்", - "autoIGCToolDescription": "எனது செய்தியை அனுப்புவதற்கு முன் தானாக பங்கேயா உரையாடல் இலக்கணம் மற்றும் மொழிபெயர்ப்பு எழுத்து உதவியை இயக்கவும்.", "tooltipInstructionsTitle": "அது என்ன செய்கிறது என்று தெரியுமா?", "tooltipInstructionsMobileBody": "உருப்படிகளை அழுத்தி வைத்திருங்கள், கருவி விளக்கங்களைப் பார்க்க.", "tooltipInstructionsBrowserBody": "உருப்படிகளின் மேல் கரைசல் வைத்து கருவி விளக்கங்களைப் பார்க்க.", @@ -4290,7 +4288,6 @@ "numModules": "{num} பகுதிகள்", "coursePlan": "பாட திட்டம்", "editCourseLater": "பின்னர் நீங்கள் மாதிரி தலைப்பு, விளக்கங்கள் மற்றும் பாடம் படத்தைத் திருத்தலாம்.", - "newCourseAccess": "இயல்பாக, பாடங்கள் தனிப்பட்டவை மற்றும் சேர்க்க நிர்வாகத்தின் ஒப்புதலை தேவைபடுகின்றன. நீங்கள் எப்போது வேண்டுமானாலும் இந்த அமைப்புகளை மாற்றலாம்.", "createCourse": "பாடத்தைக் கற்பிக்கவும்", "stats": "புள்ளிவிவரங்கள்", "createGroupChat": "குழு உரையாடலை உருவாக்கவும்", @@ -6430,14 +6427,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9056,10 +9045,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11049,5 +11034,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 நீங்கள் உரையாடலை விட்டுவிட்டீர்கள்", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "பதிவிறக்கம் தொடங்கப்பட்டது", + "webDownloadPermissionMessage": "உங்கள் உலாவி பதிவிறக்கங்களை தடுக்கும் என்றால், தயவுசெய்து இந்த தளத்திற்கு பதிவிறக்கங்களை செயல்படுத்தவும்.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "உங்கள் பயிற்சி அமர்வின் முன்னேற்றம் சேமிக்கப்படாது.", + "practiceGrammar": "வியாசத்தை பயிற்சி செய்யவும்", + "notEnoughToPractice": "பயிற்சியை திறக்க மேலும் செய்திகளை அனுப்பவும்", + "constructUseCorGCDesc": "சரியான வியாச வகை பயிற்சி", + "constructUseIncGCDesc": "தவறான வியாச வகை பயிற்சி", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "சரியான இலக்கண பிழை பயிற்சி", + "constructUseIncGEDesc": "தவறான இலக்கண பிழை பயிற்சி", + "fillInBlank": "சரியான தேர்வுடன் காலியை நிரப்பவும்", + "learn": "கற்றுக்கொள்ளுங்கள்", + "languageUpdated": "இலக்கு மொழி புதுப்பிக்கப்பட்டது!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "பாஙேஆ பாட்டின் குரல்", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "உங்கள் கோரிக்கை பாடம் நிர்வாகிக்கு அனுப்பப்பட்டுள்ளது! அவர்கள் ஒப்புதலளித்தால் நீங்கள் உள்ளே அனுமதிக்கப்படுவீர்கள்.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "உங்களுக்கு ஒரு அழைப்பு குறியீடு அல்லது பொது பாடத்திற்கு இணைப்பு உள்ளதா?", + "welcomeUser": "வரவேற்கிறேன் {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "இந்த உரையாடலுக்கு அழைக்க பயனர்களை தேடுங்கள்.", + "publicInviteDescSpace": "இந்த இடத்திற்கு அழைக்க பயனர்களை தேடுங்கள்.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "பாஙேஆ சாட் என்பது ஒரு செய்தி அனுப்பும் செயலி ஆகும், எனவே அறிவிப்புகள் முக்கியமானவை!", + "enableNotificationsDesc": "அறிவிப்புகளை அனுமதிக்கவும்", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "செயல்பாட்டு படத்தை உரையாடல் பின்னணி ஆக பயன்படுத்தவும்", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "ஆதரவுடன் உரையாடவும்", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "இயல்பாக, பாடங்கள் பொதுவாக தேடக்கூடியவை மற்றும் சேர்வதற்கு நிர்வாகத்தின் அங்கீகாரம் தேவை. நீங்கள் எப்போது வேண்டுமானாலும் இந்த அமைப்புகளை திருத்தலாம்.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "நீங்கள் எது மொழி கற்றுக்கொள்கிறீர்கள்?", + "searchLanguagesHint": "இலக்கு மொழிகளை தேடுங்கள்", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "கேள்விகள்? நாங்கள் உதவ இங்கே இருக்கிறோம்!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "எதோ தவறு ஏற்பட்டது, அதை சரிசெய்ய நாங்கள் கடுமையாக வேலை செய்கிறோம். பின்னர் மீண்டும் சரிபார்க்கவும்.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "எழுத்து உதவியை செயல்படுத்தவும்", + "autoIGCToolDescription": "அனுப்பிய செய்திகளை இலக்கு மொழிக்கு சரிசெய்ய பாஙோ உரையாடல் கருவிகளை தானாகவே இயக்கவும்.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "பதிவு தோல்வியுற்றது. உங்கள் ஒலிப் அனுமதிகளை சரிபார்க்கவும் மற்றும் மீண்டும் முயற்சிக்கவும்.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "விளக்கம்", + "grammarCopyPOSphrasalv": "பொருள் வினை", + "grammarCopyPOScompn": "சேர்க்கை", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_te.arb b/lib/l10n/intl_te.arb index 64aa04147..e1f8f030c 100644 --- a/lib/l10n/intl_te.arb +++ b/lib/l10n/intl_te.arb @@ -1196,8 +1196,6 @@ "noPaymentInfo": "చెల్లింపు సమాచారం అవసరం లేదు!", "updatePhoneOS": "మీ డివైస్ యొక్క OS వెర్షన్‌ను నవీకరించాల్సి ఉండవచ్చు.", "wordsPerMinute": "నిమిషానికి పదాలు", - "autoIGCToolName": "పాంజియా రాయడం సహాయాన్ని స్వయంచాలకంగా నడపండి", - "autoIGCToolDescription": "నా సందేశాన్ని పంపేముందు స్వయంచాలకంగా పాంజియా చాట్ వ్యాకరణం మరియు అనువాద రాయడం సహాయాన్ని నడపండి.", "tooltipInstructionsTitle": "అది ఏమిటో తెలియదా?", "tooltipInstructionsMobileBody": "టూల్‌టిప్‌లను చూడటానికి అంశాలను నొక్కి ఉంచండి.", "tooltipInstructionsBrowserBody": "టూల్‌టిప్‌లను చూడటానికి అంశాలపై హోవర్ చేయండి.", @@ -1825,7 +1823,6 @@ "numModules": "{num} మాడ్యూల్స్", "coursePlan": "కోర్సు ప్రణాళిక", "editCourseLater": "మీరు టెంప్లేట్ శీర్షిక, వివరణలు, మరియు కోర్సు చిత్రాన్ని తర్వాత సవరించవచ్చు.", - "newCourseAccess": "డిఫాల్ట్‌గా, కోర్సులు ప్రైవేట్‌గా ఉంటాయి మరియు చేరడానికి అడ్మిన్ ఆమోదం అవసరం. మీరు ఈ సెట్టింగులను ఎప్పుడైనా సవరించవచ్చు.", "createCourse": "కోర్సు సృష్టించండి", "stats": "గణాంకాలు", "createGroupChat": "గుంపు చాట్ సృష్టించండి", @@ -1920,7 +1917,7 @@ "playWithAI": "ఇప్పుడే AI తో ఆడండి", "courseStartDesc": "పాంజియా బాట్ ఎప్పుడైనా సిద్ధంగా ఉంటుంది!\n\n...కానీ స్నేహితులతో నేర్చుకోవడం మెరుగైనది!", "@@locale": "te", - "@@last_modified": "2026-01-07 14:27:24.330788", + "@@last_modified": "2026-02-05 10:09:59.064928", "@setCustomPermissionLevel": { "type": "String", "placeholders": {} @@ -7298,14 +7295,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9921,10 +9910,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11914,5 +11899,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 మీరు చాట్‌ను విడిచారు", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "డౌన్‌లోడ్ ప్రారంభించబడింది", + "webDownloadPermissionMessage": "మీ బ్రౌజర్ డౌన్‌లోడ్లను అడ్డిస్తే, దయచేసి ఈ సైట్ కోసం డౌన్‌లోడ్లను ప్రారంభించండి.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "మీ ప్రాక్టీస్ సెషన్ పురోగతి సేవ్ చేయబడదు.", + "practiceGrammar": "వ్యాకరణాన్ని అభ్యాసం చేయండి", + "notEnoughToPractice": "ప్రాక్టీస్‌ను అన్లాక్ చేయడానికి మరింత సందేశాలు పంపండి", + "constructUseCorGCDesc": "సరైన వ్యాకరణ శ్రేణి ప్రాక్టీస్", + "constructUseIncGCDesc": "తప్పు వ్యాకరణ శ్రేణి ప్రాక్టీస్", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "సరైన వ్యాకరణ దోషం అభ్యాసం", + "constructUseIncGEDesc": "తప్పు వ్యాకరణ దోషం అభ్యాసం", + "fillInBlank": "సరైన ఎంపికతో ఖాళీని నింపండి", + "learn": "కలవు", + "languageUpdated": "లక్ష్య భాష నవీకరించబడింది!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "పాంజియా బాట్ శబ్దం", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "మీ అభ్యర్థన కోర్సు నిర్వాహకుడికి పంపబడింది! వారు ఆమోదిస్తే, మీరు లోపలికి రానున్నారు.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "మీకు పబ్లిక్ కోర్సుకు ఆహ్వాన కోడ్ లేదా లింక్ ఉందా?", + "welcomeUser": "స్వాగతం {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "ఈ చాట్లో ఆహ్వానించడానికి వినియోగదారులను శోధించండి.", + "publicInviteDescSpace": "ఈ స్థలంలో ఆహ్వానించడానికి వినియోగదారులను శోధించండి.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "పాంజియా చాట్ ఒక సందేశం యాప్ కాబట్టి నోటిఫికేషన్లు ముఖ్యమైనవి!", + "enableNotificationsDesc": "నోటిఫికేషన్లను అనుమతించండి", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "చాట్ నేపథ్యంగా కార్యకలాప చిత్రాన్ని ఉపయోగించండి", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "సహాయంతో చాట్ చేయండి", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "డిఫాల్ట్‌గా, కోర్సులు ప్రజా శోధనకు అందుబాటులో ఉంటాయి మరియు చేరడానికి అడ్మిన్ ఆమోదం అవసరం. మీరు ఈ సెట్టింగులను ఎప్పుడైనా సవరించవచ్చు.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "మీరు ఏ భాష నేర్చుకుంటున్నారు?", + "searchLanguagesHint": "లక్ష్య భాషలను శోధించండి", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "ప్రశ్నలు? మేము మీకు సహాయం చేయడానికి ఇక్కడ ఉన్నాము!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "ఏదో తప్పు జరిగింది, మరియు మేము దీన్ని సరిదిద్దడానికి కష్టపడుతున్నాము. తర్వాత మళ్లీ తనిఖీ చేయండి.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "రాయడం సహాయాన్ని ప్రారంభించండి", + "autoIGCToolDescription": "సమర్పించిన సందేశాలను లక్ష్య భాషకు సరిదిద్దడానికి పాంజియా చాట్ సాధనాలను ఆటోమేటిక్‌గా నడపండి.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "రికార్డింగ్ విఫలమైంది. దయచేసి మీ ఆడియో అనుమతులను తనిఖీ చేసి మళ్లీ ప్రయత్నించండి.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "సామెత", + "grammarCopyPOSphrasalv": "పదబంధ క్రియ", + "grammarCopyPOScompn": "సంకలనం", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_th.arb b/lib/l10n/intl_th.arb index d810730a4..2f5e88895 100644 --- a/lib/l10n/intl_th.arb +++ b/lib/l10n/intl_th.arb @@ -3732,8 +3732,6 @@ "noPaymentInfo": "ไม่จำเป็นต้องมีข้อมูลการชำระเงิน!", "updatePhoneOS": "คุณอาจจำเป็นต้องอัปเดตเวอร์ชันระบบปฏิบัติการของอุปกรณ์ของคุณ", "wordsPerMinute": "คำต่อนาที", - "autoIGCToolName": "เรียกใช้เครื่องมือช่วยเขียน Pangea อัตโนมัติ", - "autoIGCToolDescription": "เรียกใช้การช่วยเขียนไวยากรณ์และการแปลของ Pangea Chat อัตโนมัติก่อนส่งข้อความของฉัน", "tooltipInstructionsTitle": "ไม่แน่ใจว่าสิ่งนั้นทำอะไร?", "tooltipInstructionsMobileBody": "กดค้างไว้เพื่อดูคำแนะนำเครื่องมือ", "tooltipInstructionsBrowserBody": "วางเมาส์เหนือรายการเพื่อดูคำแนะนำเครื่องมือ", @@ -4361,7 +4359,6 @@ "numModules": "{num} โมดูล", "coursePlan": "แผนหลักสูตร", "editCourseLater": "คุณสามารถแก้ไขชื่อเทมเพลต คำอธิบาย และภาพหลักสูตรในภายหลัง", - "newCourseAccess": "โดยค่าเริ่มต้น หลักสูตรเป็นส่วนตัวและต้องได้รับการอนุมัติจากผู้ดูแลระบบเพื่อเข้าร่วม คุณสามารถแก้ไขการตั้งค่าเหล่านี้ได้ทุกเมื่อ", "createCourse": "สร้างหลักสูตร", "stats": "สถิติ", "createGroupChat": "สร้างกลุ่มแชท", @@ -4456,7 +4453,7 @@ "playWithAI": "เล่นกับ AI ชั่วคราว", "courseStartDesc": "Pangea Bot พร้อมที่จะเริ่มต้นได้ทุกเมื่อ!\n\n...แต่การเรียนรู้ดีกว่ากับเพื่อน!", "@@locale": "th", - "@@last_modified": "2026-01-07 14:26:38.383329", + "@@last_modified": "2026-02-05 10:09:49.236652", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -7264,14 +7261,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9890,10 +9879,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11883,5 +11868,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 คุณออกจากการสนทนา", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "เริ่มการดาวน์โหลด", + "webDownloadPermissionMessage": "หากเบราว์เซอร์ของคุณบล็อกการดาวน์โหลด โปรดเปิดใช้งานการดาวน์โหลดสำหรับเว็บไซต์นี้.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "ความก้าวหน้าของการฝึกฝนของคุณจะไม่ถูกบันทึก", + "practiceGrammar": "ฝึกไวยากรณ์", + "notEnoughToPractice": "ส่งข้อความเพิ่มเติมเพื่อปลดล็อกการฝึกฝน", + "constructUseCorGCDesc": "การฝึกไวยากรณ์หมวดหมู่ที่ถูกต้อง", + "constructUseIncGCDesc": "การฝึกไวยากรณ์หมวดหมู่ที่ไม่ถูกต้อง", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "การฝึกฝนข้อผิดพลาดทางไวยากรณ์ที่ถูกต้อง", + "constructUseIncGEDesc": "การฝึกฝนข้อผิดพลาดทางไวยากรณ์ที่ไม่ถูกต้อง", + "fillInBlank": "กรอกข้อมูลในช่องว่างด้วยตัวเลือกที่ถูกต้อง", + "learn": "เรียนรู้", + "languageUpdated": "อัปเดตภาษาที่ต้องการแล้ว!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "เสียงของ Pangea Bot", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "คำขอของคุณได้ถูกส่งไปยังผู้ดูแลหลักสูตรแล้ว! คุณจะได้รับอนุญาตให้เข้าหากพวกเขาอนุมัติ.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "คุณมีรหัสเชิญหรือลิงก์ไปยังหลักสูตรสาธารณะหรือไม่?", + "welcomeUser": "ยินดีต้อนรับ {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "ค้นหาผู้ใช้เพื่อนำไปเชิญเข้าร่วมแชทนี้。", + "publicInviteDescSpace": "ค้นหาผู้ใช้เพื่อนำไปเชิญเข้าร่วมพื้นที่นี้。", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat เป็นแอปส่งข้อความ ดังนั้นการแจ้งเตือนจึงสำคัญ!", + "enableNotificationsDesc": "อนุญาตการแจ้งเตือน", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "ใช้ภาพกิจกรรมเป็นพื้นหลังแชท", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "แชทกับฝ่ายสนับสนุน", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "ตามค่าเริ่มต้น หลักสูตรจะสามารถค้นหาได้สาธารณะและต้องการการอนุมัติจากผู้ดูแลระบบเพื่อเข้าร่วม คุณสามารถแก้ไขการตั้งค่าเหล่านี้ได้ตลอดเวลา", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "คุณกำลังเรียนภาษาอะไรอยู่?", + "searchLanguagesHint": "ค้นหาภาษาที่ต้องการ", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "มีคำถามไหม? เราพร้อมที่จะช่วยเหลือ!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "มีบางอย่างผิดพลาด และเรากำลังทำงานอย่างหนักเพื่อแก้ไข ตรวจสอบอีกครั้งในภายหลัง.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "เปิดใช้งานความช่วยเหลือในการเขียน", + "autoIGCToolDescription": "เรียกใช้เครื่องมือ Pangea Chat โดยอัตโนมัติเพื่อแก้ไขข้อความที่ส่งไปยังภาษาที่ต้องการ.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "การบันทึกล้มเหลว โปรดตรวจสอบสิทธิ์เสียงของคุณและลองอีกครั้ง", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "สำนวน", + "grammarCopyPOSphrasalv": "กริยาวลี", + "grammarCopyPOScompn": "คำผสม", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index 392af0e8a..3df573555 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -1,6 +1,6 @@ { "@@locale": "tr", - "@@last_modified": "2026-01-07 14:27:16.503973", + "@@last_modified": "2026-02-05 10:09:57.710087", "about": "Hakkında", "@about": { "type": "String", @@ -3668,8 +3668,6 @@ "noPaymentInfo": "Ödeme bilgisi gerekmez!", "updatePhoneOS": "Cihazınızın işletim sistemi sürümünü güncellemeniz gerekebilir.", "wordsPerMinute": "Dakikada kelime", - "autoIGCToolName": "Pangea yazma yardımını otomatik çalıştır", - "autoIGCToolDescription": "Mesajımı göndermeden önce Pangea Sohbet dilbilgisi ve çeviri yazma yardımını otomatik olarak çalıştır.", "tooltipInstructionsTitle": "Bu ne işe yarar bilmiyor musun?", "tooltipInstructionsMobileBody": "Öğe üzerine basılı tutarak ipuçlarını görüntüleyin.", "tooltipInstructionsBrowserBody": "İpuçlarını görüntülemek için öğeler üzerinde fare ile durun.", @@ -4297,7 +4295,6 @@ "numModules": "{num} modül", "coursePlan": "Kurs Planı", "editCourseLater": "Şablon başlığı, açıklamalar ve kurs resmi daha sonra düzenleyebilirsiniz.", - "newCourseAccess": "Varsayılan olarak, kurslar özeldir ve katılmak için yönetici onayı gerekir. Bu ayarları istediğiniz zaman değiştirebilirsiniz.", "createCourse": "Kurs Oluştur", "stats": "İstatistikler", "createGroupChat": "Grup sohbeti oluştur", @@ -6428,14 +6425,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9054,10 +9043,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11047,5 +11032,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Sohbeti terk ettin", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "İndirme başlatıldı", + "webDownloadPermissionMessage": "Tarayıcınız indirmeleri engelliyorsa, lütfen bu site için indirmeleri etkinleştirin.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Pratik oturumunuzun ilerlemesi kaydedilmeyecek.", + "practiceGrammar": "Dil bilgisi pratiği yap", + "notEnoughToPractice": "Pratik yapmak için daha fazla mesaj gönderin", + "constructUseCorGCDesc": "Doğru dil bilgisi kategorisi pratiği", + "constructUseIncGCDesc": "Yanlış dil bilgisi kategorisi pratiği", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Doğru dil bilgisi hatası pratiği", + "constructUseIncGEDesc": "Yanlış dil bilgisi hatası pratiği", + "fillInBlank": "Boşluğu doğru seçimle doldurun", + "learn": "Öğren", + "languageUpdated": "Hedef dil güncellendi!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Bot sesi", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Talebiniz kurs yöneticisine gönderildi! Onaylarlarsa içeri alınacaksınız.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Bir davet kodunuz veya halka açık bir kursa bağlantınız var mı?", + "welcomeUser": "Hoş geldin {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Bu sohbete davet etmek için kullanıcıları arayın.", + "publicInviteDescSpace": "Bu alana davet etmek için kullanıcıları arayın.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat, bir mesajlaşma uygulamasıdır, bu yüzden bildirimler önemlidir!", + "enableNotificationsDesc": "Bildirimlere izin ver", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Etkinlik resmini sohbet arka planı olarak kullan", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Destek ile Sohbet Et", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Varsayılan olarak, kurslar herkese açık olarak aranabilir ve katılmak için yönetici onayı gerektirir. Bu ayarları istediğiniz zaman düzenleyebilirsiniz.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Hangi dili öğreniyorsunuz?", + "searchLanguagesHint": "Hedef dilleri arayın", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Sorular mı? Yardımcı olmaya buradayız!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Bir şeyler yanlış gitti ve biz bunu düzeltmek için yoğun bir şekilde çalışıyoruz. Lütfen daha sonra tekrar kontrol edin.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Yazma yardımını etkinleştir", + "autoIGCToolDescription": "Gönderilen mesajları hedef dile düzeltmek için Pangea Chat araçlarını otomatik olarak çalıştır.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Kayıt başarısız oldu. Lütfen ses izinlerinizi kontrol edin ve tekrar deneyin.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Deyim", + "grammarCopyPOSphrasalv": "Deyim Fiili", + "grammarCopyPOScompn": "Bileşik", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_uk.arb b/lib/l10n/intl_uk.arb index 9b8d546e5..d4d7de8bb 100644 --- a/lib/l10n/intl_uk.arb +++ b/lib/l10n/intl_uk.arb @@ -1,6 +1,6 @@ { "@@locale": "uk", - "@@last_modified": "2026-01-07 14:25:54.221263", + "@@last_modified": "2026-02-05 10:09:42.549877", "about": "Про застосунок", "@about": { "type": "String", @@ -3787,8 +3787,6 @@ "noPaymentInfo": "Інформація про оплату не потрібна!", "updatePhoneOS": "Можливо, потрібно оновити версію ОС вашого пристрою.", "wordsPerMinute": "Слів за хвилину", - "autoIGCToolName": "Автоматично запускати допомогу з написання Pangea", - "autoIGCToolDescription": "Автоматично запускати допомогу з граматики та перекладу в чаті Pangea перед відправкою мого повідомлення.", "tooltipInstructionsTitle": "Не впевнений, що це робить?", "tooltipInstructionsMobileBody": "Натисніть і утримуйте елементи, щоб переглянути підказки.", "tooltipInstructionsBrowserBody": "Наведіть курсор на елементи, щоб переглянути підказки.", @@ -4416,7 +4414,6 @@ "numModules": "{num} модулів", "coursePlan": "План курсу", "editCourseLater": "Ви можете редагувати назву шаблону, описи та зображення курсу пізніше.", - "newCourseAccess": "За замовчуванням курси є приватними і потребують схвалення адміністратора для приєднання. Ви можете редагувати ці налаштування в будь-який час.", "createCourse": "Створити курс", "stats": "Статистика", "createGroupChat": "Створити груповий чат", @@ -6200,14 +6197,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -8826,10 +8815,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10819,5 +10804,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Ви вийшли з чату", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Завантаження розпочато", + "webDownloadPermissionMessage": "Якщо ваш браузер блокує завантаження, будь ласка, увімкніть завантаження для цього сайту.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Ваш прогрес у сесії практики не буде збережено.", + "practiceGrammar": "Практика граматики", + "notEnoughToPractice": "Надішліть більше повідомлень, щоб розблокувати практику", + "constructUseCorGCDesc": "Практика правильної граматичної категорії", + "constructUseIncGCDesc": "Практика неправильної граматичної категорії", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Практика виправлення граматичних помилок", + "constructUseIncGEDesc": "Практика неправильних граматичних помилок", + "fillInBlank": "Заповніть пропуск правильним вибором", + "learn": "Вчити", + "languageUpdated": "Цільова мова оновлена!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Голос Pangea Bot", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Ваш запит надіслано адміністратору курсу! Ви будете допущені, якщо вони схвалять.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "У вас є код запрошення або посилання на публічний курс?", + "welcomeUser": "Ласкаво просимо {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Шукайте користувачів, щоб запросити їх до цього чату.", + "publicInviteDescSpace": "Шукайте користувачів, щоб запросити їх до цього простору.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat - це додаток для обміну повідомленнями, тому сповіщення важливі!", + "enableNotificationsDesc": "Дозволити сповіщення", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Використовувати зображення активності як фон чату", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Чат з підтримкою", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "За замовчуванням курси є загальнодоступними для пошуку і вимагають схвалення адміністратора для приєднання. Ви можете редагувати ці налаштування в будь-який час.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Яку мову ви вивчаєте?", + "searchLanguagesHint": "Шукати цільові мови", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Питання? Ми тут, щоб допомогти!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Щось пішло не так, і ми наполегливо працюємо над виправленням. Перевірте пізніше.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Увімкнути допомогу в написанні", + "autoIGCToolDescription": "Автоматично запускати інструменти Pangea Chat для виправлення надісланих повідомлень на цільову мову.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Запис не вдався. Будь ласка, перевірте свої аудіоправа та спробуйте ще раз.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Ідіома", + "grammarCopyPOSphrasalv": "Фразове дієслово", + "grammarCopyPOScompn": "Складене", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_vi.arb b/lib/l10n/intl_vi.arb index 77ad1ad05..7c8c9bc73 100644 --- a/lib/l10n/intl_vi.arb +++ b/lib/l10n/intl_vi.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:27:35.989013", + "@@last_modified": "2026-02-05 10:10:02.295528", "about": "Giới thiệu", "@about": { "type": "String", @@ -2422,8 +2422,6 @@ "noPaymentInfo": "Không cần thông tin thanh toán!", "updatePhoneOS": "Bạn có thể cần nâng cấp phiên bản hệ điều hành.", "wordsPerMinute": "Từ mỗi phút", - "autoIGCToolName": "Tự động chạy hỗ trợ ngôn ngữ", - "autoIGCToolDescription": "Tự động chạy hỗ trợ ngôn ngữ sau khi gõ tin nhắn", "chatCapacity": "Giới hạn thành viên trò chuyện", "roomFull": "Phòng đã đạt giới hạn.", "chatCapacityHasBeenChanged": "Giới hạn thành viên trò chuyện đã thay đổi", @@ -3979,7 +3977,6 @@ "numModules": "{num} mô-đun", "coursePlan": "Kế hoạch khóa học", "editCourseLater": "Bạn có thể chỉnh sửa tiêu đề mẫu, mô tả và hình ảnh khóa học sau.", - "newCourseAccess": "Theo mặc định, các khóa học là riêng tư và yêu cầu sự chấp thuận của quản trị viên để tham gia. Bạn có thể chỉnh sửa các cài đặt này bất cứ lúc nào.", "createCourse": "Tạo khóa học", "stats": "Thống kê", "createGroupChat": "Tạo nhóm trò chuyện", @@ -4369,10 +4366,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -6395,5 +6388,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 Bạn đã rời khỏi cuộc trò chuyện", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "Tải xuống đã được khởi động", + "webDownloadPermissionMessage": "Nếu trình duyệt của bạn chặn tải xuống, vui lòng bật tải xuống cho trang web này.", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "Tiến trình phiên thực hành của bạn sẽ không được lưu.", + "practiceGrammar": "Thực hành ngữ pháp", + "notEnoughToPractice": "Gửi thêm tin nhắn để mở khóa thực hành", + "constructUseCorGCDesc": "Thực hành thể loại ngữ pháp đúng", + "constructUseIncGCDesc": "Thực hành thể loại ngữ pháp sai", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "Thực hành lỗi ngữ pháp đúng", + "constructUseIncGEDesc": "Thực hành lỗi ngữ pháp sai", + "fillInBlank": "Điền vào chỗ trống với lựa chọn đúng", + "learn": "Học", + "languageUpdated": "Ngôn ngữ mục tiêu đã được cập nhật!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Giọng nói của Pangea Bot", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "Yêu cầu của bạn đã được gửi đến quản trị viên khóa học! Bạn sẽ được cho vào nếu họ chấp thuận.", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "Bạn có mã mời hoặc liên kết đến một khóa học công khai không?", + "welcomeUser": "Chào mừng {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "Tìm kiếm người dùng để mời họ tham gia trò chuyện này.", + "publicInviteDescSpace": "Tìm kiếm người dùng để mời họ tham gia không gian này.", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat là một ứng dụng nhắn tin nên thông báo là rất quan trọng!", + "enableNotificationsDesc": "Cho phép thông báo", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "Sử dụng hình ảnh hoạt động làm nền trò chuyện", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "Trò chuyện với Hỗ trợ", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "Theo mặc định, các khóa học có thể tìm kiếm công khai và yêu cầu sự chấp thuận của quản trị viên để tham gia. Bạn có thể chỉnh sửa các cài đặt này bất kỳ lúc nào.", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "Bạn đang học ngôn ngữ nào?", + "searchLanguagesHint": "Tìm kiếm ngôn ngữ mục tiêu", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "Câu hỏi? Chúng tôi ở đây để giúp đỡ!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "Đã xảy ra sự cố, và chúng tôi đang nỗ lực khắc phục. Vui lòng kiểm tra lại sau.", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "Bật trợ giúp viết", + "autoIGCToolDescription": "Tự động chạy các công cụ Pangea Chat để sửa các tin nhắn đã gửi sang ngôn ngữ mục tiêu.", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "Ghi âm không thành công. Vui lòng kiểm tra quyền truy cập âm thanh của bạn và thử lại.", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "Thành ngữ", + "grammarCopyPOSphrasalv": "Động từ cụm", + "grammarCopyPOScompn": "Hợp chất", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_yue.arb b/lib/l10n/intl_yue.arb index 8684f06a8..410706220 100644 --- a/lib/l10n/intl_yue.arb +++ b/lib/l10n/intl_yue.arb @@ -1119,8 +1119,6 @@ "noPaymentInfo": "無需付款資料!", "updatePhoneOS": "您可能需要更新設備的操作系統版本。", "wordsPerMinute": "每分鐘字數", - "autoIGCToolName": "自動運行Pangea寫作協助", - "autoIGCToolDescription": "在發送消息前,自動運行Pangea聊天語法和翻譯寫作協助。", "tooltipInstructionsTitle": "不確定那是什麼嗎?", "tooltipInstructionsMobileBody": "長按項目以查看工具提示。", "tooltipInstructionsBrowserBody": "將滑鼠懸停在項目上以查看工具提示。", @@ -1749,7 +1747,6 @@ "numModules": "{num} 個模組", "coursePlan": "課程計劃", "editCourseLater": "你可以稍後編輯模板標題、描述同課程圖片。", - "newCourseAccess": "預設情況下,課程係私密嘅,需要管理員批准先可以加入。你可以隨時修改呢啲設定。", "createCourse": "建立課程", "stats": "統計數據", "createGroupChat": "建立群組聊天", @@ -1856,7 +1853,7 @@ "selectAll": "全選", "deselectAll": "取消全選", "@@locale": "yue", - "@@last_modified": "2026-01-07 14:25:37.722488", + "@@last_modified": "2026-02-05 10:09:39.916672", "@ignoreUser": { "type": "String", "placeholders": {} @@ -6899,14 +6896,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -9521,10 +9510,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -11916,5 +11901,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 你已離開聊天", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "下載已啟動", + "webDownloadPermissionMessage": "如果你的瀏覽器阻止下載,請為此網站啟用下載。", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "您的練習進度將不會被保存。", + "practiceGrammar": "練習語法", + "notEnoughToPractice": "發送更多消息以解鎖練習", + "constructUseCorGCDesc": "正確語法類別練習", + "constructUseIncGCDesc": "不正確語法類別練習", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "正確語法錯誤練習", + "constructUseIncGEDesc": "不正確語法錯誤練習", + "fillInBlank": "用正確的選擇填空", + "learn": "學習", + "languageUpdated": "目標語言已更新!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Bot 聲音", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "你的請求已經發送給課程管理員!如果他們批准,你將被允許進入。", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "你有邀請碼或公共課程的鏈接嗎?", + "welcomeUser": "歡迎 {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "搜尋用戶以邀請他們加入此聊天。", + "publicInviteDescSpace": "搜尋用戶以邀請他們加入此空間。", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat 係一個短信應用程式,所以通知非常重要!", + "enableNotificationsDesc": "允許通知", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "使用活動圖片作為聊天背景", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "與支援聊天", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "默認情況下,課程是公開可搜索的,並且需要管理員批准才能加入。您可以隨時編輯這些設置。", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "你正在學習什麼語言?", + "searchLanguagesHint": "搜尋目標語言", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "有問題嗎?我們在這裡幫助你!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "發生了一些問題,我們正在努力修復。稍後再檢查。", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "啟用寫作輔助", + "autoIGCToolDescription": "自動運行 Pangea Chat 工具以將發送的消息更正為目標語言。", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "錄音失敗。請檢查您的音頻權限並重試。", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "成語", + "grammarCopyPOSphrasalv": "短語動詞", + "grammarCopyPOScompn": "複合詞", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index 401c2b283..e141c5b19 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -1,6 +1,6 @@ { "@@locale": "zh", - "@@last_modified": "2026-01-07 14:27:59.550670", + "@@last_modified": "2026-02-05 10:10:07.531332", "about": "关于", "@about": { "type": "String", @@ -3788,8 +3788,6 @@ "noPaymentInfo": "无需支付信息!", "updatePhoneOS": "您可能需要更新设备的操作系统版本。", "wordsPerMinute": "每分钟字数", - "autoIGCToolName": "自动运行Pangea写作辅助", - "autoIGCToolDescription": "在发送消息前自动运行Pangea聊天语法和翻译写作辅助。", "tooltipInstructionsTitle": "不确定它的作用吗?", "tooltipInstructionsMobileBody": "长按项目以查看工具提示。", "tooltipInstructionsBrowserBody": "将鼠标悬停在项目上以查看工具提示。", @@ -4417,7 +4415,6 @@ "numModules": "{num} 个模块", "coursePlan": "课程计划", "editCourseLater": "您可以稍后编辑模板标题、描述和课程图片。", - "newCourseAccess": "默认情况下,课程是私有的,需要管理员批准才能加入。您可以随时编辑这些设置。", "createCourse": "创建课程", "stats": "统计", "createGroupChat": "创建群聊", @@ -6197,14 +6194,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -8823,10 +8812,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10816,5 +10801,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 你离开了聊天", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "下载已启动", + "webDownloadPermissionMessage": "如果您的浏览器阻止下载,请为此网站启用下载。", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "您的练习会话进度将不会被保存。", + "practiceGrammar": "练习语法", + "notEnoughToPractice": "发送更多消息以解锁练习", + "constructUseCorGCDesc": "正确语法类别练习", + "constructUseIncGCDesc": "错误语法类别练习", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "正确语法错误练习", + "constructUseIncGEDesc": "不正确语法错误练习", + "fillInBlank": "用正确的选项填空", + "learn": "学习", + "languageUpdated": "目标语言已更新!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "潘吉亚机器人声音", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "您的请求已发送给课程管理员!如果他们批准,您将被允许进入。", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "您是否有邀请代码或公共课程的链接?", + "welcomeUser": "欢迎 {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "搜索用户以邀请他们加入此聊天。", + "publicInviteDescSpace": "搜索用户以邀请他们加入此空间。", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat 是一款短信应用,因此通知非常重要!", + "enableNotificationsDesc": "允许通知", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "将活动图像用作聊天背景", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "与支持聊天", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "默认情况下,课程是公开可搜索的,并且需要管理员批准才能加入。您可以随时编辑这些设置。", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "你正在学习什么语言?", + "searchLanguagesHint": "搜索目标语言", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "有问题吗?我们在这里帮助您!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "出现了一些问题,我们正在努力修复。请稍后再检查。", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "启用写作辅助", + "autoIGCToolDescription": "自动运行 Pangea Chat 工具以将发送的消息纠正为目标语言。", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "录音失败。请检查您的音频权限并重试。", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "成语", + "grammarCopyPOSphrasalv": "短语动词", + "grammarCopyPOScompn": "复合词", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_zh_Hant.arb b/lib/l10n/intl_zh_Hant.arb index 3ee19351a..0aea18e73 100644 --- a/lib/l10n/intl_zh_Hant.arb +++ b/lib/l10n/intl_zh_Hant.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-01-07 14:26:51.046421", + "@@last_modified": "2026-02-05 10:09:52.100652", "about": "關於", "@about": { "type": "String", @@ -3764,8 +3764,6 @@ "noPaymentInfo": "無需付款資訊!", "updatePhoneOS": "您可能需要更新設備的作業系統版本。", "wordsPerMinute": "每分鐘字數", - "autoIGCToolName": "自動運行Pangea寫作協助", - "autoIGCToolDescription": "在發送訊息前,自動運行Pangea聊天語法和翻譯寫作協助。", "tooltipInstructionsTitle": "不確定這是做什麼的嗎?", "tooltipInstructionsMobileBody": "長按項目以查看工具提示。", "tooltipInstructionsBrowserBody": "將滑鼠懸停在項目上以查看工具提示。", @@ -4393,7 +4391,6 @@ "numModules": "{num} 模組", "coursePlan": "課程計劃", "editCourseLater": "您可以稍後編輯課程標題、描述和課程圖片。", - "newCourseAccess": "默認情況下,課程是私有的,需要管理員批准才能加入。您可以隨時編輯這些設置。", "createCourse": "創建課程", "stats": "統計數據", "createGroupChat": "創建群聊", @@ -6221,14 +6218,6 @@ "type": "String", "placeholders": {} }, - "@autoIGCToolName": { - "type": "String", - "placeholders": {} - }, - "@autoIGCToolDescription": { - "type": "String", - "placeholders": {} - }, "@tooltipInstructionsTitle": { "type": "String", "placeholders": {} @@ -8847,10 +8836,6 @@ "type": "String", "placeholders": {} }, - "@newCourseAccess": { - "type": "String", - "placeholders": {} - }, "@createCourse": { "type": "String", "placeholders": {} @@ -10823,5 +10808,179 @@ "@voice": { "type": "String", "placeholders": {} + }, + "youLeftTheChat": "🚪 你已離開聊天", + "@youLeftTheChat": { + "type": "String", + "placeholders": {} + }, + "downloadInitiated": "下載已啟動", + "webDownloadPermissionMessage": "如果您的瀏覽器阻止下載,請為此網站啟用下載。", + "@downloadInitiated": { + "type": "String", + "placeholders": {} + }, + "@webDownloadPermissionMessage": { + "type": "String", + "placeholders": {} + }, + "exitPractice": "您的練習進度將不會被保存。", + "practiceGrammar": "練習文法", + "notEnoughToPractice": "發送更多訊息以解鎖練習", + "constructUseCorGCDesc": "正確文法類別練習", + "constructUseIncGCDesc": "不正確文法類別練習", + "@exitPractice": { + "type": "String", + "placeholders": {} + }, + "@practiceGrammar": { + "type": "String", + "placeholders": {} + }, + "@notEnoughToPractice": { + "type": "String", + "placeholders": {} + }, + "@constructUseCorGCDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGCDesc": { + "type": "String", + "placeholders": {} + }, + "constructUseCorGEDesc": "正確語法錯誤練習", + "constructUseIncGEDesc": "不正確語法錯誤練習", + "fillInBlank": "用正確的選擇填空", + "learn": "學習", + "languageUpdated": "目標語言已更新!", + "@constructUseCorGEDesc": { + "type": "String", + "placeholders": {} + }, + "@constructUseIncGEDesc": { + "type": "String", + "placeholders": {} + }, + "@fillInBlank": { + "type": "String", + "placeholders": {} + }, + "@learn": { + "type": "String", + "placeholders": {} + }, + "@languageUpdated": { + "type": "String", + "placeholders": {} + }, + "voiceDropdownTitle": "Pangea Bot 語音", + "@voiceDropdownTitle": { + "type": "String", + "placeholders": {} + }, + "knockDesc": "您的請求已發送給課程管理員!如果他們批准,您將被允許進入。", + "@knockDesc": { + "type": "String", + "placeholders": {} + }, + "joinSpaceOnboardingDesc": "您是否有邀請碼或公共課程的鏈接?", + "welcomeUser": "歡迎 {user}", + "@joinSpaceOnboardingDesc": { + "type": "String", + "placeholders": {} + }, + "@welcomeUser": { + "type": "String", + "placeholders": { + "user": { + "type": "String" + } + } + }, + "publicInviteDescChat": "搜尋用戶以邀請他們加入此聊天。", + "publicInviteDescSpace": "搜尋用戶以邀請他們加入此空間。", + "@publicInviteDescChat": { + "type": "String", + "placeholders": {} + }, + "@publicInviteDescSpace": { + "type": "String", + "placeholders": {} + }, + "enableNotificationsTitle": "Pangea Chat 是一個即時通訊應用程式,因此通知非常重要!", + "enableNotificationsDesc": "允許通知", + "@enableNotificationsTitle": { + "type": "String", + "placeholders": {} + }, + "@enableNotificationsDesc": { + "type": "String", + "placeholders": {} + }, + "useActivityImageAsChatBackground": "使用活動圖片作為聊天背景", + "@useActivityImageAsChatBackground": { + "type": "String", + "placeholders": {} + }, + "chatWithSupport": "與支援聊天", + "@chatWithSupport": { + "type": "String", + "placeholders": {} + }, + "newCourseAccess": "預設情況下,課程是公開可搜尋的,並且需要管理員批准才能加入。您可以隨時編輯這些設置。", + "@newCourseAccess": { + "type": "String", + "placeholders": {} + }, + "onboardingLanguagesTitle": "你正在學習什麼語言?", + "searchLanguagesHint": "搜尋目標語言", + "@onboardingLanguagesTitle": { + "type": "String", + "placeholders": {} + }, + "@searchLanguagesHint": { + "type": "String", + "placeholders": {} + }, + "supportSubtitle": "有問題嗎?我們在這裡幫助您!", + "@supportSubtitle": { + "type": "String", + "placeholders": {} + }, + "courseLoadingError": "發生了一些問題,我們正在努力修復。稍後再檢查。", + "@courseLoadingError": { + "type": "String", + "placeholders": {} + }, + "autoIGCToolName": "啟用寫作輔助", + "autoIGCToolDescription": "自動運行 Pangea Chat 工具以將發送的消息更正為目標語言。", + "@autoIGCToolName": { + "type": "String", + "placeholders": {} + }, + "@autoIGCToolDescription": { + "type": "String", + "placeholders": {} + }, + "emptyAudioError": "錄音失敗。請檢查您的音頻權限並重試。", + "@emptyAudioError": { + "type": "String", + "placeholders": {} + }, + "grammarCopyPOSidiom": "成語", + "grammarCopyPOSphrasalv": "片語動詞", + "grammarCopyPOScompn": "合成詞", + "@grammarCopyPOSidiom": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOSphrasalv": { + "type": "String", + "placeholders": {} + }, + "@grammarCopyPOScompn": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 38dc0583e..86d333ca1 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -32,7 +32,6 @@ import 'package:fluffychat/pangea/activity_sessions/activity_session_chat/activi import 'package:fluffychat/pangea/activity_sessions/activity_session_chat/activity_chat_extension.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_update_dispatcher.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.dart'; -import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_banner.dart'; @@ -191,9 +190,12 @@ class ChatController extends State StreamSubscription? _levelSubscription; StreamSubscription? _constructsSubscription; + StreamSubscription? _tokensSubscription; + StreamSubscription? _botAudioSubscription; final timelineUpdateNotifier = _TimelineUpdateNotifier(); late final ActivityChatController activityController; + final ValueNotifier scrollableNotifier = ValueNotifier(false); // Pangea# Room get room => sendingClient.getRoomById(roomId) ?? widget.room; @@ -251,9 +253,14 @@ class ChatController extends State final Set unfolded = {}; - Event? replyEvent; + // #Pangea + // Event? replyEvent; - Event? editEvent; + // Event? editEvent; + + ValueNotifier replyEvent = ValueNotifier(null); + ValueNotifier editEvent = ValueNotifier(null); + // Pangea# bool _scrolledUp = false; @@ -477,6 +484,11 @@ class ChatController extends State ); } + void _onTokenUpdate(Set constructs) { + if (constructs.isEmpty) return; + TokensUtil.clearNewTokenCache(); + } + Future _botAudioListener(SyncUpdate update) async { if (update.rooms?.join?[roomId]?.timeline?.events == null) return; final timeline = update.rooms!.join![roomId]!.timeline!; @@ -490,6 +502,8 @@ class ChatController extends State if (botAudioEvent == null) return; final matrix = Matrix.of(context); + if (matrix.voiceMessageEventId.value != null) return; + matrix.voiceMessageEventId.value = botAudioEvent.eventId; matrix.audioPlayer?.dispose(); matrix.audioPlayer = AudioPlayer(); @@ -527,6 +541,9 @@ class ChatController extends State _constructsSubscription = updater.unlockedConstructsStream.stream.listen(_onUnlockConstructs); + _tokensSubscription = + updater.newConstructsStream.stream.listen(_onTokenUpdate); + _botAudioSubscription = room.client.onSync.stream.listen(_botAudioListener); activityController = ActivityChatController( @@ -790,11 +807,13 @@ class ChatController extends State _levelSubscription?.cancel(); _botAudioSubscription?.cancel(); _constructsSubscription?.cancel(); + _tokensSubscription?.cancel(); _router.routeInformationProvider.removeListener(_onRouteChanged); choreographer.timesDismissedIT.removeListener(_onCloseIT); scrollController.dispose(); inputFocus.dispose(); depressMessageButton.dispose(); + scrollableNotifier.dispose(); TokensUtil.clearNewTokenCache(); //Pangea# super.dispose(); @@ -869,7 +888,6 @@ class ChatController extends State Future sendFakeMessage(Event? edit, Event? reply) async { if (sendController.text.trim().isEmpty) return null; final message = sendController.text; - inputFocus.unfocus(); sendController.setSystemText("", EditTypeEnum.other); return room.sendFakeMessage( @@ -885,13 +903,17 @@ class ChatController extends State // Also, adding PangeaMessageData Future send() async { final message = sendController.text; - final edit = editEvent; - final reply = replyEvent; - editEvent = null; - replyEvent = null; + final edit = editEvent.value; + final reply = replyEvent.value; + editEvent.value = null; + replyEvent.value = null; pendingText = ''; final tempEventId = await sendFakeMessage(edit, reply); + if (!inputFocus.hasFocus) { + inputFocus.requestFocus(); + } + final content = await choreographer.getMessageContent(message); choreographer.clear(); @@ -939,7 +961,10 @@ class ChatController extends State sendController.setSystemText("", EditTypeEnum.other); } - final previousEdit = editEvent; + final previousEdit = edit; + if (showEmojiPicker) { + hideEmojiPicker(); + } room .pangeaSendTextEvent( @@ -991,8 +1016,8 @@ class ChatController extends State data: { 'roomId': roomId, 'text': message, - 'inReplyTo': replyEvent?.eventId, - 'editEventId': editEvent?.eventId, + 'inReplyTo': reply?.eventId, + 'editEventId': edit?.eventId, }, ); return; @@ -1012,8 +1037,8 @@ class ChatController extends State data: { 'roomId': roomId, 'text': message, - 'inReplyTo': replyEvent?.eventId, - 'editEventId': editEvent?.eventId, + 'inReplyTo': reply?.eventId, + 'editEventId': edit?.eventId, }, ); }); @@ -1141,8 +1166,8 @@ class ChatController extends State ); // #Pangea - final reply = replyEvent; - replyEvent = null; + final reply = replyEvent.value; + replyEvent.value = null; // Pangea# await room @@ -1510,7 +1535,7 @@ class ChatController extends State void replyAction({Event? replyTo}) { // #Pangea - replyEvent = replyTo ?? selectedEvents.first; + replyEvent.value = replyTo ?? selectedEvents.first; clearSelectedEvents(); // setState(() { // replyEvent = replyTo ?? selectedEvents.first; @@ -1668,9 +1693,9 @@ class ChatController extends State // selectedEvents.clear(); // }); pendingText = sendController.text; - editEvent = selectedEvents.first; + editEvent.value = selectedEvents.first; sendController.text = - editEvent!.getDisplayEvent(timeline!).calcLocalizedBodyFallback( + editEvent.value!.getDisplayEvent(timeline!).calcLocalizedBodyFallback( MatrixLocals(L10n.of(context)), withSenderNamePrefix: false, hideReply: true, @@ -1952,15 +1977,17 @@ class ChatController extends State } void cancelReplyEventAction() => setState(() { - if (editEvent != null) { - // #Pangea - // sendController.text = pendingText; - sendController.setSystemText(pendingText, EditTypeEnum.other); - // Pangea# - pendingText = ''; - } - replyEvent = null; - editEvent = null; + // #Pangea + // sendController.text = pendingText; + sendController.setSystemText(pendingText, EditTypeEnum.other); + // Pangea# + pendingText = ''; + // #Pangea + // replyEvent = null; + // editEvent = null; + replyEvent.value = null; + editEvent.value = null; + // Pangea# }); // #Pangea ValueNotifier depressMessageButton = ValueNotifier(false); @@ -1997,17 +2024,6 @@ class ChatController extends State bool get _isToolbarOpen => MatrixState.pAnyState.isOverlayOpen(RegExp(r'^message_toolbar_overlay$')); - bool showMessageShimmer(Event event) { - if (event.type != EventTypes.Message) return false; - if (event.messageType == MessageTypes.Text) { - return !InstructionsEnum.clickTextMessages.isToggledOff; - } - if (event.messageType == MessageTypes.Audio) { - return !InstructionsEnum.clickAudioMessages.isToggledOff; - } - return false; - } - void showToolbar( Event event, { PangeaMessageEvent? pangeaMessageEvent, @@ -2015,14 +2031,9 @@ class ChatController extends State MessagePracticeMode? mode, Event? nextEvent, Event? prevEvent, - }) { + }) async { if (event.redacted || event.status == EventStatus.sending) return; - // Close keyboard, if open - if (inputFocus.hasFocus && PlatformInfos.isMobile) { - inputFocus.unfocus(); - return; - } // Close emoji picker, if open if (showEmojiPicker) { hideEmojiPicker(); @@ -2035,31 +2046,6 @@ class ChatController extends State return; } - final langCode = - pangeaMessageEvent?.originalSent?.langCode.split('-').first; - - if (LanguageMismatchRepo.shouldShowByEvent(event.eventId) && - langCode != null && - pangeaMessageEvent?.originalSent?.content.langCodeMatchesL2 == false && - room.client.allMyAnalyticsRooms.any((r) => r.madeForLang == langCode)) { - LanguageMismatchRepo.setEvent(event.eventId); - OverlayUtil.showLanguageMismatchPopup( - context: context, - targetId: event.eventId, - message: L10n.of(context).messageLanguageMismatchMessage, - targetLanguage: pangeaMessageEvent!.originalSent!.langCode, - onConfirm: () => showToolbar( - event, - pangeaMessageEvent: pangeaMessageEvent, - selectedToken: selectedToken, - mode: mode, - nextEvent: nextEvent, - prevEvent: prevEvent, - ), - ); - return; - } - final overlayEntry = MessageSelectionOverlay( chatController: this, event: event, @@ -2070,14 +2056,8 @@ class ChatController extends State ); // you've clicked a message so lets turn this off - InstructionsEnum.clickMessage.setToggledOff(true); - if (event.messageType == MessageTypes.Text && - !InstructionsEnum.clickTextMessages.isToggledOff) { - InstructionsEnum.clickTextMessages.setToggledOff(true); - } - if (event.messageType == MessageTypes.Audio && - !InstructionsEnum.clickAudioMessages.isToggledOff) { - InstructionsEnum.clickAudioMessages.setToggledOff(true); + if (!InstructionsEnum.clickMessage.isToggledOff) { + InstructionsEnum.clickMessage.setToggledOff(true); } if (!kIsWeb) { @@ -2085,8 +2065,24 @@ class ChatController extends State } stopMediaStream.add(null); - if (buttonEventID == event.eventId) { + final isButton = buttonEventID == event.eventId; + final keyboardOpen = inputFocus.hasFocus && PlatformInfos.isMobile; + + final delay = keyboardOpen + ? const Duration(milliseconds: 500) + : isButton + ? const Duration(milliseconds: 200) + : null; + + if (isButton) { depressMessageButton.value = true; + } + + if (keyboardOpen) { + inputFocus.unfocus(); + } + + if (delay != null) { OverlayUtil.showOverlay( context: context, child: TransparentBackdrop( @@ -2094,28 +2090,28 @@ class ChatController extends State onDismiss: clearSelectedEvents, blurBackground: true, animateBackground: true, - backgroundAnimationDuration: const Duration(milliseconds: 200), + backgroundAnimationDuration: delay, ), position: OverlayPositionEnum.centered, overlayKey: "button_message_backdrop", ); - Future.delayed(const Duration(milliseconds: 200), () { - if (_router.state.path != ':roomid') { - // The user has navigated away from the chat, - // so we don't want to show the overlay. - return; - } - OverlayUtil.showOverlay( - context: context, - child: overlayEntry, - position: OverlayPositionEnum.centered, - onDismiss: clearSelectedEvents, - blurBackground: true, - backgroundColor: Colors.black, - overlayKey: "message_toolbar_overlay", - ); - }); + await Future.delayed(delay); + + if (_router.state.path != ':roomid') { + // The user has navigated away from the chat, + // so we don't want to show the overlay. + return; + } + OverlayUtil.showOverlay( + context: context, + child: overlayEntry, + position: OverlayPositionEnum.centered, + onDismiss: clearSelectedEvents, + blurBackground: true, + backgroundColor: Colors.black, + overlayKey: "message_toolbar_overlay", + ); } else { OverlayUtil.showOverlay( context: context, @@ -2252,7 +2248,7 @@ class ChatController extends State bool autosend = false, }) async { if (shouldShowLanguageMismatchPopupByActivity) { - return showLanguageMismatchPopup(); + return showLanguageMismatchPopup(manual: manual); } await choreographer.requestWritingAssistance(manual: manual); @@ -2265,7 +2261,7 @@ class ChatController extends State } } - void showLanguageMismatchPopup() { + void showLanguageMismatchPopup({bool manual = false}) { if (!shouldShowLanguageMismatchPopupByActivity) { return; } @@ -2278,11 +2274,41 @@ class ChatController extends State message: L10n.of(context).languageMismatchDesc, targetLanguage: targetLanguage, onConfirm: () => WidgetsBinding.instance.addPostFrameCallback( - (_) => onRequestWritingAssistance(manual: false, autosend: true), + (_) => onRequestWritingAssistance(manual: manual, autosend: true), ), ); } + Future updateLanguageOnMismatch(String target) async { + final messenger = ScaffoldMessenger.of(context); + messenger.hideCurrentSnackBar(); + final resp = await showFutureLoadingDialog( + context: context, + future: () async { + clearSelectedEvents(); + await MatrixState.pangeaController.userController.updateProfile( + (profile) { + profile.userSettings.targetLanguage = target; + return profile; + }, + waitForDataInSync: true, + ); + }, + ); + if (resp.isError) return; + if (mounted) { + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Text( + L10n.of(context).languageUpdated, + textAlign: TextAlign.center, + ), + ), + ); + } + } + void _onCloseIT() { if (choreographer.timesDismissedIT.value >= 3) { showDisableLanguageToolsPopup(); @@ -2403,6 +2429,8 @@ class ChatController extends State ); if (reason == null) return; + + clearSelectedEvents(); await showFutureLoadingDialog( context: context, future: () => room.sendEvent( diff --git a/lib/pages/chat/chat_emoji_picker.dart b/lib/pages/chat/chat_emoji_picker.dart index d154b0839..734d082e4 100644 --- a/lib/pages/chat/chat_emoji_picker.dart +++ b/lib/pages/chat/chat_emoji_picker.dart @@ -49,8 +49,19 @@ class ChatEmojiPicker extends StatelessWidget { backgroundColor: theme.colorScheme.onInverseSurface, ), - bottomActionBarConfig: const BottomActionBarConfig( - enabled: false, + bottomActionBarConfig: BottomActionBarConfig( + // #Pangea + // enabled: false, + showBackspaceButton: false, + backgroundColor: Theme.of(context) + .colorScheme + .surfaceContainer, + buttonColor: Theme.of(context) + .colorScheme + .surfaceContainer, + buttonIconColor: + Theme.of(context).colorScheme.onSurface, + // Pangea# ), categoryViewConfig: CategoryViewConfig( backspaceColor: theme.colorScheme.primary, @@ -68,6 +79,17 @@ class ChatEmojiPicker extends StatelessWidget { )!, indicatorColor: theme.colorScheme.onSurface, ), + // #Pangea + viewOrderConfig: const ViewOrderConfig( + middle: EmojiPickerItem.searchBar, + top: EmojiPickerItem.categoryBar, + bottom: EmojiPickerItem.emojiView, + ), + searchViewConfig: SearchViewConfig( + backgroundColor: theme.colorScheme.surface, + buttonIconColor: theme.colorScheme.onSurface, + ), + // Pangea# ), ), // #Pangea diff --git a/lib/pages/chat/chat_event_list.dart b/lib/pages/chat/chat_event_list.dart index 4fa2a92a3..990469e8f 100644 --- a/lib/pages/chat/chat_event_list.dart +++ b/lib/pages/chat/chat_event_list.dart @@ -51,147 +51,160 @@ class ChatEventList extends StatelessWidget { controller.room.client.applicationAccountConfig.wallpaperUrl != null; return SelectionArea( - child: ListView.custom( - padding: EdgeInsets.only( - top: 16, - bottom: 8, - left: horizontalPadding, - right: horizontalPadding, - ), - reverse: true, - controller: controller.scrollController, - keyboardDismissBehavior: PlatformInfos.isIOS - ? ScrollViewKeyboardDismissBehavior.onDrag - : ScrollViewKeyboardDismissBehavior.manual, - childrenDelegate: SliverChildBuilderDelegate( - (BuildContext context, int i) { - // Footer to display typing indicator and read receipts: - if (i == 0) { - if (timeline.isRequestingFuture) { - return const Center( - child: CircularProgressIndicator.adaptive(strokeWidth: 2), - ); - } - if (timeline.canRequestFuture) { - return Center( - child: IconButton( - onPressed: controller.requestFuture, - icon: const Icon(Icons.refresh_outlined), - ), - ); - } - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - SeenByRow(controller), - TypingIndicators(controller), - ], - ); - } - - // Request history button or progress indicator: - // #Pangea - // if (i == events.length + 1) { - if (i == events.length + 2) { - // Pangea# - if (timeline.isRequestingHistory) { - return const Center( - child: CircularProgressIndicator.adaptive(strokeWidth: 2), - ); - } - if (timeline.canRequestHistory) { - return Builder( - builder: (context) { - // #Pangea - // WidgetsBinding.instance - // .addPostFrameCallback(controller.requestHistory); - WidgetsBinding.instance.addPostFrameCallback( - (_) => controller.requestHistory(), - ); - // Pangea# - return Center( - child: IconButton( - onPressed: controller.requestHistory, - icon: const Icon(Icons.refresh_outlined), - ), - ); - }, - ); - } - return const SizedBox.shrink(); - } - - // #Pangea - if (i == 1) { - return ActivityUserSummaries(controller: controller); - } - // Pangea# - - // #Pangea - // i--; - i = i - 2; - // Pangea# - - // The message at this index: - final event = events[i]; - final animateIn = animateInEventIndex != null && - timeline.events.length > animateInEventIndex && - event == timeline.events[animateInEventIndex]; - - return AutoScrollTag( - key: ValueKey(event.eventId), - index: i, - controller: controller.scrollController, - child: Message( - event, - animateIn: animateIn, - resetAnimateIn: () { - controller.animateInEventIndex = null; - }, - onSwipe: () => controller.replyAction(replyTo: event), - // #Pangea - onInfoTab: (_) => {}, - // onInfoTab: controller.showEventInfo, - // Pangea# - onMention: () => controller.sendController.text += - '${event.senderFromMemoryOrFallback.mention} ', - highlightMarker: - controller.scrollToEventIdMarker == event.eventId, - // #Pangea - // onSelect: controller.onSelectMessage, - onSelect: (_) {}, - // Pangea# - scrollToEventId: (String eventId) => - controller.scrollToEventId(eventId), - longPressSelect: controller.selectedEvents.isNotEmpty, - // #Pangea - controller: controller, - isButton: event.eventId == controller.buttonEventID, - canRefresh: event.eventId == controller.refreshEventID, - // Pangea# - selected: controller.selectedEvents - .any((e) => e.eventId == event.eventId), - singleSelected: - controller.selectedEvents.singleOrNull?.eventId == - event.eventId, - onEdit: () => controller.editSelectedEventAction(), - timeline: timeline, - displayReadMarker: - i > 0 && controller.readMarkerEventId == event.eventId, - nextEvent: i + 1 < events.length ? events[i + 1] : null, - previousEvent: i > 0 ? events[i - 1] : null, - wallpaperMode: hasWallpaper, - scrollController: controller.scrollController, - colors: colors, - ), - ); - }, - // #Pangea - // childCount: events.length + 2, - childCount: events.length + 3, + // #Pangea + // child: ListView.custom( + child: NotificationListener( + onNotification: (_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final scrollable = + controller.scrollController.position.maxScrollExtent > 0; + controller.scrollableNotifier.value = scrollable; + }); + return true; + }, + child: ListView.custom( // Pangea# - findChildIndexCallback: (key) => - controller.findChildIndexCallback(key, thisEventsKeyMap), + padding: EdgeInsets.only( + top: 16, + bottom: 8, + left: horizontalPadding, + right: horizontalPadding, + ), + reverse: true, + controller: controller.scrollController, + keyboardDismissBehavior: PlatformInfos.isIOS + ? ScrollViewKeyboardDismissBehavior.onDrag + : ScrollViewKeyboardDismissBehavior.manual, + childrenDelegate: SliverChildBuilderDelegate( + (BuildContext context, int i) { + // Footer to display typing indicator and read receipts: + if (i == 0) { + if (timeline.isRequestingFuture) { + return const Center( + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ); + } + if (timeline.canRequestFuture) { + return Center( + child: IconButton( + onPressed: controller.requestFuture, + icon: const Icon(Icons.refresh_outlined), + ), + ); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SeenByRow(controller), + TypingIndicators(controller), + ], + ); + } + + // Request history button or progress indicator: + // #Pangea + // if (i == events.length + 1) { + if (i == events.length + 2) { + // Pangea# + if (timeline.isRequestingHistory) { + return const Center( + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ); + } + if (timeline.canRequestHistory) { + return Builder( + builder: (context) { + // #Pangea + // WidgetsBinding.instance + // .addPostFrameCallback(controller.requestHistory); + WidgetsBinding.instance.addPostFrameCallback( + (_) => controller.requestHistory(), + ); + // Pangea# + return Center( + child: IconButton( + onPressed: controller.requestHistory, + icon: const Icon(Icons.refresh_outlined), + ), + ); + }, + ); + } + return const SizedBox.shrink(); + } + + // #Pangea + if (i == 1) { + return ActivityUserSummaries(controller: controller); + } + // Pangea# + + // #Pangea + // i--; + i = i - 2; + // Pangea# + + // The message at this index: + final event = events[i]; + final animateIn = animateInEventIndex != null && + timeline.events.length > animateInEventIndex && + event == timeline.events[animateInEventIndex]; + + return AutoScrollTag( + key: ValueKey(event.eventId), + index: i, + controller: controller.scrollController, + child: Message( + event, + animateIn: animateIn, + resetAnimateIn: () { + controller.animateInEventIndex = null; + }, + onSwipe: () => controller.replyAction(replyTo: event), + // #Pangea + onInfoTab: (_) => {}, + // onInfoTab: controller.showEventInfo, + // Pangea# + onMention: () => controller.sendController.text += + '${event.senderFromMemoryOrFallback.mention} ', + highlightMarker: + controller.scrollToEventIdMarker == event.eventId, + // #Pangea + // onSelect: controller.onSelectMessage, + onSelect: (_) {}, + // Pangea# + scrollToEventId: (String eventId) => + controller.scrollToEventId(eventId), + longPressSelect: controller.selectedEvents.isNotEmpty, + // #Pangea + controller: controller, + isButton: event.eventId == controller.buttonEventID, + canRefresh: event.eventId == controller.refreshEventID, + // Pangea# + selected: controller.selectedEvents + .any((e) => e.eventId == event.eventId), + singleSelected: + controller.selectedEvents.singleOrNull?.eventId == + event.eventId, + onEdit: () => controller.editSelectedEventAction(), + timeline: timeline, + displayReadMarker: + i > 0 && controller.readMarkerEventId == event.eventId, + nextEvent: i + 1 < events.length ? events[i + 1] : null, + previousEvent: i > 0 ? events[i - 1] : null, + wallpaperMode: hasWallpaper, + scrollController: controller.scrollController, + colors: colors, + ), + ); + }, + // #Pangea + // childCount: events.length + 2, + childCount: events.length + 3, + // Pangea# + findChildIndexCallback: (key) => + controller.findChildIndexCallback(key, thisEventsKeyMap), + ), ), ), ); diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index 1d83d673d..c698dacbf 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -6,6 +6,7 @@ import 'package:badges/badges.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/chat/chat.dart'; @@ -24,6 +25,7 @@ import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart' import 'package:fluffychat/pangea/chat/widgets/chat_floating_action_button.dart'; import 'package:fluffychat/pangea/chat/widgets/chat_input_bar.dart'; import 'package:fluffychat/pangea/chat/widgets/chat_view_background.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/navigation/navigation_util.dart'; import 'package:fluffychat/utils/account_config.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; @@ -357,7 +359,55 @@ class ChatView extends StatelessWidget { child: Stack( // Pangea# children: [ - if (accountConfig.wallpaperUrl != null) + // #Pangea + // if (accountConfig.wallpaperUrl != null) + // Only use activity image as chat background if enabled in AppConfig + if (controller.room.activityPlan != null && + controller.room.activityPlan!.imageURL != null && + AppConfig.useActivityImageAsChatBackground) + Opacity( + opacity: 0.25, + child: ImageFiltered( + imageFilter: ui.ImageFilter.blur( + sigmaX: accountConfig.wallpaperBlur ?? 0.0, + sigmaY: accountConfig.wallpaperBlur ?? 0.0, + ), + child: controller.room.activityPlan!.imageURL! + .toString() + .startsWith('mxc') + ? MxcImage( + uri: controller.room.activityPlan!.imageURL!, + fit: BoxFit.cover, + height: MediaQuery.sizeOf(context).height, + width: MediaQuery.sizeOf(context).width, + cacheKey: controller + .room.activityPlan!.imageURL + .toString(), + isThumbnail: false, + ) + : Image.network( + controller.room.activityPlan!.imageURL + .toString(), + fit: BoxFit.cover, + height: MediaQuery.sizeOf(context).height, + width: MediaQuery.sizeOf(context).width, + headers: controller + .room.activityPlan!.imageURL + .toString() + .contains(Environment.cmsApi) + ? { + 'Authorization': + 'Bearer ${MatrixState.pangeaController.userController.accessToken}', + } + : null, + errorBuilder: (context, error, stackTrace) => + Container(), + ), + ), + ) + // If not enabled, fall through to default wallpaper logic + else if (accountConfig.wallpaperUrl != null) + // Pangea# Opacity( opacity: accountConfig.wallpaperOpacity ?? 0.5, child: ImageFiltered( diff --git a/lib/pages/chat/events/audio_player.dart b/lib/pages/chat/events/audio_player.dart index ee6490a5f..b10397e23 100644 --- a/lib/pages/chat/events/audio_player.dart +++ b/lib/pages/chat/events/audio_player.dart @@ -151,6 +151,7 @@ class AudioPlayerState extends State { audioPlayer.pause(); audioPlayer.dispose(); matrix.voiceMessageEventId.value = matrix.audioPlayer = null; + matrix.voiceMessageEventId.removeListener(_onPlayerChange); // #Pangea _onAudioStateChanged?.cancel(); // Pangea# @@ -173,6 +174,14 @@ class AudioPlayerState extends State { if (currentPlayer != null) { // #Pangea currentPlayer.setSpeed(playbackSpeed); + _onAudioStateChanged?.cancel(); + _onAudioStateChanged = + matrix.audioPlayer!.playerStateStream.listen((state) { + if (state.processingState == ProcessingState.completed) { + matrix.audioPlayer!.stop(); + matrix.audioPlayer!.seek(Duration.zero); + } + }); // Pangea# if (currentPlayer.isAtEndPosition) { currentPlayer.seek(Duration.zero); @@ -382,10 +391,26 @@ class AudioPlayerState extends State { return eventWaveForm.map((i) => i > 1024 ? 1024 : i).toList(); } + // #Pangea + void _onPlayerChange() { + if (matrix.audioPlayer == null) return; + _onAudioStateChanged?.cancel(); + _onAudioStateChanged = + matrix.audioPlayer?.playerStateStream.listen((state) { + if (state.processingState == ProcessingState.completed) { + matrix.audioPlayer?.stop(); + matrix.audioPlayer?.seek(Duration.zero); + } + }); + } + // Pangea# + @override void initState() { super.initState(); matrix = Matrix.of(context); + WidgetsBinding.instance.addPostFrameCallback((_) => _onPlayerChange()); + matrix.voiceMessageEventId.addListener(_onPlayerChange); _waveform = _getWaveform(); // #Pangea diff --git a/lib/pages/chat/events/html_message.dart b/lib/pages/chat/events/html_message.dart index 9cddf5570..d09a7b7dd 100644 --- a/lib/pages/chat/events/html_message.dart +++ b/lib/pages/chat/events/html_message.dart @@ -11,8 +11,10 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pangea/common/widgets/shimmer_background.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/instructions/instructions_enum.dart'; import 'package:fluffychat/pangea/toolbar/layout/reading_assistance_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/message_practice_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/token_practice_button.dart'; @@ -20,6 +22,7 @@ import 'package:fluffychat/pangea/toolbar/message_selection_overlay.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance/token_emoji_button.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance/token_rendering_util.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance/tokens_util.dart'; +import 'package:fluffychat/pangea/toolbar/reading_assistance/underline_text_widget.dart'; import 'package:fluffychat/utils/event_checkbox_extension.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; @@ -173,8 +176,9 @@ class HtmlMessage extends StatelessWidget { ); String _addTokenTags() { - final regex = RegExp(r'(<[^>]+>)'); + if (html.contains("]+>)'); final matches = regex.allMatches(html); final List result = []; int lastEnd = 0; @@ -395,17 +399,6 @@ class HtmlMessage extends StatelessWidget { if (!allowedHtmlTags.contains(node.localName)) return const TextSpan(); // #Pangea - final renderer = TokenRenderingUtil( - existingStyle: pangeaMessageEvent != null - ? textStyle.merge( - AppConfig.messageTextStyle( - pangeaMessageEvent!.event, - textColor, - ), - ) - : textStyle, - ); - double fontSize = this.fontSize; if (readingAssistanceMode == ReadingAssistanceMode.practiceMode) { fontSize = (overlayController != null && overlayController!.maxWidth > 600 @@ -414,7 +407,22 @@ class HtmlMessage extends StatelessWidget { this.fontSize; } - final underlineColor = Theme.of(context).colorScheme.primary.withAlpha(200); + final existingStyle = pangeaMessageEvent != null + ? textStyle + .merge( + AppConfig.messageTextStyle( + pangeaMessageEvent!.event, + textColor, + ), + ) + .copyWith(fontSize: fontSize) + : textStyle.copyWith(fontSize: fontSize); + + final renderer = TokenRenderingUtil(); + + final underlineColor = pangeaMessageEvent!.ownMessage + ? ThemeData.dark().colorScheme.primaryContainer.withAlpha(200) + : Theme.of(context).colorScheme.primary.withAlpha(200); final newTokens = pangeaMessageEvent != null && !pangeaMessageEvent!.ownMessage @@ -440,10 +448,17 @@ class HtmlMessage extends StatelessWidget { : false; final isNew = token != null && newTokens.contains(token.text); + final isFirstNewToken = isNew && + controller.buttonEventID == event.eventId && + newTokens.first == token.text; + final showShimmer = + !InstructionsEnum.shimmerNewToken.isToggledOff && isFirstNewToken; + final tokenWidth = renderer.tokenTextWidthForContainer( node.text, Theme.of(context).colorScheme.primary.withAlpha(200), - fontSize: fontSize, + existingStyle, + fontSize, ); return TextSpan( @@ -472,10 +487,7 @@ class HtmlMessage extends StatelessWidget { TokenPracticeButton( token: token, controller: overlayController!.practiceController, - textStyle: renderer.style( - fontSize: fontSize, - underlineColor: underlineColor, - ), + textStyle: existingStyle, width: tokenWidth, textColor: textColor, ), @@ -496,28 +508,25 @@ class HtmlMessage extends StatelessWidget { : null, child: HoverBuilder( builder: (context, hovered) { - return RichText( - textDirection: pangeaMessageEvent?.textDirection, - text: TextSpan( - children: [ - LinkifySpan( - text: node.text.trim(), - style: renderer.style( - fontSize: fontSize, - underlineColor: underlineColor, - selected: selected, - highlighted: highlighted, - isNew: isNew, - practiceMode: readingAssistanceMode == - ReadingAssistanceMode.practiceMode, - hovered: hovered, - ), - linkStyle: linkStyle, - onOpen: (url) => - UrlLauncher(context, url.url) - .launchUrl(), - ), - ], + return ShimmerBackground( + enabled: showShimmer, + borderRadius: BorderRadius.circular(4.0), + child: UnderlineText( + text: node.text.trim(), + style: existingStyle, + linkStyle: linkStyle, + textDirection: + pangeaMessageEvent?.textDirection, + underlineColor: + TokenRenderingUtil.underlineColor( + underlineColor, + selected: selected, + highlighted: highlighted, + isNew: isNew, + practiceMode: readingAssistanceMode == + ReadingAssistanceMode.practiceMode, + hovered: hovered, + ), ), ); }, @@ -669,10 +678,7 @@ class HtmlMessage extends StatelessWidget { // const TextSpan(text: '• '), TextSpan( text: '• ', - style: renderer.style( - underlineColor: underlineColor, - fontSize: fontSize, - ), + style: existingStyle, ), // Pangea# if (node.parent?.localName == 'ol') @@ -681,10 +687,7 @@ class HtmlMessage extends StatelessWidget { '${(node.parent?.nodes.whereType().toList().indexOf(node) ?? 0) + (int.tryParse(node.parent?.attributes['start'] ?? '1') ?? 1)}. ', // #Pangea // style: textStyle, - style: renderer.style( - underlineColor: underlineColor, - fontSize: fontSize, - ), + style: existingStyle, // Pangea# ), if (node.className == 'task-list-item') diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index d3ac2306b..d4ee1d5c1 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -16,9 +16,7 @@ import 'package:fluffychat/pangea/activity_sessions/activity_summary_widget.dart import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/bot/widgets/bot_settings_language_icon.dart'; import 'package:fluffychat/pangea/chat/extensions/custom_room_display_extension.dart'; -import 'package:fluffychat/pangea/chat/widgets/request_regeneration_button.dart'; import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; -import 'package:fluffychat/pangea/common/widgets/shimmer_background.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; @@ -147,6 +145,7 @@ class Message extends StatelessWidget { valueListenable: controller.activityController.showInstructions, builder: (context, show, __) { return ActivitySummary( + inChat: true, activity: event.room.activityPlan!, room: event.room, assignedRoles: event.room.hasArchivedActivity @@ -603,241 +602,223 @@ class Message extends StatelessWidget { child: ValueListenableBuilder( valueListenable: controller .depressMessageButton, - // #Pangea - child: ShimmerBackground( - enabled: controller - .showMessageShimmer( - event, + + child: Container( + decoration: BoxDecoration( + color: noBubble + ? Colors.transparent + : color, + borderRadius: + borderRadius, ), - // Pangea# - child: Container( - decoration: - BoxDecoration( - color: noBubble - ? Colors - .transparent - : color, - borderRadius: - borderRadius, - ), - clipBehavior: - Clip.antiAlias, - // #Pangea - child: - CompositedTransformTarget( - link: MatrixState + clipBehavior: + Clip.antiAlias, + // #Pangea + child: + CompositedTransformTarget( + link: MatrixState + .pAnyState + .layerLinkAndKey( + event.eventId, + ) + .link, + // child: BubbleBackground( + // colors: colors, + // ignore: noBubble || !ownMessage, + // scrollController: scrollController, + // Pangea# + child: Container( + // #Pangea + key: MatrixState .pAnyState .layerLinkAndKey( event.eventId, ) - .link, - // child: BubbleBackground( - // colors: colors, - // ignore: noBubble || !ownMessage, - // scrollController: scrollController, + .key, // Pangea# - child: Container( - // #Pangea - key: MatrixState - .pAnyState - .layerLinkAndKey( - event.eventId, - ) - .key, - // Pangea# - decoration: - BoxDecoration( - borderRadius: - BorderRadius - .circular( - AppConfig - .borderRadius, - ), + decoration: + BoxDecoration( + borderRadius: + BorderRadius + .circular( + AppConfig + .borderRadius, ), - constraints: - const BoxConstraints( - maxWidth: FluffyThemes - .columnWidth * - 1.5, - ), - child: Column( - mainAxisSize: - MainAxisSize - .min, - crossAxisAlignment: - CrossAxisAlignment - .start, - children: [ - if ({ - RelationshipTypes - .reply, - RelationshipTypes - .thread, - }.contains( - event - .relationshipType, - )) - FutureBuilder< - Event?>( - future: event - .getReplyEvent( - timeline, - ), - builder: ( - BuildContext - context, - snapshot, - ) { - final replyEvent = snapshot - .hasData - ? snapshot - .data! - : Event( - eventId: event.relationshipEventId!, - content: { - 'msgtype': 'm.text', - 'body': '...', - }, - // #Pangea - // senderId: event - // .senderId, - senderId: "", - // Pangea# - type: 'm.room.message', - room: event.room, - status: EventStatus.sent, - originServerTs: DateTime.now(), - ); - return Padding( - padding: - const EdgeInsets.only( - left: - 16, - right: - 16, - top: - 8, - ), + ), + constraints: + const BoxConstraints( + maxWidth: FluffyThemes + .columnWidth * + 1.5, + ), + child: Column( + mainAxisSize: + MainAxisSize + .min, + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + if ({ + RelationshipTypes + .reply, + RelationshipTypes + .thread, + }.contains( + event + .relationshipType, + )) + FutureBuilder< + Event?>( + future: event + .getReplyEvent( + timeline, + ), + builder: ( + BuildContext + context, + snapshot, + ) { + final replyEvent = snapshot + .hasData + ? snapshot + .data! + : Event( + eventId: + event.relationshipEventId!, + content: { + 'msgtype': 'm.text', + 'body': '...', + }, + // #Pangea + // senderId: event + // .senderId, + senderId: + "", + // Pangea# + type: + 'm.room.message', + room: + event.room, + status: + EventStatus.sent, + originServerTs: + DateTime.now(), + ); + return Padding( + padding: + const EdgeInsets + .only( + left: + 16, + right: + 16, + top: 8, + ), + child: + Material( + color: Colors + .transparent, + borderRadius: + ReplyContent.borderRadius, child: - Material( - color: - Colors.transparent, + InkWell( borderRadius: ReplyContent.borderRadius, + onTap: () => + scrollToEventId( + replyEvent.eventId, + ), child: - InkWell( - borderRadius: - ReplyContent.borderRadius, - onTap: () => - scrollToEventId( - replyEvent.eventId, - ), + AbsorbPointer( child: - AbsorbPointer( - child: ReplyContent( - replyEvent, - ownMessage: ownMessage, - timeline: timeline, - ), + ReplyContent( + replyEvent, + ownMessage: ownMessage, + timeline: timeline, ), ), ), - ); - }, - ), - MessageContent( - displayEvent, - textColor: - textColor, - linkColor: - linkColor, - onInfoTab: - onInfoTab, - borderRadius: - borderRadius, - timeline: - timeline, - selected: - selected, - // #Pangea - pangeaMessageEvent: - pangeaMessageEvent, - controller: - controller, - nextEvent: - nextEvent, - prevEvent: - previousEvent, - // Pangea# + ), + ); + }, ), - if (event - .hasAggregatedEvents( - timeline, - RelationshipTypes - .edit, - )) - Padding( - padding: - const EdgeInsets - .only( - bottom: - 8.0, - left: - 16.0, - right: - 16.0, - ), - child: Row( - mainAxisSize: - MainAxisSize - .min, - spacing: - 4.0, - children: [ - Icon( - Icons - .edit_outlined, + MessageContent( + displayEvent, + textColor: + textColor, + linkColor: + linkColor, + onInfoTab: + onInfoTab, + borderRadius: + borderRadius, + timeline: + timeline, + selected: + selected, + // #Pangea + pangeaMessageEvent: + pangeaMessageEvent, + controller: + controller, + nextEvent: + nextEvent, + prevEvent: + previousEvent, + // Pangea# + ), + if (event + .hasAggregatedEvents( + timeline, + RelationshipTypes + .edit, + )) + Padding( + padding: + const EdgeInsets + .only( + bottom: 8.0, + left: 16.0, + right: 16.0, + ), + child: Row( + mainAxisSize: + MainAxisSize + .min, + spacing: + 4.0, + children: [ + Icon( + Icons + .edit_outlined, + color: textColor + .withAlpha( + 164, + ), + size: + 14, + ), + Text( + displayEvent + .originServerTs + .localizedTimeShort( + context, + ), + style: + TextStyle( color: textColor.withAlpha( 164, ), - size: - 14, + fontSize: + 11, ), - Text( - displayEvent - .originServerTs - .localizedTimeShort( - context, - ), - style: - TextStyle( - color: - textColor.withAlpha( - 164, - ), - fontSize: - 11, - ), - ), - ], - ), - ) - // #Pangea - else if (canRefresh) - RequestRegenerationButton( - textColor: - textColor, - onPressed: () => - controller - .requestRegeneration( - event - .eventId, - ), + ), + ], ), - // Pangea# - ], - ), + ), + ], ), ), ), diff --git a/lib/pages/chat/events/state_message.dart b/lib/pages/chat/events/state_message.dart index 41f011305..2975c8a06 100644 --- a/lib/pages/chat/events/state_message.dart +++ b/lib/pages/chat/events/state_message.dart @@ -25,9 +25,19 @@ class StateMessage extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), child: Text( - event.calcLocalizedBodyFallback( - MatrixLocals(L10n.of(context)), - ), + // #Pangea + // event.calcLocalizedBodyFallback( + // MatrixLocals(L10n.of(context)), + // ), + (event.type == EventTypes.RoomMember) && + (event.roomMemberChangeType == + RoomMemberChangeType.leave) && + (event.stateKey == event.room.client.userID) + ? L10n.of(context).youLeftTheChat + : event.calcLocalizedBodyFallback( + MatrixLocals(L10n.of(context)), + ), + // Pangea# textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, diff --git a/lib/pages/chat/recording_dialog.dart b/lib/pages/chat/recording_dialog.dart index bf10421aa..501e20663 100644 --- a/lib/pages/chat/recording_dialog.dart +++ b/lib/pages/chat/recording_dialog.dart @@ -19,6 +19,8 @@ import 'events/audio_player.dart'; class PermissionException implements Exception {} +class EmptyAudioException implements Exception {} + class RecordingDialog extends StatefulWidget { const RecordingDialog({ super.key, @@ -35,6 +37,7 @@ class RecordingDialogState extends State { // #Pangea // bool error = false; Object? error; + bool _loading = true; // Pangea# final _audioRecorder = AudioRecorder(); @@ -87,7 +90,13 @@ class RecordingDialogState extends State { path: path ?? '', ); - setState(() => _duration = Duration.zero); + // #Pangea + // setState(() => _duration = Duration.zero); + setState(() { + _duration = Duration.zero; + _loading = false; + }); + // Pangea# _recorderSubscription?.cancel(); _recorderSubscription = Timer.periodic(const Duration(milliseconds: 100), (_) async { @@ -136,6 +145,16 @@ class RecordingDialogState extends State { for (var i = 0; i < amplitudeTimeline.length; i += step) { waveform.add((amplitudeTimeline[i] / 100 * 1024).round()); } + + // #Pangea + if (amplitudeTimeline.isEmpty || amplitudeTimeline.every((e) => e <= 1)) { + if (mounted) { + setState(() => error = EmptyAudioException()); + } + return; + } + // Pangea# + Navigator.of(context, rootNavigator: false).pop( RecordingResult( path: path, @@ -161,7 +180,7 @@ class RecordingDialogState extends State { constraints: const BoxConstraints(maxWidth: 250.0), child: error is PermissionException ? Text(L10n.of(context).recordingPermissionDenied) - : kIsWeb + : kIsWeb && error is! EmptyAudioException ? Text(L10n.of(context).genericWebRecordingError) : Text(error!.toLocalizedString(context)), ) @@ -202,7 +221,16 @@ class RecordingDialogState extends State { const SizedBox(width: 8), SizedBox( width: 48, - child: Text(time), + // #Pangea + // child: Text(time), + child: _loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive(), + ) + : Text(time), + // Pangea# ), ], ); diff --git a/lib/pages/chat/reply_display.dart b/lib/pages/chat/reply_display.dart index c7c9f8a8a..8d2df4b2d 100644 --- a/lib/pages/chat/reply_display.dart +++ b/lib/pages/chat/reply_display.dart @@ -16,35 +16,57 @@ class ReplyDisplay extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return AnimatedContainer( - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - height: controller.editEvent != null || controller.replyEvent != null - ? 56 - : 0, - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - color: theme.colorScheme.onInverseSurface, - ), - child: Row( - children: [ - IconButton( - tooltip: L10n.of(context).close, - icon: const Icon(Icons.close), - onPressed: controller.cancelReplyEventAction, + // #Pangea + return ListenableBuilder( + listenable: + Listenable.merge([controller.replyEvent, controller.editEvent]), + builder: (context, __) { + final editEvent = controller.editEvent.value; + final replyEvent = controller.replyEvent.value; + // Pangea# + return AnimatedContainer( + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + // #Pangea + // height: controller.editEvent != null || controller.replyEvent != null + height: editEvent != null || replyEvent != null + // Pangea# + ? 56 + : 0, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: theme.colorScheme.onInverseSurface, ), - Expanded( - child: controller.replyEvent != null - ? ReplyContent( - controller.replyEvent!, - timeline: controller.timeline!, - ) - : _EditContent( - controller.editEvent?.getDisplayEvent(controller.timeline!), - ), + child: Row( + children: [ + IconButton( + tooltip: L10n.of(context).close, + icon: const Icon(Icons.close), + onPressed: controller.cancelReplyEventAction, + ), + Expanded( + // #Pangea + // child: controller.replyEvent != null + child: replyEvent != null + // Pangea# + ? ReplyContent( + // #Pangea + // controller.replyEvent, + replyEvent, + // Pangea# + timeline: controller.timeline!, + ) + : _EditContent( + // #Pangea + // controller.editEvent?.getDisplayEvent(controller.timeline!), + editEvent?.getDisplayEvent(controller.timeline!), + // Pangea# + ), + ), + ], ), - ], - ), + ); + }, ); } } diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index d0f2d8948..bd2d0c0a2 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -111,9 +111,12 @@ class ChatListController extends State // StreamSubscription? _intentUriStreamSubscription; // Pangea# - ActiveFilter activeFilter = AppConfig.separateChatTypes - ? ActiveFilter.messages - : ActiveFilter.allChats; + // #Pangea + // ActiveFilter activeFilter = AppConfig.separateChatTypes + // ? ActiveFilter.messages + // : ActiveFilter.allChats; + ActiveFilter activeFilter = ActiveFilter.allChats; + // Pangea# // #Pangea String? get activeSpaceId => widget.activeSpaceId; @@ -693,7 +696,6 @@ class ChatListController extends State _roomCapacitySubscription?.cancel(); MatrixState.pangeaController.subscriptionController.subscriptionNotifier .removeListener(_onSubscribe); - SpaceCodeController.codeNotifier.removeListener(_onCacheSpaceCode); //Pangea# scrollController.removeListener(_onScroll); super.dispose(); @@ -1105,14 +1107,8 @@ class ChatListController extends State MatrixState.pangeaController.initControllers(); if (mounted) { SpaceCodeController.joinCachedSpaceCode(context); - SpaceCodeController.codeNotifier.addListener(_onCacheSpaceCode); } } - - void _onCacheSpaceCode() { - if (!mounted) return; - SpaceCodeController.joinCachedSpaceCode(context); - } // Pangea# void setActiveFilter(ActiveFilter filter) { diff --git a/lib/pages/chat_list/chat_list_body.dart b/lib/pages/chat_list/chat_list_body.dart index b8aae527f..7e998777c 100644 --- a/lib/pages/chat_list/chat_list_body.dart +++ b/lib/pages/chat_list/chat_list_body.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; @@ -9,9 +10,12 @@ import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; import 'package:fluffychat/pages/chat_list/dummy_chat_list_item.dart'; import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart'; +import 'package:fluffychat/pangea/chat_list/support_client_extension.dart'; import 'package:fluffychat/pangea/chat_list/widgets/pangea_chat_list_header.dart'; import 'package:fluffychat/pangea/chat_settings/utils/bot_client_extension.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/course_chats/course_chats_page.dart'; +import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/stream_extension.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/public_room_dialog.dart'; @@ -343,6 +347,59 @@ class ChatListViewBody extends StatelessWidget { ), ), ), + if (!client.hasSupportDM && + !InstructionsEnum.dismissSupportChat.isToggledOff && + !controller.isSearchMode) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 1, + ), + child: Material( + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + clipBehavior: Clip.hardEdge, + child: ListTile( + contentPadding: const EdgeInsets.only( + left: 16, + right: 16, + ), + leading: Container( + alignment: Alignment.center, + height: Avatar.defaultSize, + width: Avatar.defaultSize, + child: const Icon( + Symbols.chat_add_on, + size: Avatar.defaultSize - 16, + ), + ), + trailing: IconButton( + icon: const Icon(Icons.close), + onPressed: () => InstructionsEnum.dismissSupportChat + .setToggledOff(true), + ), + title: Text(L10n.of(context).chatWithSupport), + subtitle: Text(L10n.of(context).supportSubtitle), + onTap: () async { + await showFutureLoadingDialog( + context: context, + future: () async { + final roomId = await Matrix.of(context) + .client + .startDirectChat( + Environment.supportUserId, + enableEncryption: false, + ); + context.go('/rooms/$roomId'); + }, + ); + }, + ), + ), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 75.0)), // Pangea# ], ), diff --git a/lib/pages/chat_list/navi_rail_item.dart b/lib/pages/chat_list/navi_rail_item.dart index e90dacba6..19eb929e8 100644 --- a/lib/pages/chat_list/navi_rail_item.dart +++ b/lib/pages/chat_list/navi_rail_item.dart @@ -49,128 +49,135 @@ class NaviRailItem extends StatelessWidget { // Pangea# final icon = isSelected ? selectedIcon ?? this.icon : this.icon; final unreadBadgeFilter = this.unreadBadgeFilter; - return HoverBuilder( - builder: (context, hovered) { - // #Pangea - // return SizedBox( - // height: 72, - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: width - (isColumnMode ? 16.0 : 12.0), - width: width, - // width: FluffyThemes.navRailWidth, - // Pangea# - child: Stack( - children: [ - Positioned( - top: 8, - bottom: 8, - left: 0, - child: AnimatedContainer( - width: isSelected - ? FluffyThemes.isColumnMode(context) - ? 8 - : 4 - : 0, - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - decoration: BoxDecoration( - color: theme.colorScheme.primary, - borderRadius: const BorderRadius.only( - topRight: Radius.circular(90), - bottomRight: Radius.circular(90), + // #Pangea + // return HoverBuilder( + return GestureDetector( + onTap: onTap, + child: HoverBuilder( + // Pangea# + builder: (context, hovered) { + // #Pangea + // return SizedBox( + // height: 72, + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: width - (isColumnMode ? 16.0 : 12.0), + width: width, + // width: FluffyThemes.navRailWidth, + // Pangea# + child: Stack( + children: [ + Positioned( + top: 8, + bottom: 8, + left: 0, + child: AnimatedContainer( + width: isSelected + ? FluffyThemes.isColumnMode(context) + ? 8 + : 4 + : 0, + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: const BorderRadius.only( + topRight: Radius.circular(90), + bottomRight: Radius.circular(90), + ), ), ), ), - ), - Center( - child: AnimatedScale( - scale: hovered ? 1.1 : 1.0, - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - // #Pangea - // child: Material( - // borderRadius: borderRadius, - // color: isSelected - // ? theme.colorScheme.primaryContainer - // : theme.colorScheme.surfaceContainerHigh, - child: UnreadRoomsBadge( - filter: unreadBadgeFilter ?? (_) => false, - badgePosition: BadgePosition.topEnd( - top: 1, - end: isColumnMode ? 8 : 4, - ), - child: Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: backgroundColor ?? - (isSelected - ? theme.colorScheme.primaryContainer - : theme.colorScheme.surfaceContainerHigh), - borderRadius: borderRadius, + Center( + child: AnimatedScale( + scale: hovered ? 1.1 : 1.0, + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + // #Pangea + // child: Material( + // borderRadius: borderRadius, + // color: isSelected + // ? theme.colorScheme.primaryContainer + // : theme.colorScheme.surfaceContainerHigh, + child: UnreadRoomsBadge( + filter: unreadBadgeFilter ?? (_) => false, + badgePosition: BadgePosition.topEnd( + top: 1, + end: isColumnMode ? 8 : 4, ), - margin: EdgeInsets.symmetric( - horizontal: isColumnMode ? 16.0 : 12.0, - vertical: isColumnMode ? 8.0 : 6.0, - ), - child: TooltipVisibility( - visible: !expanded, - // Pangea# - child: Tooltip( - message: toolTip, - child: InkWell( - borderRadius: borderRadius, - onTap: onTap, - // #Pangea - child: icon, - // child: unreadBadgeFilter == null - // ? icon - // : UnreadRoomsBadge( - // filter: unreadBadgeFilter, - // badgePosition: BadgePosition.topEnd( - // top: -12, - // end: -8, - // ), - // child: icon, - // ), - // Pangea# + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: backgroundColor ?? + (isSelected + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHigh), + borderRadius: borderRadius, + ), + margin: EdgeInsets.symmetric( + horizontal: isColumnMode ? 16.0 : 12.0, + vertical: isColumnMode ? 8.0 : 6.0, + ), + child: TooltipVisibility( + visible: !expanded, + // Pangea# + child: Tooltip( + message: toolTip, + child: InkWell( + borderRadius: borderRadius, + // #Pangea + // onTap: onTap, + child: icon, + // child: unreadBadgeFilter == null + // ? icon + // : UnreadRoomsBadge( + // filter: unreadBadgeFilter, + // badgePosition: BadgePosition.topEnd( + // top: -12, + // end: -8, + // ), + // child: icon, + // ), + // Pangea# + ), ), ), ), ), ), ), - ), - ], + ], + ), ), - ), - if (expanded) - Flexible( - child: Padding( - padding: const EdgeInsets.only(right: 16.0), - child: ListTile( - title: Text( - toolTip, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium, - ), - onTap: onTap, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16.0), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 0.0, + if (expanded) + Flexible( + child: Container( + height: width - (isColumnMode ? 16.0 : 12.0), + padding: const EdgeInsets.only(right: 16.0), + child: ListTile( + title: Text( + toolTip, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium, + ), + onTap: onTap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.0), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 0.0, + ), ), ), ), - ), - ], - ); - }, + ], + ); + }, + ), ); } } diff --git a/lib/pages/chat_search/chat_search_page.dart b/lib/pages/chat_search/chat_search_page.dart index 40109d0b6..f4a25e35d 100644 --- a/lib/pages/chat_search/chat_search_page.dart +++ b/lib/pages/chat_search/chat_search_page.dart @@ -76,10 +76,25 @@ class ChatSearchController extends State (result) => ( { 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(); }); } diff --git a/lib/pages/login/login.dart b/lib/pages/login/login.dart index 98e811692..1354566fc 100644 --- a/lib/pages/login/login.dart +++ b/lib/pages/login/login.dart @@ -7,6 +7,7 @@ import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/authentication/p_login.dart'; import 'package:fluffychat/pangea/login/pages/login_options_view.dart'; import 'package:fluffychat/pangea/login/pages/pangea_login_view.dart'; +import 'package:fluffychat/pangea/login/pages/signup.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart'; @@ -290,6 +291,19 @@ class LoginController extends State { obscureText: true, minLines: 1, maxLines: 1, + // #Pangea + validator: (value) { + if (value.isEmpty) { + return L10n.of(context).chooseAStrongPassword; + } + if (value.length < SignupPageController.minPassLength) { + return L10n.of(context).pleaseChooseAtLeastChars( + SignupPageController.minPassLength.toString(), + ); + } + return null; + }, + // Pangea# ); if (password == null) return; final ok = await showOkAlertDialog( diff --git a/lib/pages/new_private_chat/new_private_chat.dart b/lib/pages/new_private_chat/new_private_chat.dart index 543031c9f..4c7984ce2 100644 --- a/lib/pages/new_private_chat/new_private_chat.dart +++ b/lib/pages/new_private_chat/new_private_chat.dart @@ -9,6 +9,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/new_private_chat/new_private_chat_view.dart'; import 'package:fluffychat/pages/new_private_chat/qr_scanner_modal.dart'; +import 'package:fluffychat/pangea/user/user_search_extension.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/fluffy_share.dart'; import 'package:fluffychat/utils/platform_infos.dart'; @@ -52,15 +53,20 @@ class NewPrivateChatController extends State { } Future> _searchUser(String searchTerm) async { - final result = - await Matrix.of(context).client.searchUserDirectory(searchTerm); + // #Pangea + // final result = + // await Matrix.of(context).client.searchUserDirectory(searchTerm); + final result = await Matrix.of(context).client.searchUser(searchTerm); + // Pangea# final profiles = result.results; - if (searchTerm.isValidMatrixId && - searchTerm.sigil == '@' && - !profiles.any((profile) => profile.userId == searchTerm)) { - profiles.add(Profile(userId: searchTerm)); - } + // #Pangea + // if (searchTerm.isValidMatrixId && + // searchTerm.sigil == '@' && + // !profiles.any((profile) => profile.userId == searchTerm)) { + // profiles.add(Profile(userId: searchTerm)); + // } + // Pangea# return profiles; } diff --git a/lib/pages/onboarding/enable_notifications.dart b/lib/pages/onboarding/enable_notifications.dart new file mode 100644 index 000000000..e3586033a --- /dev/null +++ b/lib/pages/onboarding/enable_notifications.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/authentication/p_logout.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/login/pages/pangea_login_scaffold.dart'; +import 'package:fluffychat/widgets/local_notifications_extension.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class EnableNotifications extends StatefulWidget { + const EnableNotifications({super.key}); + + @override + EnableNotificationsController createState() => + EnableNotificationsController(); +} + +class EnableNotificationsController extends State { + Profile? profile; + + @override + void initState() { + _setProfile(); + super.initState(); + } + + Future _setProfile() async { + final client = Matrix.of(context).client; + try { + profile = await client.getProfileFromUserId( + client.userID!, + ); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'userId': client.userID, + }, + ); + } finally { + if (mounted) setState(() {}); + } + } + + Future _requestNotificationPermission() async { + await Matrix.of(context).requestNotificationPermission(); + if (mounted) { + context.go("/registration/course"); + } + } + + @override + Widget build(BuildContext context) { + return PangeaLoginScaffold( + customAppBar: AppBar( + title: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 450, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + BackButton( + onPressed: () => pLogoutAction( + context, + bypassWarning: true, + ), + ), + const SizedBox( + width: 40.0, + ), + ], + ), + ), + automaticallyImplyLeading: false, + ), + showAppName: false, + mainAssetUrl: profile?.avatarUrl, + children: [ + Column( + spacing: 8.0, + children: [ + Text( + L10n.of(context).welcomeUser( + profile?.displayName ?? + Matrix.of(context).client.userID?.localpart ?? + "", + ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + L10n.of(context).enableNotificationsTitle, + textAlign: TextAlign.center, + ), + ElevatedButton( + onPressed: _requestNotificationPermission, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + foregroundColor: + Theme.of(context).colorScheme.onPrimaryContainer, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(L10n.of(context).enableNotificationsDesc), + ], + ), + ), + TextButton( + child: Text(L10n.of(context).skipForNow), + onPressed: () => context.go("/registration/course"), + ), + ], + ), + ], + ); + } +} diff --git a/lib/pages/onboarding/space_code_onboarding.dart b/lib/pages/onboarding/space_code_onboarding.dart new file mode 100644 index 000000000..54c50f557 --- /dev/null +++ b/lib/pages/onboarding/space_code_onboarding.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pages/onboarding/space_code_onboarding_view.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/join_codes/space_code_controller.dart'; +import 'package:fluffychat/pangea/spaces/space_constants.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class SpaceCodeOnboarding extends StatefulWidget { + const SpaceCodeOnboarding({super.key}); + + @override + State createState() => SpaceCodeOnboardingState(); +} + +class SpaceCodeOnboardingState extends State { + Profile? profile; + Client get client => Matrix.of(context).client; + + final TextEditingController codeController = TextEditingController(); + + @override + void initState() { + _setProfile(); + codeController.addListener(() { + if (mounted) setState(() {}); + }); + super.initState(); + } + + @override + void dispose() { + codeController.dispose(); + super.dispose(); + } + + Future _setProfile() async { + try { + profile = await client.getProfileFromUserId( + client.userID!, + ); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'userId': client.userID, + }, + ); + } finally { + if (mounted) setState(() {}); + } + } + + Future submitCode() async { + String code = codeController.text.trim(); + if (code.isEmpty) return; + + try { + final link = Uri.parse(Uri.parse(code).fragment); + if (link.queryParameters.containsKey(SpaceConstants.classCode)) { + code = link.queryParameters[SpaceConstants.classCode]!; + } + } catch (e) { + debugPrint("Text input is not a URL: $e"); + } + + final roomId = await SpaceCodeController.joinSpaceWithCode(context, code); + if (roomId != null) { + final room = Matrix.of(context).client.getRoomById(roomId); + room?.isSpace ?? true + ? context.go('/rooms/spaces/$roomId/details') + : context.go('/rooms/$roomId'); + } + } + + @override + Widget build(BuildContext context) => + SpaceCodeOnboardingView(controller: this); +} diff --git a/lib/pages/onboarding/space_code_onboarding_view.dart b/lib/pages/onboarding/space_code_onboarding_view.dart new file mode 100644 index 000000000..bbd57d5eb --- /dev/null +++ b/lib/pages/onboarding/space_code_onboarding_view.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/onboarding/space_code_onboarding.dart'; +import 'package:fluffychat/pangea/login/pages/pangea_login_scaffold.dart'; + +class SpaceCodeOnboardingView extends StatelessWidget { + final SpaceCodeOnboardingState controller; + const SpaceCodeOnboardingView({ + super.key, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + return PangeaLoginScaffold( + customAppBar: AppBar( + title: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 450, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + BackButton( + onPressed: Navigator.of(context).pop, + ), + const SizedBox( + width: 40.0, + ), + ], + ), + ), + automaticallyImplyLeading: false, + ), + showAppName: false, + mainAssetUrl: controller.profile?.avatarUrl, + children: [ + Column( + spacing: 8.0, + children: [ + Text( + L10n.of(context).welcomeUser( + controller.profile?.displayName ?? + controller.client.userID?.localpart ?? + "", + ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + L10n.of(context).joinSpaceOnboardingDesc, + textAlign: TextAlign.center, + ), + TextField( + decoration: InputDecoration( + hintText: L10n.of(context).enterCodeToJoin, + ), + controller: controller.codeController, + onSubmitted: (_) => controller.submitCode, + ), + ElevatedButton( + onPressed: controller.codeController.text.isNotEmpty + ? controller.submitCode + : null, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + foregroundColor: + Theme.of(context).colorScheme.onPrimaryContainer, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(L10n.of(context).join), + ], + ), + ), + TextButton( + child: Text(L10n.of(context).skipForNow), + onPressed: () => context.go("/rooms"), + ), + ], + ), + ], + ); + } +} diff --git a/lib/pages/settings/settings_view.dart b/lib/pages/settings/settings_view.dart index 3cbd371e4..874e7ebe3 100644 --- a/lib/pages/settings/settings_view.dart +++ b/lib/pages/settings/settings_view.dart @@ -12,6 +12,7 @@ import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/utils/fluffy_share.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/local_notifications_extension.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../widgets/mxc_image_viewer.dart'; import 'settings.dart'; @@ -147,9 +148,7 @@ class SettingsView extends StatelessWidget { displayname, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 18, - ), + style: const TextStyle(fontSize: 18), ), ), TextButton.icon( @@ -171,25 +170,6 @@ class SettingsView extends StatelessWidget { // style: const TextStyle(fontSize: 12), ), ), - // #Pangea - TextButton.icon( - onPressed: controller.setStatus, - icon: const Icon( - Icons.add, - size: 14, - ), - style: TextButton.styleFrom( - foregroundColor: - theme.colorScheme.secondary, - iconColor: theme.colorScheme.secondary, - ), - label: Text( - L10n.of(context).setStatus, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - // Pangea# ], ), ), @@ -252,6 +232,23 @@ class SettingsView extends StatelessWidget { ? theme.colorScheme.surfaceContainerHigh : null, onTap: () => context.go('/rooms/settings/notifications'), + // #Pangea + trailing: ValueListenableBuilder( + valueListenable: + Matrix.of(context).notifPermissionNotifier, + builder: (context, _, __) => FutureBuilder( + future: Matrix.of(context).notificationsEnabled, + builder: (context, snapshot) { + return snapshot.data != false + ? const SizedBox() + : Icon( + Icons.error_outline, + color: theme.colorScheme.error, + ); + }, + ), + ), + // Pangea# ), ListTile( leading: const Icon(Icons.devices_outlined), @@ -294,7 +291,8 @@ class SettingsView extends StatelessWidget { // #Pangea ListTile( leading: const Icon(Icons.help_outline_outlined), - title: Text(L10n.of(context).help), + title: Text(L10n.of(context).chatWithSupport), + trailing: const Icon(Icons.chat_bubble_outline), onTap: () async { await showFutureLoadingDialog( context: context, diff --git a/lib/pages/settings_3pid/settings_3pid.dart b/lib/pages/settings_3pid/settings_3pid.dart index 801427f85..9014c4155 100644 --- a/lib/pages/settings_3pid/settings_3pid.dart +++ b/lib/pages/settings_3pid/settings_3pid.dart @@ -61,6 +61,9 @@ class Settings3PidController extends State { auth: auth, ), ), + // #Pangea + showError: (e) => !e.toString().contains("Request has been canceled"), + // Pangea# ); if (success.error != null) return; setState(() => request = null); diff --git a/lib/pages/settings_chat/settings_chat.dart b/lib/pages/settings_chat/settings_chat.dart index 1c1035559..3d30663ac 100644 --- a/lib/pages/settings_chat/settings_chat.dart +++ b/lib/pages/settings_chat/settings_chat.dart @@ -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 { + // #Pangea + Future 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); } diff --git a/lib/pages/settings_chat/settings_chat_view.dart b/lib/pages/settings_chat/settings_chat_view.dart index ca75754bd..16db97abc 100644 --- a/lib/pages/settings_chat/settings_chat_view.dart +++ b/lib/pages/settings_chat/settings_chat_view.dart @@ -78,7 +78,13 @@ class SettingsChatView extends StatelessWidget { storeKey: SettingKeys.swipeRightToLeftToReply, defaultValue: AppConfig.swipeRightToLeftToReply, ), + // #Pangea + SwitchListTile.adaptive( + value: AppConfig.useActivityImageAsChatBackground, + title: Text(L10n.of(context).useActivityImageAsChatBackground), + onChanged: controller.setUseActivityImageBackground, + ), // Divider(color: theme.dividerColor), // ListTile( // title: Text( diff --git a/lib/pages/settings_notifications/settings_notifications.dart b/lib/pages/settings_notifications/settings_notifications.dart index 84ab62b60..b0974940c 100644 --- a/lib/pages/settings_notifications/settings_notifications.dart +++ b/lib/pages/settings_notifications/settings_notifications.dart @@ -13,6 +13,7 @@ import 'package:fluffychat/widgets/adaptive_dialogs/adaptive_dialog_action.dart' import 'package:fluffychat/widgets/adaptive_dialogs/show_modal_action_popup.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/local_notifications_extension.dart'; import '../../widgets/matrix.dart'; import 'settings_notifications_view.dart'; @@ -205,6 +206,11 @@ class SettingsNotificationsController extends State { value, ); } + + Future requestNotificationPermission() async { + await Matrix.of(context).requestNotificationPermission(); + if (mounted) setState(() {}); + } // Pangea# @override diff --git a/lib/pages/settings_notifications/settings_notifications_view.dart b/lib/pages/settings_notifications/settings_notifications_view.dart index d5efd9d31..e4e14aa79 100644 --- a/lib/pages/settings_notifications/settings_notifications_view.dart +++ b/lib/pages/settings_notifications/settings_notifications_view.dart @@ -6,6 +6,7 @@ import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/settings_notifications/push_rule_extensions.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; +import 'package:fluffychat/widgets/local_notifications_extension.dart'; import '../../utils/localized_exception_extension.dart'; import '../../widgets/matrix.dart'; import 'settings_notifications.dart'; @@ -49,6 +50,37 @@ class SettingsNotificationsView extends StatelessWidget { child: Column( children: [ // #Pangea + FutureBuilder( + future: Matrix.of(context).notificationsEnabled, + builder: (context, snapshot) => AnimatedSize( + duration: FluffyThemes.animationDuration, + child: snapshot.data != false + ? const SizedBox() + : Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + ), + child: ListTile( + tileColor: theme.colorScheme.primaryContainer, + leading: Icon( + Icons.error_outline, + color: theme.colorScheme.onPrimaryContainer, + ), + title: Text( + L10n.of(context).enableNotificationsTitle, + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + subtitle: Text( + L10n.of(context).enableNotificationsDesc, + ), + onTap: controller.requestNotificationPermission, + ), + ), + ), + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( diff --git a/lib/pages/settings_style/settings_style.dart b/lib/pages/settings_style/settings_style.dart index 49b0b003c..5a0092a9a 100644 --- a/lib/pages/settings_style/settings_style.dart +++ b/lib/pages/settings_style/settings_style.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/setting_keys.dart'; +import 'package:fluffychat/pangea/user/style_settings_repo.dart'; import 'package:fluffychat/utils/account_config.dart'; import 'package:fluffychat/utils/file_selector.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; @@ -157,10 +157,16 @@ class SettingsStyleController extends State { void changeFontSizeFactor(double d) { setState(() => AppConfig.fontSizeFactor = d); - Matrix.of(context).store.setString( - SettingKeys.fontSizeFactor, - AppConfig.fontSizeFactor.toString(), - ); + // #Pangea + // Matrix.of(context).store.setString( + // SettingKeys.fontSizeFactor, + // AppConfig.fontSizeFactor.toString(), + // ); + StyleSettingsRepo.setFontSizeFactor( + Matrix.of(context).client.userID!, + AppConfig.fontSizeFactor, + ); + // Pangea# } @override diff --git a/lib/pages/settings_style/settings_style_view.dart b/lib/pages/settings_style/settings_style_view.dart index 1c11c0f67..6bf3b2a79 100644 --- a/lib/pages/settings_style/settings_style_view.dart +++ b/lib/pages/settings_style/settings_style_view.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/chat/events/state_message.dart'; @@ -16,7 +15,6 @@ import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; import '../../config/app_config.dart'; -import '../../widgets/settings_switch_list_tile.dart'; import 'settings_style.dart'; class SettingsStyleView extends StatelessWidget { @@ -335,31 +333,31 @@ class SettingsStyleView extends StatelessWidget { semanticFormatterCallback: (d) => d.toString(), onChanged: controller.changeFontSizeFactor, ), - Divider( - color: theme.dividerColor, - ), - ListTile( - title: Text( - L10n.of(context).overview, - style: TextStyle( - color: theme.colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - ), - ), - SettingsSwitchListTile.adaptive( - title: L10n.of(context).presencesToggle, - onChanged: (b) => AppConfig.showPresences = b, - storeKey: SettingKeys.showPresences, - defaultValue: AppConfig.showPresences, - ), - SettingsSwitchListTile.adaptive( - title: L10n.of(context).separateChatTypes, - onChanged: (b) => AppConfig.separateChatTypes = b, - storeKey: SettingKeys.separateChatTypes, - defaultValue: AppConfig.separateChatTypes, - ), // #Pangea + // Divider( + // color: theme.dividerColor, + // ), + // ListTile( + // title: Text( + // L10n.of(context).overview, + // style: TextStyle( + // color: theme.colorScheme.secondary, + // fontWeight: FontWeight.bold, + // ), + // ), + // ), + // SettingsSwitchListTile.adaptive( + // title: L10n.of(context).presencesToggle, + // onChanged: (b) => AppConfig.showPresences = b, + // storeKey: SettingKeys.showPresences, + // defaultValue: AppConfig.showPresences, + // ), + // SettingsSwitchListTile.adaptive( + // title: L10n.of(context).separateChatTypes, + // onChanged: (b) => AppConfig.separateChatTypes = b, + // storeKey: SettingKeys.separateChatTypes, + // defaultValue: AppConfig.separateChatTypes, + // ), // SettingsSwitchListTile.adaptive( // title: L10n.of(context).displayNavigationRail, // onChanged: (b) => AppConfig.displayNavigationRail = b, diff --git a/lib/pangea/activity_planner/activity_plan_model.dart b/lib/pangea/activity_planner/activity_plan_model.dart index 978d448a6..4546aaacd 100644 --- a/lib/pangea/activity_planner/activity_plan_model.dart +++ b/lib/pangea/activity_planner/activity_plan_model.dart @@ -1,6 +1,9 @@ +import 'dart:math'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart'; import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; @@ -44,9 +47,18 @@ class ActivityPlanModel { _roles = roles, _imageURL = imageURL; + List get placeholderImages => [ + "${AppConfig.assetsBaseURL}/Space%20template%202.png", + "${AppConfig.assetsBaseURL}/Space%20template%203.png", + "${AppConfig.assetsBaseURL}/Space%20template%204.png", + ]; + + String get randomPlaceholder => placeholderImages[ + Random(title.hashCode).nextInt(placeholderImages.length)]; + Uri? get imageURL => _imageURL != null ? Uri.tryParse("${Environment.cmsApi}$_imageURL") - : null; + : Uri.tryParse(randomPlaceholder); Map get roles { if (_roles != null) return _roles!; diff --git a/lib/pangea/activity_sessions/activity_participant_indicator.dart b/lib/pangea/activity_sessions/activity_participant_indicator.dart index 58fd0403f..c5871e820 100644 --- a/lib/pangea/activity_sessions/activity_participant_indicator.dart +++ b/lib/pangea/activity_sessions/activity_participant_indicator.dart @@ -2,12 +2,11 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:matrix/matrix.dart'; -import 'package:shimmer/shimmer.dart'; -import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/bot/widgets/bot_settings_language_icon.dart'; +import 'package:fluffychat/pangea/common/widgets/shimmer_background.dart'; import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/hover_builder.dart'; @@ -46,6 +45,7 @@ class ActivityParticipantIndicator extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final borderRadius = this.borderRadius ?? BorderRadius.circular(8.0); return MouseRegion( cursor: SystemMouseCursors.basic, child: GestureDetector( @@ -71,11 +71,11 @@ class ActivityParticipantIndicator extends StatelessWidget { size: 60.0, userId: userId, miniIcon: - room != null && userId == BotName.byEnvironment + room != null && user?.id == BotName.byEnvironment ? BotSettingsLanguageIcon(user: user!) : null, presenceOffset: - room != null && userId == BotName.byEnvironment + room != null && user?.id == BotName.byEnvironment ? const Offset(0, 0) : null, ) @@ -98,52 +98,53 @@ class ActivityParticipantIndicator extends StatelessWidget { ); return Opacity( opacity: opacity, - child: Container( - padding: padding ?? - const EdgeInsets.symmetric( - vertical: 4.0, - horizontal: 8.0, - ), - decoration: BoxDecoration( - borderRadius: borderRadius ?? BorderRadius.circular(8.0), - color: (hovered || selected) && selectable - ? theme.colorScheme.surfaceContainerHighest - : theme.colorScheme.surface.withAlpha(130), - ), - height: 125.0, - constraints: const BoxConstraints(maxWidth: 100.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - shimmer && !selected - ? Shimmer.fromColors( - baseColor: AppConfig.gold.withAlpha(20), - highlightColor: AppConfig.gold.withAlpha(50), - child: avatar, - ) - : avatar, - Text( - name, - style: const TextStyle( - fontSize: 12.0, + child: ShimmerBackground( + enabled: shimmer, + borderRadius: borderRadius, + child: Container( + alignment: Alignment.center, + padding: padding ?? + const EdgeInsets.symmetric( + vertical: 4.0, + horizontal: 8.0, ), - textAlign: TextAlign.center, - ), - Text( - userId?.localpart ?? L10n.of(context).openRoleLabel, - style: TextStyle( - fontSize: 12.0, - color: (Theme.of(context).brightness == - Brightness.light - ? (userId?.localpart?.darkColor ?? name.darkColor) - : (userId?.localpart?.lightColorText ?? - name.lightColorText)), + decoration: BoxDecoration( + borderRadius: borderRadius, + color: (hovered || selected) && selectable + ? theme.colorScheme.surfaceContainerHighest + : theme.colorScheme.surface.withAlpha(130), + ), + height: 125.0, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + avatar, + Text( + name, + style: const TextStyle( + fontSize: 12.0, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + Text( + userId?.localpart ?? L10n.of(context).openRoleLabel, + style: TextStyle( + fontSize: 12.0, + color: (Theme.of(context).brightness == + Brightness.light + ? (userId?.localpart?.darkColor ?? + name.darkColor) + : (userId?.localpart?.lightColorText ?? + name.lightColorText)), + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), ), ); diff --git a/lib/pangea/activity_sessions/activity_participant_list.dart b/lib/pangea/activity_sessions/activity_participant_list.dart index ecc3f7a7f..a29c3f23a 100644 --- a/lib/pangea/activity_sessions/activity_participant_list.dart +++ b/lib/pangea/activity_sessions/activity_participant_list.dart @@ -52,51 +52,79 @@ class ActivityParticipantList extends StatelessWidget { spacing: 12.0, mainAxisSize: MainAxisSize.min, children: [ - Wrap( - alignment: WrapAlignment.center, - spacing: 12.0, - runSpacing: 12.0, - children: availableRoles.map((availableRole) { - final selected = - isSelected != null ? isSelected!(availableRole.id) : false; + LayoutBuilder( + builder: (context, constraints) { + const minItemWidth = 125.0; - final assignedRole = assignedRoles[availableRole.id] ?? - (selected - ? ActivityRoleModel( - id: availableRole.id, - userId: Matrix.of(context).client.userID!, - role: availableRole.name, - ) - : null); + final rows = (availableRoles.length / + (constraints.maxWidth / minItemWidth)) + .ceil(); - final User? user = participants.participants.firstWhereOrNull( - (u) => u.id == assignedRole?.userId, - ) ?? - course?.getParticipants().firstWhereOrNull( - (u) => u.id == assignedRole?.userId, + final entriesPerRow = (availableRoles.length / rows).ceil(); + + return Column( + spacing: 8.0, + children: List.generate(rows, (rowIndex) { + final entries = availableRoles + .skip(rowIndex * entriesPerRow) + .take(entriesPerRow) + .toList(); + + return Row( + spacing: 8.0, + mainAxisAlignment: MainAxisAlignment.center, + children: entries.map((availableRole) { + final selected = isSelected != null + ? isSelected!(availableRole.id) + : false; + + final assignedRole = assignedRoles[availableRole.id] ?? + (selected + ? ActivityRoleModel( + id: availableRole.id, + userId: Matrix.of(context).client.userID!, + role: availableRole.name, + ) + : null); + + final User? user = + participants.participants.firstWhereOrNull( + (u) => u.id == assignedRole?.userId, + ) ?? + course?.getParticipants().firstWhereOrNull( + (u) => u.id == assignedRole?.userId, + ); + + final selectable = canSelect != null + ? canSelect!(availableRole.id) + : true; + + final shimmering = isShimmering != null + ? isShimmering!(availableRole.id) + : false; + + return Expanded( + child: ActivityParticipantIndicator( + name: availableRole.name, + userId: assignedRole?.userId, + opacity: getOpacity != null + ? getOpacity!(assignedRole) + : 1.0, + user: user, + onTap: onTap != null && selectable + ? () => onTap!(availableRole.id) + : null, + selected: selected, + selectable: selectable, + shimmer: shimmering, + room: room, + ), ); - - final selectable = - canSelect != null ? canSelect!(availableRole.id) : true; - - final shimmering = isShimmering != null - ? isShimmering!(availableRole.id) - : false; - - return ActivityParticipantIndicator( - name: availableRole.name, - userId: assignedRole?.userId, - opacity: getOpacity != null ? getOpacity!(assignedRole) : 1.0, - user: user, - onTap: onTap != null && selectable - ? () => onTap!(availableRole.id) - : null, - selected: selected, - selectable: selectable, - shimmer: shimmering, - room: room, + }).toList(), + ); + }), ); - }).toList(), + }, ), Wrap( alignment: WrapAlignment.center, diff --git a/lib/pangea/activity_sessions/activity_room_extension.dart b/lib/pangea/activity_sessions/activity_room_extension.dart index 19bd114b9..e48a8cfd5 100644 --- a/lib/pangea/activity_sessions/activity_room_extension.dart +++ b/lib/pangea/activity_sessions/activity_room_extension.dart @@ -353,7 +353,8 @@ extension ActivityRoomExtension on Room { bool get isActivitySession => (roomType?.startsWith(PangeaRoomTypes.activitySession) == true || activityPlan != null) && - activityPlan?.isDeprecatedModel == false; + activityPlan?.isDeprecatedModel == false && + activityPlan?.activityId != null; String? get activityId { if (!isActivitySession) return null; diff --git a/lib/pangea/activity_sessions/activity_session_chat/activity_chat_extension.dart b/lib/pangea/activity_sessions/activity_session_chat/activity_chat_extension.dart index b1f6ccb8c..582fddb85 100644 --- a/lib/pangea/activity_sessions/activity_session_chat/activity_chat_extension.dart +++ b/lib/pangea/activity_sessions/activity_session_chat/activity_chat_extension.dart @@ -1,3 +1,4 @@ +import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; @@ -12,7 +13,8 @@ extension ActivityMenuLogic on ChatController { if (AppConfig.showedActivityMenu || InstructionsEnum.activityStatsMenu.isToggledOff || MatrixState.pAnyState.isOverlayOpen(RegExp(r"^word-zoom-card-.*$")) || - timeline == null) { + timeline == null || + GoRouterState.of(context).fullPath?.endsWith(':roomid') != true) { return false; } @@ -42,15 +44,9 @@ extension ActivityMenuLogic on ChatController { return false; } - final l1 = - MatrixState.pangeaController.userController.userL1?.langCodeShort; final l2 = MatrixState.pangeaController.userController.userL2?.langCodeShort; final activityLang = room.activityPlan?.req.targetLanguage.split('-').first; - - return activityLang != null && - l2 != null && - l2 != activityLang && - l1 != activityLang; + return activityLang != null && l2 != activityLang; } } diff --git a/lib/pangea/activity_sessions/activity_session_chat/activity_finished_status_message.dart b/lib/pangea/activity_sessions/activity_session_chat/activity_finished_status_message.dart index cd2159910..dbd0b0578 100644 --- a/lib/pangea/activity_sessions/activity_session_chat/activity_finished_status_message.dart +++ b/lib/pangea/activity_sessions/activity_session_chat/activity_finished_status_message.dart @@ -7,8 +7,9 @@ import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; import 'package:fluffychat/pangea/activity_summary/activity_summary_model.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; -import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/pangea/languages/p_language_store.dart'; import 'package:fluffychat/widgets/matrix.dart'; class ActivityFinishedStatusMessage extends StatelessWidget { @@ -19,25 +20,35 @@ class ActivityFinishedStatusMessage extends StatelessWidget { required this.controller, }); - Future _onArchive(BuildContext context) async { - final resp = await showFutureLoadingDialog( - context: context, - future: () => _archiveToAnalytics(context), + void _onArchive(BuildContext context) { + _archiveToAnalytics(); + context.go( + "/rooms/spaces/${controller.room.courseParent!.id}/details?tab=course", ); - - if (!resp.isError) { - context.go( - "/rooms/spaces/${controller.room.courseParent!.id}/details?tab=course", - ); - } } - Future _archiveToAnalytics(BuildContext context) async { - await controller.room.archiveActivity(); - await Matrix.of(context) - .analyticsDataService - .updateService - .sendActivityAnalytics(controller.room.id); + Future _archiveToAnalytics() async { + try { + final activityPlan = controller.room.activityPlan; + if (activityPlan == null) { + throw Exception("No activity plan found for room"); + } + + final lang = activityPlan.req.targetLanguage.split("-").first; + final langModel = PLanguageStore.byLangCode(lang)!; + await controller.room.archiveActivity(); + await MatrixState + .pangeaController.matrixState.analyticsDataService.updateService + .sendActivityAnalytics(controller.room.id, langModel); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'roomId': controller.room.id, + }, + ); + } } ActivitySummaryModel? get summary => controller.room.activitySummary; diff --git a/lib/pangea/activity_sessions/activity_session_chat/activity_vocab_widget.dart b/lib/pangea/activity_sessions/activity_session_chat/activity_vocab_widget.dart index bd78b0538..0a05e5c47 100644 --- a/lib/pangea/activity_sessions/activity_session_chat/activity_vocab_widget.dart +++ b/lib/pangea/activity_sessions/activity_session_chat/activity_vocab_widget.dart @@ -8,6 +8,7 @@ import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance/token_rendering_util.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance/tokens_util.dart'; +import 'package:fluffychat/pangea/toolbar/reading_assistance/underline_text_widget.dart'; import 'package:fluffychat/pangea/toolbar/token_rendering_mixin.dart'; import 'package:fluffychat/pangea/toolbar/word_card/word_zoom_widget.dart'; import 'package:fluffychat/widgets/hover_builder.dart'; @@ -105,27 +106,17 @@ class _VocabChipsState extends State<_VocabChips> with TokenRenderingMixin { OverlayUtil.showPositionedCard( overlayKey: target, context: context, - cardToShow: StatefulBuilder( - builder: (context, setState) => WordZoomWidget( - token: PangeaTokenText( - content: v.lemma, - length: v.lemma.characters.length, - offset: 0, - ), - construct: ConstructIdentifier( - lemma: v.lemma, - type: ConstructTypeEnum.vocab, - category: v.pos, - ), - langCode: widget.langCode, - onClose: () { - MatrixState.pAnyState.closeOverlay(target); - setState(() => _selectedVocab = null); - }, - onDismissNewWordOverlay: () { - if (mounted) setState(() {}); - }, - ), + cardToShow: _WordCardWrapper( + v: v, + langCode: widget.langCode, + target: target, + onClose: () { + if (mounted) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => setState(() => _selectedVocab = null), + ); + } + }, ), transformTargetId: target, closePrevOverlay: false, @@ -143,12 +134,6 @@ class _VocabChipsState extends State<_VocabChips> with TokenRenderingMixin { tokens, widget.activityLangCode, ); - final renderer = TokenRenderingUtil( - existingStyle: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 14.0, - ), - ); return Wrap( spacing: 4.0, @@ -186,13 +171,14 @@ class _VocabChipsState extends State<_VocabChips> with TokenRenderingMixin { color: color, borderRadius: BorderRadius.circular(20), ), - child: Text( - v.lemma, - style: renderer.style( - underlineColor: Theme.of(context) - .colorScheme - .primary - .withAlpha(200), + child: UnderlineText( + text: v.lemma, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: 14.0, + ), + underlineColor: TokenRenderingUtil.underlineColor( + Theme.of(context).colorScheme.primary.withAlpha(200), isNew: isNew, selected: _selectedVocab == v, hovered: hovered, @@ -208,3 +194,52 @@ class _VocabChipsState extends State<_VocabChips> with TokenRenderingMixin { ); } } + +class _WordCardWrapper extends StatefulWidget { + final Vocab v; + final String langCode; + final String target; + final VoidCallback onClose; + + const _WordCardWrapper({ + required this.v, + required this.langCode, + required this.target, + required this.onClose, + }); + + @override + State<_WordCardWrapper> createState() => _WordCardWrapperState(); +} + +class _WordCardWrapperState extends State<_WordCardWrapper> { + @override + void dispose() { + widget.onClose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WordZoomWidget( + token: PangeaTokenText( + content: widget.v.lemma, + length: widget.v.lemma.characters.length, + offset: 0, + ), + construct: ConstructIdentifier( + lemma: widget.v.lemma, + type: ConstructTypeEnum.vocab, + category: widget.v.pos, + ), + langCode: widget.langCode, + onClose: () { + MatrixState.pAnyState.closeOverlay(widget.target); + widget.onClose(); + }, + onDismissNewWordOverlay: () { + if (mounted) setState(() {}); + }, + ); + } +} diff --git a/lib/pangea/activity_sessions/activity_session_start/activity_session_start_page.dart b/lib/pangea/activity_sessions/activity_session_start/activity_session_start_page.dart index c2c39fb85..ecfaeae14 100644 --- a/lib/pangea/activity_sessions/activity_session_start/activity_session_start_page.dart +++ b/lib/pangea/activity_sessions/activity_session_start/activity_session_start_page.dart @@ -14,6 +14,7 @@ import 'package:fluffychat/pangea/activity_sessions/activity_session_start/activ import 'package:fluffychat/pangea/activity_sessions/activity_session_start/bot_join_error_dialog.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/chat_settings/utils/room_summary_extension.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/course_plans/course_activities/activity_summaries_provider.dart'; import 'package:fluffychat/pangea/course_plans/course_activities/course_activity_repo.dart'; import 'package:fluffychat/pangea/course_plans/course_activities/course_activity_translation_request.dart'; @@ -293,8 +294,17 @@ class ActivitySessionStartController extends State ); } await Future.wait(futures); - } catch (e) { + } catch (e, s) { error = e; + ErrorHandler.logError( + e: e, + s: s, + data: { + "activityId": widget.activityId, + "roomId": widget.roomId, + "parentId": widget.parentId, + }, + ); } finally { if (mounted) { setState(() => loading = false); diff --git a/lib/pangea/activity_sessions/activity_summary_widget.dart b/lib/pangea/activity_sessions/activity_summary_widget.dart index df4bb64ed..7ef14d940 100644 --- a/lib/pangea/activity_sessions/activity_summary_widget.dart +++ b/lib/pangea/activity_sessions/activity_summary_widget.dart @@ -9,6 +9,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:matrix/matrix.dart'; import 'package:matrix/src/utils/markdown.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; @@ -35,6 +36,8 @@ class ActivitySummary extends StatelessWidget { final ValueNotifier>? usedVocab; + final bool inChat; + const ActivitySummary({ super.key, required this.activity, @@ -49,6 +52,7 @@ class ActivitySummary extends StatelessWidget { this.getParticipantOpacity, this.room, this.course, + this.inChat = false, }); @override @@ -63,18 +67,20 @@ class ActivitySummary extends StatelessWidget { child: Column( spacing: 4.0, children: [ - LayoutBuilder( - builder: (context, constraints) { - return ImageByUrl( - imageUrl: activity.imageURL, - width: min( - constraints.maxWidth, - MediaQuery.sizeOf(context).height * 0.5, - ), - borderRadius: BorderRadius.circular(20), - ); - }, - ), + (!inChat || !AppConfig.useActivityImageAsChatBackground) + ? LayoutBuilder( + builder: (context, constraints) { + return ImageByUrl( + imageUrl: activity.imageURL, + width: min( + constraints.maxWidth, + MediaQuery.sizeOf(context).height * 0.5, + ), + borderRadius: BorderRadius.circular(20), + ); + }, + ) + : const SizedBox.shrink(), ActivityParticipantList( activity: activity, room: room, @@ -91,17 +97,15 @@ class ActivitySummary extends StatelessWidget { color: theme.colorScheme.surface.withAlpha(128), borderRadius: BorderRadius.circular(12.0), ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - ), - child: Column( - spacing: 4.0, - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - hoverColor: theme.colorScheme.surfaceTint.withAlpha(55), - onTap: toggleInstructions, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + borderRadius: BorderRadius.circular(12.0), + hoverColor: theme.colorScheme.surfaceTint.withAlpha(55), + onTap: toggleInstructions, + child: Padding( + padding: const EdgeInsets.all(8.0), child: Column( spacing: 4.0, children: [ @@ -133,69 +137,75 @@ class ActivitySummary extends StatelessWidget { ], ), ), - if (showInstructions) ...[ - Row( - spacing: 8.0, - mainAxisSize: MainAxisSize.min, + ), + if (showInstructions) + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( children: [ - Text( - activity.req.mode, - style: theme.textTheme.bodyMedium, - ), Row( - spacing: 4.0, + spacing: 8.0, mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.school, size: 12.0), Text( - activity.req.cefrLevel.string, + activity.req.mode, style: theme.textTheme.bodyMedium, ), + Row( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.school, size: 12.0), + Text( + activity.req.cefrLevel.string, + style: theme.textTheme.bodyMedium, + ), + ], + ), ], ), + ActivitySessionDetailsRow( + icon: Symbols.target, + iconSize: 16.0, + child: Text( + activity.learningObjective, + style: theme.textTheme.bodyMedium, + ), + ), + ActivitySessionDetailsRow( + icon: Symbols.steps, + iconSize: 16.0, + child: Html( + data: markdown(activity.instructions), + style: { + "body": Style( + margin: Margins.all(0), + padding: HtmlPaddings.all(0), + fontSize: FontSize( + theme.textTheme.bodyMedium!.fontSize!, + ), + ), + }, + ), + ), + ActivitySessionDetailsRow( + icon: Symbols.dictionary, + iconSize: 16.0, + child: ActivityVocabWidget( + key: ValueKey( + "activity-summary-${activity.activityId}", + ), + vocab: activity.vocab, + langCode: activity.req.targetLanguage, + targetId: "activity-summary-vocab", + usedVocab: usedVocab, + activityLangCode: activity.req.targetLanguage, + ), + ), ], ), - ActivitySessionDetailsRow( - icon: Symbols.target, - iconSize: 16.0, - child: Text( - activity.learningObjective, - style: theme.textTheme.bodyMedium, - ), - ), - ActivitySessionDetailsRow( - icon: Symbols.steps, - iconSize: 16.0, - child: Html( - data: markdown(activity.instructions), - style: { - "body": Style( - margin: Margins.all(0), - padding: HtmlPaddings.all(0), - fontSize: FontSize( - theme.textTheme.bodyMedium!.fontSize!, - ), - ), - }, - ), - ), - ActivitySessionDetailsRow( - icon: Symbols.dictionary, - iconSize: 16.0, - child: ActivityVocabWidget( - key: ValueKey( - "activity-summary-${activity.activityId}", - ), - vocab: activity.vocab, - langCode: activity.req.targetLanguage, - targetId: "activity-summary-vocab", - usedVocab: usedVocab, - activityLangCode: activity.req.targetLanguage, - ), - ), - ], - ], - ), + ), + ], ), ), ], diff --git a/lib/pangea/activity_sessions/activity_user_summaries_widget.dart b/lib/pangea/activity_sessions/activity_user_summaries_widget.dart index 2553f1a5a..39db710db 100644 --- a/lib/pangea/activity_sessions/activity_user_summaries_widget.dart +++ b/lib/pangea/activity_sessions/activity_user_summaries_widget.dart @@ -202,14 +202,11 @@ class ButtonControlledCarouselView extends StatelessWidget { ], ), Flexible( - child: SingleChildScrollView( - child: Text( - p.displayFeedback( - user?.calcDisplayname() ?? - p.participantId.localpart ?? - p.participantId, - ), - style: const TextStyle(fontSize: 14.0), + child: _SummaryText( + text: p.displayFeedback( + user?.calcDisplayname() ?? + p.participantId.localpart ?? + p.participantId, ), ), ), @@ -290,14 +287,20 @@ class ButtonControlledCarouselView extends StatelessWidget { (role) => role.userId == p.participantId, ); final userRoleInfo = availableRoles[userRole.id]!; - return ActivityParticipantIndicator( - name: userRoleInfo.name, - userId: p.participantId, - user: user, - borderRadius: BorderRadius.circular(4), - selected: highlightedRole?.id == userRole.id, - onTap: () => _scrollToUser(userRole, index, cardWidth), - room: controller.room, + return SizedBox( + width: 100.0, + height: 125.0, + child: Center( + child: ActivityParticipantIndicator( + name: userRoleInfo.name, + userId: p.participantId, + user: user, + borderRadius: BorderRadius.circular(4), + selected: highlightedRole?.id == userRole.id, + onTap: () => _scrollToUser(userRole, index, cardWidth), + room: controller.room, + ), + ), ); }, ); @@ -334,3 +337,38 @@ class SuperlativeTile extends StatelessWidget { ); } } + +class _SummaryText extends StatefulWidget { + final String text; + const _SummaryText({ + required this.text, + }); + + @override + State<_SummaryText> createState() => _SummaryTextState(); +} + +class _SummaryTextState extends State<_SummaryText> { + final ScrollController _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scrollbar( + controller: _scrollController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _scrollController, + child: Text( + widget.text, + style: const TextStyle(fontSize: 14.0), + ), + ), + ); + } +} diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart index f1312c625..bd371aa81 100644 --- a/lib/pangea/analytics_data/analytics_data_service.dart +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -14,10 +14,12 @@ import 'package:fluffychat/pangea/analytics_data/level_up_analytics_service.dart import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/analytics_settings/analytics_settings_extension.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/languages/language_model.dart'; import 'package:fluffychat/pangea/user/analytics_profile_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -85,6 +87,7 @@ class AnalyticsDataService { void dispose() { _syncController?.dispose(); updateDispatcher.dispose(); + updateService.dispose(); _closeDatabase(); } @@ -121,7 +124,10 @@ class AnalyticsDataService { _invalidateCaches(); final analyticsUserId = await _analyticsClientGetter.database.getUserID(); - if (analyticsUserId != client.userID) { + final lastUpdated = + await _analyticsClientGetter.database.getLastUpdated(); + + if (analyticsUserId != client.userID || lastUpdated == null) { await _clearDatabase(); await _analyticsClientGetter.database.updateUserID(client.userID!); } @@ -157,6 +163,7 @@ class AnalyticsDataService { Logs().i("Analytics database initialized."); initCompleter.complete(); updateDispatcher.sendConstructAnalyticsUpdate(AnalyticsUpdate([])); + updateDispatcher.sendActivityAnalyticsUpdate(null); } } @@ -212,6 +219,8 @@ class AnalyticsDataService { await _syncController?.waitForSync(analyticsRoomID); } + DerivedAnalyticsDataModel? get cachedDerivedData => _cachedDerivedStats; + Future get derivedData async { await _ensureInitialized(); @@ -232,12 +241,15 @@ class AnalyticsDataService { int? count, String? roomId, DateTime? since, + ConstructUseTypeEnum? type, + bool filterCapped = true, }) async { await _ensureInitialized(); final uses = await _analyticsClientGetter.database.getUses( count: count, roomId: roomId, since: since, + type: type, ); final blocked = blockedConstructs; @@ -246,12 +258,15 @@ class AnalyticsDataService { final Map cappedLastUseCache = {}; for (final use in uses) { if (blocked.contains(use.identifier)) continue; + if (use.category == 'other') continue; + if (!cappedLastUseCache.containsKey(use.identifier)) { final constructs = await getConstructUse(use.identifier); 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); @@ -317,7 +332,8 @@ class AnalyticsDataService { final existing = cleaned[canonical]; if (existing != null) { existing.merge(entry); - } else if (!blocked.contains(canonical)) { + } else if (!blocked.contains(canonical) && + canonical.category != 'other') { cleaned[canonical] = entry; } } @@ -338,7 +354,10 @@ class AnalyticsDataService { final blocked = blockedConstructs; final uses = newConstructs .where( - (c) => c.constructType == type && !blocked.contains(c.identifier), + (c) => + c.constructType == type && + !blocked.contains(c.identifier) && + c.identifier.category != 'other', ) .toList(); @@ -371,7 +390,9 @@ class AnalyticsDataService { AnalyticsUpdate update, ) async { final events = []; - final updateIds = update.addedConstructs.map((c) => c.identifier).toList(); + final addedConstructs = + update.addedConstructs.where((c) => c.category != 'other').toList(); + final updateIds = addedConstructs.map((c) => c.identifier).toList(); final prevData = await derivedData; final prevConstructs = await getConstructUses(updateIds); @@ -380,9 +401,12 @@ class AnalyticsDataService { await _ensureInitialized(); final blocked = blockedConstructs; - _mergeTable.addConstructsByUses(update.addedConstructs, blocked); + final newUnusedConstructs = + updateIds.where((id) => !hasUsedConstruct(id)).toSet(); + + _mergeTable.addConstructsByUses(addedConstructs, blocked); await _analyticsClientGetter.database.updateLocalAnalytics( - update.addedConstructs, + addedConstructs, ); final newConstructs = await getConstructUses(updateIds); @@ -433,10 +457,31 @@ class AnalyticsDataService { events.add(MorphUnlockedEvent(newUnlockedMorphs)); } + for (final entry in newConstructs.entries) { + final prevConstruct = prevConstructs[entry.key]; + if (prevConstruct == null) continue; + + final prevLevel = prevConstruct.lemmaCategory; + final newLevel = entry.value.lemmaCategory; + if (newLevel.xpNeeded > prevLevel.xpNeeded) { + events.add( + ConstructLevelUpEvent( + entry.key, + newLevel, + update.targetID, + ), + ); + } + } + if (update.blockedConstruct != null) { events.add(ConstructBlockedEvent(update.blockedConstruct!)); } + if (newUnusedConstructs.isNotEmpty) { + events.add(NewConstructsEvent(newUnusedConstructs)); + } + return events; } diff --git a/lib/pangea/analytics_data/analytics_database.dart b/lib/pangea/analytics_data/analytics_database.dart index 8a1f089c5..870bd9f61 100644 --- a/lib/pangea/analytics_data/analytics_database.dart +++ b/lib/pangea/analytics_data/analytics_database.dart @@ -10,6 +10,7 @@ import 'package:synchronized/synchronized.dart'; import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; @@ -177,6 +178,12 @@ class AnalyticsDatabase with DatabaseFileStorage { Future getUserID() => _lastEventTimestampBox.get('user_id'); + Future getLastUpdated() async { + final entry = await _lastEventTimestampBox.get('last_updated'); + if (entry == null) return null; + return DateTime.tryParse(entry); + } + Future getLastEventTimestamp() async { final timestampString = await _lastEventTimestampBox.get('last_event_timestamp'); @@ -197,6 +204,7 @@ class AnalyticsDatabase with DatabaseFileStorage { int? count, String? roomId, DateTime? since, + ConstructUseTypeEnum? type, }) async { final stopwatch = Stopwatch()..start(); final results = []; @@ -208,6 +216,9 @@ class AnalyticsDatabase with DatabaseFileStorage { if (roomId != null && use.metadata.roomId != roomId) { return true; // skip but continue } + if (type != null && use.useType != type) { + return true; // skip but continue + } results.add(use); return count == null || results.length < count; @@ -395,10 +406,7 @@ class AnalyticsDatabase with DatabaseFileStorage { ); } - for (final u in usesForKey) { - model.addUse(u); - } - + model.addUses(usesForKey); updates[key] = model; } @@ -480,6 +488,15 @@ class AnalyticsDatabase with DatabaseFileStorage { }); } + Future updateLastUpdated(DateTime timestamp) { + return _transaction(() async { + await _lastEventTimestampBox.put( + 'last_updated', + timestamp.toIso8601String(), + ); + }); + } + Future updateXPOffset(int offset) { return _transaction(() async { final stats = await getDerivedStats(); @@ -575,6 +592,8 @@ class AnalyticsDatabase with DatabaseFileStorage { ); }); + await updateLastUpdated(DateTime.now()); + stopwatch.stop(); Logs().i( "Server analytics update took ${stopwatch.elapsedMilliseconds} ms", @@ -629,6 +648,8 @@ class AnalyticsDatabase with DatabaseFileStorage { } }); + await updateLastUpdated(DateTime.now()); + stopwatch.stop(); Logs().i("Local analytics update took ${stopwatch.elapsedMilliseconds} ms"); } diff --git a/lib/pangea/analytics_data/analytics_sync_controller.dart b/lib/pangea/analytics_data/analytics_sync_controller.dart index 53ccd760a..f895c5657 100644 --- a/lib/pangea/analytics_data/analytics_sync_controller.dart +++ b/lib/pangea/analytics_data/analytics_sync_controller.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_update_dispatcher.dart'; import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; @@ -52,6 +53,13 @@ class AnalyticsSyncController { if (constructEvents.isEmpty) return; await dataService.updateServerAnalytics(constructEvents); + + // Server updates do not usually need to update the UI, since usually they are only + // transfering local data to the server. However, if a user if using multiple devices, + // we do need to update the UI when new data comes from the server. + dataService.updateDispatcher.sendConstructAnalyticsUpdate( + AnalyticsUpdate([]), + ); } Future waitForSync(String analyticsRoomId) async { diff --git a/lib/pangea/analytics_data/analytics_update_dispatcher.dart b/lib/pangea/analytics_data/analytics_update_dispatcher.dart index 84d697020..acf7052fc 100644 --- a/lib/pangea/analytics_data/analytics_update_dispatcher.dart +++ b/lib/pangea/analytics_data/analytics_update_dispatcher.dart @@ -4,6 +4,7 @@ import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_update_events.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart'; class LevelUpdate { @@ -28,14 +29,26 @@ class AnalyticsUpdate { }); } +class ConstructLevelUpdate { + final ConstructIdentifier constructId; + final ConstructLevelEnum level; + final String? targetID; + + ConstructLevelUpdate({ + required this.constructId, + required this.level, + this.targetID, + }); +} + class AnalyticsUpdateDispatcher { final AnalyticsDataService dataService; final StreamController constructUpdateStream = StreamController.broadcast(); - final StreamController activityAnalyticsStream = - StreamController.broadcast(); + final StreamController activityAnalyticsStream = + StreamController.broadcast(); final StreamController> unlockedConstructsStream = StreamController>.broadcast(); @@ -43,6 +56,12 @@ class AnalyticsUpdateDispatcher { final StreamController levelUpdateStream = StreamController.broadcast(); + final StreamController> newConstructsStream = + StreamController>.broadcast(); + + final StreamController constructLevelUpdateStream = + StreamController.broadcast(); + final StreamController> _lemmaInfoUpdateStream = StreamController< MapEntry>.broadcast(); @@ -54,6 +73,7 @@ class AnalyticsUpdateDispatcher { activityAnalyticsStream.close(); unlockedConstructsStream.close(); levelUpdateStream.close(); + constructLevelUpdateStream.close(); _lemmaInfoUpdateStream.close(); } @@ -65,7 +85,7 @@ class AnalyticsUpdateDispatcher { .map((update) => update.value); void sendActivityAnalyticsUpdate( - String activityAnalytics, + String? activityAnalytics, ) => activityAnalyticsStream.add(activityAnalytics); @@ -98,6 +118,12 @@ class AnalyticsUpdateDispatcher { case final ConstructBlockedEvent e: _onBlockedConstruct(e.blockedConstruct); break; + case final ConstructLevelUpEvent e: + _onConstructLevelUp(e.constructId, e.level, e.targetID); + break; + case final NewConstructsEvent e: + _onNewConstruct(e.newConstructs); + break; } } @@ -131,10 +157,29 @@ class AnalyticsUpdateDispatcher { constructUpdateStream.add(update); } + void _onConstructLevelUp( + ConstructIdentifier constructId, + ConstructLevelEnum level, + String? targetID, + ) { + constructLevelUpdateStream.add( + ConstructLevelUpdate( + constructId: constructId, + level: level, + targetID: targetID, + ), + ); + } + void _onBlockedConstruct(ConstructIdentifier constructId) { final update = AnalyticsStreamUpdate( blockedConstruct: constructId, ); constructUpdateStream.add(update); } + + void _onNewConstruct(Set constructIds) { + if (constructIds.isEmpty) return; + newConstructsStream.add(constructIds); + } } diff --git a/lib/pangea/analytics_data/analytics_update_events.dart b/lib/pangea/analytics_data/analytics_update_events.dart index 511de32a5..2e7a02ce6 100644 --- a/lib/pangea/analytics_data/analytics_update_events.dart +++ b/lib/pangea/analytics_data/analytics_update_events.dart @@ -1,4 +1,5 @@ import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; sealed class AnalyticsUpdateEvent {} @@ -13,6 +14,17 @@ class MorphUnlockedEvent extends AnalyticsUpdateEvent { MorphUnlockedEvent(this.unlocked); } +class ConstructLevelUpEvent extends AnalyticsUpdateEvent { + final ConstructIdentifier constructId; + final ConstructLevelEnum level; + final String? targetID; + ConstructLevelUpEvent( + this.constructId, + this.level, + this.targetID, + ); +} + class XPGainedEvent extends AnalyticsUpdateEvent { final int points; final String? targetID; @@ -23,3 +35,8 @@ class ConstructBlockedEvent extends AnalyticsUpdateEvent { final ConstructIdentifier blockedConstruct; ConstructBlockedEvent(this.blockedConstruct); } + +class NewConstructsEvent extends AnalyticsUpdateEvent { + final Set newConstructs; + NewConstructsEvent(this.newConstructs); +} diff --git a/lib/pangea/analytics_data/analytics_update_service.dart b/lib/pangea/analytics_data/analytics_update_service.dart index cdb832650..e0cd2d66c 100644 --- a/lib/pangea/analytics_data/analytics_update_service.dart +++ b/lib/pangea/analytics_data/analytics_update_service.dart @@ -23,9 +23,19 @@ class AnalyticsUpdateService { final AnalyticsDataService dataService; - AnalyticsUpdateService(this.dataService); + AnalyticsUpdateService(this.dataService) { + _periodicTimer = Timer.periodic( + const Duration(minutes: 5), + (_) => sendLocalAnalyticsToAnalyticsRoom(), + ); + } Completer? _updateCompleter; + Timer? _periodicTimer; + + void dispose() { + _periodicTimer?.cancel(); + } LanguageModel? get _l2 => MatrixState.pangeaController.userController.userL2; @@ -50,8 +60,9 @@ class AnalyticsUpdateService { Future addAnalytics( String? targetID, - List newConstructs, - ) async { + List newConstructs, { + bool forceUpdate = false, + }) async { await dataService.updateDispatcher.sendConstructAnalyticsUpdate( AnalyticsUpdate( newConstructs, @@ -63,7 +74,9 @@ class AnalyticsUpdateService { final lastUpdated = await dataService.getLastUpdatedAnalytics(); final difference = DateTime.now().difference(lastUpdated ?? DateTime.now()); - if (localConstructCount > _maxMessagesCached || difference.inMinutes > 10) { + if (forceUpdate || + localConstructCount > _maxMessagesCached || + difference.inMinutes > 10) { sendLocalAnalyticsToAnalyticsRoom(); } } @@ -115,12 +128,14 @@ class AnalyticsUpdateService { await future; } - Future sendActivityAnalytics(String roomId) async { - final analyticsRoom = await _getAnalyticsRoom(); + Future sendActivityAnalytics(String roomId, LanguageModel lang) async { + final analyticsRoom = await _getAnalyticsRoom(l2Override: lang); if (analyticsRoom == null) return; await analyticsRoom.addActivityRoomId(roomId); - dataService.updateDispatcher.sendActivityAnalyticsUpdate(roomId); + if (lang.langCodeShort == _l2?.langCodeShort) { + dataService.updateDispatcher.sendActivityAnalyticsUpdate(roomId); + } } Future blockConstruct(ConstructIdentifier constructId) async { diff --git a/lib/pangea/analytics_data/analytics_updater_mixin.dart b/lib/pangea/analytics_data/analytics_updater_mixin.dart index fa90aab51..d24628010 100644 --- a/lib/pangea/analytics_data/analytics_updater_mixin.dart +++ b/lib/pangea/analytics_data/analytics_updater_mixin.dart @@ -3,12 +3,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_update_dispatcher.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/common/utils/overlay.dart'; import 'package:fluffychat/widgets/matrix.dart'; mixin AnalyticsUpdater on State { StreamSubscription? _analyticsSubscription; + StreamSubscription? _constructLevelSubscription; @override void initState() { @@ -16,11 +18,14 @@ mixin AnalyticsUpdater on State { final updater = Matrix.of(context).analyticsDataService.updateDispatcher; _analyticsSubscription = updater.constructUpdateStream.stream.listen(_onAnalyticsUpdate); + _constructLevelSubscription = + updater.constructLevelUpdateStream.stream.listen(_onConstructLevelUp); } @override void dispose() { _analyticsSubscription?.cancel(); + _constructLevelSubscription?.cancel(); super.dispose(); } @@ -38,4 +43,15 @@ mixin AnalyticsUpdater on State { OverlayUtil.showPointsGained(update.targetID!, update.points, context); } } + + void _onConstructLevelUp(ConstructLevelUpdate update) { + if (update.targetID != null) { + OverlayUtil.showGrowthAnimation( + context, + update.targetID!, + update.level, + update.constructId, + ); + } + } } diff --git a/lib/pangea/analytics_data/construct_merge_table.dart b/lib/pangea/analytics_data/construct_merge_table.dart index b84786568..77cb2b065 100644 --- a/lib/pangea/analytics_data/construct_merge_table.dart +++ b/lib/pangea/analytics_data/construct_merge_table.dart @@ -1,5 +1,3 @@ -import 'package:collection/collection.dart'; - import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; @@ -7,7 +5,6 @@ import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; class ConstructMergeTable { Map> lemmaTypeGroups = {}; - Map otherToSpecific = {}; final Map caseInsensitive = {}; void addConstructs( @@ -27,6 +24,8 @@ class ConstructMergeTable { for (final use in uses) { final id = use.identifier; if (exclude.contains(id)) continue; + if (id.category == 'other') continue; + final composite = id.compositeKey; (lemmaTypeGroups[composite] ??= {}).add(id); } @@ -34,6 +33,8 @@ class ConstructMergeTable { for (final use in uses) { final id = use.identifier; if (exclude.contains(id)) continue; + if (id.category == 'other') continue; + final group = lemmaTypeGroups[id.compositeKey]; if (group == null) continue; final matches = group.where((m) => m != id && m.string == id.string); @@ -42,20 +43,6 @@ class ConstructMergeTable { caseInsensitive[id] = id; } } - - for (final use in uses) { - if (exclude.contains(use.identifier)) continue; - final id = use.identifier; - final composite = id.compositeKey; - if (id.category == 'other' && !otherToSpecific.containsKey(id)) { - final specific = lemmaTypeGroups[composite]!.firstWhereOrNull( - (k) => k.category != 'other', - ); - if (specific != null) { - otherToSpecific[id] = caseInsensitive[specific] ?? specific; - } - } - } } void removeConstruct(ConstructIdentifier id) { @@ -68,17 +55,6 @@ class ConstructMergeTable { lemmaTypeGroups.remove(composite); } - if (id.category != 'other') { - final otherId = ConstructIdentifier( - lemma: id.lemma, - type: id.type, - category: 'other', - ); - otherToSpecific.remove(otherId); - } else { - otherToSpecific.remove(id); - } - final caseEntry = caseInsensitive[id]; if (caseEntry != null && caseEntry != id) { caseInsensitive.remove(caseEntry); @@ -87,8 +63,7 @@ class ConstructMergeTable { } ConstructIdentifier resolve(ConstructIdentifier key) { - final specific = otherToSpecific[key] ?? key; - return caseInsensitive[specific] ?? specific; + return caseInsensitive[key] ?? key; } List groupedIds( @@ -96,10 +71,12 @@ class ConstructMergeTable { Set exclude, ) { final keys = []; - if (!exclude.contains(id)) { - keys.add(id); + if (exclude.contains(id) || id.category == 'other') { + return keys; } + keys.add(id); + // if this key maps to a different case variant, include that as well final differentCase = caseInsensitive[id]; if (differentCase != null && differentCase != id) { @@ -108,28 +85,6 @@ class ConstructMergeTable { } } - // if this is an broad ('other') key, find the specific key it maps to - // and include it if available - if (id.category == 'other') { - final specificKey = otherToSpecific[id]; - if (specificKey != null) { - keys.add(specificKey); - } - return keys; - } - - // if this is a specific key, and there existing an 'other' construct - // in the same group, and that 'other' construct maps to this specific key, - // include the 'other' construct as well - final otherEntry = lemmaTypeGroups[id.compositeKey] - ?.firstWhereOrNull((k) => k.category == 'other'); - if (otherEntry == null) { - return keys; - } - - if (otherToSpecific[otherEntry] == id) { - keys.add(otherEntry); - } return keys; } @@ -152,7 +107,6 @@ class ConstructMergeTable { void clear() { lemmaTypeGroups.clear(); - otherToSpecific.clear(); caseInsensitive.clear(); } } diff --git a/lib/pangea/analytics_data/derived_analytics_data_model.dart b/lib/pangea/analytics_data/derived_analytics_data_model.dart index 04a0ccf92..384af243e 100644 --- a/lib/pangea/analytics_data/derived_analytics_data_model.dart +++ b/lib/pangea/analytics_data/derived_analytics_data_model.dart @@ -1,7 +1,6 @@ import 'dart:math'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; -import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; class DerivedAnalyticsDataModel { @@ -30,7 +29,7 @@ class DerivedAnalyticsDataModel { return progress >= 0 ? progress : 0; } - static final double D = Environment.isStagingEnvironment ? 500 : 1500; + static const double D = 300; static int calculateXpWithLevel(int level) { // If level <= 1, XP should be 0 or negative by this math. diff --git a/lib/pangea/analytics_data/level_up_analytics_service.dart b/lib/pangea/analytics_data/level_up_analytics_service.dart index 2004638d7..532a5252c 100644 --- a/lib/pangea/analytics_data/level_up_analytics_service.dart +++ b/lib/pangea/analytics_data/level_up_analytics_service.dart @@ -84,10 +84,19 @@ class LevelUpAnalyticsService { ownMessage: room.client.userID == event.senderId, ); - messages.add({ - 'sent': pangeaEvent.originalSent?.text ?? pangeaEvent.body, - 'written': pangeaEvent.originalWrittenContent, - }); + if (pangeaEvent.isAudioMessage) { + final stt = pangeaEvent.getSpeechToTextLocal(); + if (stt == null) continue; + messages.add({ + 'sent': stt.transcript.text, + 'written': stt.transcript.text, + }); + } else { + messages.add({ + 'sent': pangeaEvent.originalSent?.text ?? pangeaEvent.body, + 'written': pangeaEvent.originalWrittenContent, + }); + } } catch (e, s) { ErrorHandler.logError( e: e, diff --git a/lib/pangea/analytics_details_popup/analytics_details_popup.dart b/lib/pangea/analytics_details_popup/analytics_details_popup.dart index c816dfb80..7bfa27c6a 100644 --- a/lib/pangea/analytics_details_popup/analytics_details_popup.dart +++ b/lib/pangea/analytics_details_popup/analytics_details_popup.dart @@ -2,7 +2,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:diacritic/diacritic.dart'; import 'package:go_router/go_router.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; @@ -16,9 +18,13 @@ import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicators import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; import 'package:fluffychat/pangea/morphs/default_morph_mapping.dart'; import 'package:fluffychat/pangea/morphs/morph_models.dart'; import 'package:fluffychat/pangea/morphs/morph_repo.dart'; +import 'package:fluffychat/pangea/token_info_feedback/show_token_feedback_dialog.dart'; +import 'package:fluffychat/pangea/token_info_feedback/token_info_feedback_request.dart'; import 'package:fluffychat/widgets/matrix.dart'; class ConstructAnalyticsView extends StatefulWidget { @@ -47,6 +53,7 @@ class ConstructAnalyticsViewState extends State { FocusNode searchFocusNode = FocusNode(); ConstructLevelEnum? selectedConstructLevel; StreamSubscription? _constructUpdateSub; + final ValueNotifier reloadNotifier = ValueNotifier(0); @override void initState() { @@ -70,6 +77,7 @@ class ConstructAnalyticsViewState extends State { searchController.dispose(); _constructUpdateSub?.cancel(); searchFocusNode.dispose(); + reloadNotifier.dispose(); super.dispose(); } @@ -106,7 +114,11 @@ class ConstructAnalyticsViewState extends State { vocab = data.values.toList(); vocab!.sort( - (a, b) => a.lemma.toLowerCase().compareTo(b.lemma.toLowerCase()), + (a, b) { + final normalizedA = removeDiacritics(a.lemma).toLowerCase(); + final normalizedB = removeDiacritics(b.lemma).toLowerCase(); + return normalizedA.compareTo(normalizedB); + }, ); } finally { if (mounted) setState(() {}); @@ -156,6 +168,29 @@ class ConstructAnalyticsViewState extends State { }); } + Future onFlagTokenInfo( + PangeaToken token, + LemmaInfoResponse lemmaInfo, + String phonetics, + ) async { + final requestData = TokenInfoFeedbackRequestData( + userId: Matrix.of(context).client.userID!, + detectedLanguage: MatrixState.pangeaController.userController.userL2Code!, + tokens: [token], + selectedToken: 0, + wordCardL1: MatrixState.pangeaController.userController.userL1Code!, + lemmaInfo: lemmaInfo, + phonetics: phonetics, + ); + + await TokenFeedbackUtil.showTokenFeedbackDialog( + context, + requestData: requestData, + langCode: MatrixState.pangeaController.userController.userL2Code!, + onUpdated: () => reloadNotifier.value++, + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -176,79 +211,77 @@ class ConstructAnalyticsViewState extends State { : MorphDetailsView(constructId: widget.construct!) : widget.construct == null ? VocabAnalyticsListView(controller: this) - : VocabDetailsView(constructId: widget.construct!), + : VocabDetailsView( + constructId: widget.construct!, + controller: this, + ), ), ], ), ), ), floatingActionButton: - widget.view == ConstructTypeEnum.vocab && widget.construct == null - ? _buildVocabPracticeButton(context) - : null, + widget.construct == null ? _PracticeButton(view: widget.view) : null, ); } } -Widget _buildVocabPracticeButton(BuildContext context) { - // Check if analytics is loaded first - if (MatrixState - .pangeaController.matrixState.analyticsDataService.isInitializing) { +class _PracticeButton extends StatelessWidget { + final ConstructTypeEnum view; + const _PracticeButton({required this.view}); + + void _showSnackbar(BuildContext context, String message) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + behavior: SnackBarBehavior.floating, + ), + ); + } + + @override + Widget build(BuildContext context) { + final analyticsService = Matrix.of(context).analyticsDataService; + if (analyticsService.isInitializing) { + return FloatingActionButton.extended( + onPressed: () => _showSnackbar( + context, + L10n.of(context).loadingPleaseWait, + ), + label: Text(view.practiceButtonText(context)), + backgroundColor: Theme.of(context).colorScheme.surface, + foregroundColor: + Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ); + } + + final count = analyticsService.numConstructs(view); + final enabled = count >= 10; + return FloatingActionButton.extended( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Loading vocabulary data...', - ), - behavior: SnackBarBehavior.floating, + onPressed: enabled + ? () => context.go("/rooms/analytics/${view.name}/practice") + : () => _showSnackbar( + context, + L10n.of(context).notEnoughToPractice, + ), + backgroundColor: + enabled ? null : Theme.of(context).colorScheme.surfaceContainer, + foregroundColor: enabled + ? null + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + enabled ? Symbols.fitness_center : Icons.lock_outline, + size: 18, ), - ); - }, - label: Text(L10n.of(context).practiceVocab), - backgroundColor: Theme.of(context).colorScheme.surface, - foregroundColor: - Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + const SizedBox(width: 4), + Text(view.practiceButtonText(context)), + ], + ), ); } - - final vocabCount = MatrixState - .pangeaController.matrixState.analyticsDataService - .numConstructs(ConstructTypeEnum.vocab); - final hasEnoughVocab = vocabCount >= 10; - - return FloatingActionButton.extended( - onPressed: hasEnoughVocab - ? () { - context.go( - "/rooms/analytics/${ConstructTypeEnum.vocab.name}/practice", - ); - } - : () { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - L10n.of(context).mustHave10Words, - ), - behavior: SnackBarBehavior.floating, - ), - ); - }, - backgroundColor: - hasEnoughVocab ? null : Theme.of(context).colorScheme.surfaceContainer, - foregroundColor: hasEnoughVocab - ? null - : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!hasEnoughVocab) ...[ - const Icon(Icons.lock_outline, size: 18), - const SizedBox(width: 4), - ], - Text(L10n.of(context).practiceVocab), - ], - ), - ); } diff --git a/lib/pangea/analytics_details_popup/construct_xp_progress_bar.dart b/lib/pangea/analytics_details_popup/construct_xp_progress_bar.dart index 4089a46b1..794cdbe5f 100644 --- a/lib/pangea/analytics_details_popup/construct_xp_progress_bar.dart +++ b/lib/pangea/analytics_details_popup/construct_xp_progress_bar.dart @@ -41,32 +41,16 @@ class ConstructXPProgressBar extends StatelessWidget { return Column( spacing: 8.0, children: [ - LayoutBuilder( - builder: (context, constraints) { - double availableGap = - constraints.maxWidth - (categories.length * iconSize); - const totalPoints = AnalyticsConstants.xpForFlower; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ...categories.map( - (c) { - final gapPercent = (c.xpNeeded / totalPoints); - final gap = availableGap * gapPercent; - availableGap -= gap; - return Container( - width: iconSize + gap, - alignment: Alignment.centerRight, - child: Opacity( - opacity: level == c ? 1.0 : 0.4, - child: c.icon(iconSize), - ), - ); - }, - ), - ], - ); - }, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ...categories.map( + (c) => Opacity( + opacity: level == c ? 1.0 : 0.4, + child: c.icon(iconSize), + ), + ), + ], ), AnimatedProgressBar( height: 20.0, diff --git a/lib/pangea/analytics_details_popup/lemma_usage_dots.dart b/lib/pangea/analytics_details_popup/lemma_usage_dots.dart index aa4e78e83..7ccd9849f 100644 --- a/lib/pangea/analytics_details_popup/lemma_usage_dots.dart +++ b/lib/pangea/analytics_details_popup/lemma_usage_dots.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; @@ -24,16 +23,19 @@ class LemmaUsageDots extends StatelessWidget { }); /// Find lemma uses for the given exercise type, to create dot list - List sortedUses(LearningSkillsEnum category) { - final List useList = []; + List sortedUses(LearningSkillsEnum category) { + final List useList = []; for (final OneConstructUse use in construct.cappedUses) { - if (use.xp == 0) { - continue; - } // If the use type matches the given category, save to list // Usage with positive XP is saved as true, else false if (category == use.useType.skillsEnumType) { - useList.add(use.xp > 0); + useList.add( + switch (use.xp) { + > 0 => AppConfig.success, + < 0 => Colors.red, + _ => Colors.grey[400]!, + }, + ); } } return useList; @@ -42,13 +44,13 @@ class LemmaUsageDots extends StatelessWidget { @override Widget build(BuildContext context) { final List dots = []; - for (final bool use in sortedUses(category)) { + for (final Color color in sortedUses(category)) { dots.add( Container( width: 15.0, height: 15.0, decoration: BoxDecoration( - color: use ? AppConfig.success : Colors.red, + color: color, shape: BoxShape.circle, ), ), @@ -71,9 +73,11 @@ class LemmaUsageDots extends StatelessWidget { ), title: dots.isEmpty ? Text( - L10n.of(context).noDataFound, - style: const TextStyle( - fontStyle: FontStyle.italic, + "-", + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: textColor.withAlpha(100), ), ) : Wrap( diff --git a/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart b/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart index f923391e0..9be8139e0 100644 --- a/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart +++ b/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart @@ -75,6 +75,7 @@ class MorphAnalyticsListView extends StatelessWidget { childCount: controller.features.length, ), ), + const SliverToBoxAdapter(child: SizedBox(height: 75.0)), ], ), ), diff --git a/lib/pangea/analytics_details_popup/morph_details_view.dart b/lib/pangea/analytics_details_popup/morph_details_view.dart index 6b22469a7..ddfe948d4 100644 --- a/lib/pangea/analytics_details_popup/morph_details_view.dart +++ b/lib/pangea/analytics_details_popup/morph_details_view.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_usage_content.dart'; +import 'package:fluffychat/pangea/analytics_details_popup/construct_xp_progress_bar.dart'; import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; -import 'package:fluffychat/pangea/lemmas/construct_xp_widget.dart'; import 'package:fluffychat/pangea/morphs/morph_feature_display.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/morph_tag_display.dart'; @@ -54,11 +54,7 @@ class MorphDetailsView extends StatelessWidget { ), const Divider(), if (construct != null) ...[ - ConstructXpWidget( - icon: construct.lemmaCategory.icon(30.0), - level: construct.lemmaCategory, - points: construct.points, - ), + ConstructXPProgressBar(construct: construct.id), Padding( padding: const EdgeInsets.all(20.0), child: AnalyticsDetailsUsageContent( diff --git a/lib/pangea/analytics_details_popup/morph_meaning_widget.dart b/lib/pangea/analytics_details_popup/morph_meaning_widget.dart index fd34f5c9d..d9c619b32 100644 --- a/lib/pangea/analytics_details_popup/morph_meaning_widget.dart +++ b/lib/pangea/analytics_details_popup/morph_meaning_widget.dart @@ -16,12 +16,14 @@ class MorphMeaningWidget extends StatefulWidget { final MorphFeaturesEnum feature; final String tag; final TextStyle? style; + final bool blankErrorFeedback; const MorphMeaningWidget({ super.key, required this.feature, required this.tag, this.style, + this.blankErrorFeedback = false, }); @override @@ -91,12 +93,13 @@ class MorphMeaningWidgetState extends State { ); if (result.isError) { - return L10n.of(context).meaningNotFound; + return widget.blankErrorFeedback ? '' : L10n.of(context).meaningNotFound; } final morph = result.result!.getFeatureByCode(widget.feature.name); final data = morph?.getTagByCode(widget.tag); - return data?.l1Description ?? L10n.of(context).meaningNotFound; + return data?.l1Description ?? + (widget.blankErrorFeedback ? '' : L10n.of(context).meaningNotFound); } void _toggleEditMode(bool value) => setState(() => _editMode = value); diff --git a/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart b/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart index 6de39e654..79dda8441 100644 --- a/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart +++ b/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup.dart'; import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_usage_content.dart'; import 'package:fluffychat/pangea/analytics_details_popup/construct_xp_progress_bar.dart'; import 'package:fluffychat/pangea/analytics_details_popup/word_text_with_audio_button.dart'; @@ -12,8 +13,6 @@ 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/lemmas/lemma.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; -import 'package:fluffychat/pangea/token_info_feedback/show_token_feedback_dialog.dart'; -import 'package:fluffychat/pangea/token_info_feedback/token_info_feedback_request.dart'; import 'package:fluffychat/pangea/toolbar/word_card/word_zoom_widget.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; @@ -23,10 +22,12 @@ import 'package:fluffychat/widgets/matrix.dart'; /// Displays information about selected lemma, and its usage class VocabDetailsView extends StatelessWidget { final ConstructIdentifier constructId; + final ConstructAnalyticsViewState controller; const VocabDetailsView({ super.key, required this.constructId, + required this.controller, }); Future _blockLemma(BuildContext context) async { @@ -82,35 +83,25 @@ class VocabDetailsView extends StatelessWidget { maxWidth: 600.0, showBorder: false, child: Column( - spacing: 16.0, + spacing: 20.0, children: [ - WordZoomWidget( - token: tokenText, - langCode: - MatrixState.pangeaController.userController.userL2Code!, - construct: constructId, - onClose: Navigator.of(context).pop, - onFlagTokenInfo: - (LemmaInfoResponse lemmaInfo, String phonetics) { - final requestData = TokenInfoFeedbackRequestData( - userId: Matrix.of(context).client.userID!, - detectedLanguage: - MatrixState.pangeaController.userController.userL2Code!, - tokens: [token], - selectedToken: 0, - wordCardL1: - MatrixState.pangeaController.userController.userL1Code!, - lemmaInfo: lemmaInfo, - phonetics: phonetics, - ); - - TokenFeedbackUtil.showTokenFeedbackDialog( - context, - requestData: requestData, - langCode: - MatrixState.pangeaController.userController.userL2Code!, - ); - }, + const SizedBox(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: WordZoomWidget( + token: tokenText, + langCode: + MatrixState.pangeaController.userController.userL2Code!, + construct: constructId, + onClose: Navigator.of(context).pop, + onFlagTokenInfo: ( + LemmaInfoResponse lemmaInfo, + String phonetics, + ) => + controller.onFlagTokenInfo(token, lemmaInfo, phonetics), + reloadNotifier: controller.reloadNotifier, + maxWidth: double.infinity, + ), ), if (construct != null) Column( diff --git a/lib/pangea/analytics_details_popup/vocab_analytics_list_tile.dart b/lib/pangea/analytics_details_popup/vocab_analytics_list_tile.dart index 564069291..91f81c172 100644 --- a/lib/pangea/analytics_details_popup/vocab_analytics_list_tile.dart +++ b/lib/pangea/analytics_details_popup/vocab_analytics_list_tile.dart @@ -77,7 +77,7 @@ class VocabAnalyticsListTile extends StatelessWidget { }, ), Container( - alignment: Alignment.topCenter, + alignment: Alignment.center, padding: const EdgeInsets.only(top: 4), height: (maxWidth - padding * 2) * 0.4, child: ShrinkableText( diff --git a/lib/pangea/analytics_details_popup/vocab_analytics_list_view.dart b/lib/pangea/analytics_details_popup/vocab_analytics_list_view.dart index 45181eea2..541b3ff9c 100644 --- a/lib/pangea/analytics_details_popup/vocab_analytics_list_view.dart +++ b/lib/pangea/analytics_details_popup/vocab_analytics_list_view.dart @@ -115,6 +115,7 @@ class VocabAnalyticsListView extends StatelessWidget { } } + final filteredVocab = _filteredVocab; return Column( children: [ AnimatedContainer( @@ -178,7 +179,7 @@ class VocabAnalyticsListView extends StatelessWidget { ), // Grid of vocab tiles - if (vocab == null) + if (filteredVocab == null) const SliverFillRemaining( hasScrollBody: false, child: Center( @@ -186,7 +187,7 @@ class VocabAnalyticsListView extends StatelessWidget { ), ) else - vocab.isEmpty + filteredVocab.isEmpty ? SliverToBoxAdapter( child: controller.selectedConstructLevel != null ? Padding( @@ -209,7 +210,7 @@ class VocabAnalyticsListView extends StatelessWidget { ), delegate: SliverChildBuilderDelegate( (context, index) { - final vocabItem = _filteredVocab![index]; + final vocabItem = filteredVocab[index]; return VocabAnalyticsListTile( onTap: () { TtsController.tryToSpeak( @@ -232,7 +233,7 @@ class VocabAnalyticsListView extends StatelessWidget { selected: vocabItem.id == selectedConstruct, ); }, - childCount: _filteredVocab!.length, + childCount: filteredVocab.length, ), ), const SliverToBoxAdapter(child: SizedBox(height: 75.0)), diff --git a/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart b/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart index f616a1be6..ea0739580 100644 --- a/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart +++ b/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:csv/csv.dart'; @@ -44,12 +45,18 @@ class AnalyticsDownloadDialogState extends State { String? get _statusText { if (_downloading) return L10n.of(context).downloading; - if (_downloaded) return L10n.of(context).downloadComplete; + if (_downloaded) return L10n.of(context).downloadInitiated; return null; } void _setDownloadType(DownloadType type) { - if (mounted) setState(() => _downloadType = type); + if (mounted) { + setState(() { + _downloadType = type; + _downloaded = false; + _error = null; + }); + } } Future _downloadAnalytics() async { @@ -426,7 +433,8 @@ class AnalyticsDownloadDialogState extends State { padding: const EdgeInsets.all(8.0), child: SegmentedButton( selected: {_downloadType}, - onSelectionChanged: (c) => _setDownloadType(c.first), + onSelectionChanged: + _downloading ? null : (c) => _setDownloadType(c.first), segments: [ ButtonSegment( value: DownloadType.csv, @@ -462,6 +470,21 @@ class AnalyticsDownloadDialogState extends State { ) : const SizedBox(), ), + AnimatedSize( + duration: FluffyThemes.animationDuration, + child: kIsWeb && _downloaded + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + L10n.of(context).webDownloadPermissionMessage, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).disabledColor, + ), + ), + ) + : const SizedBox(), + ), AnimatedSize( duration: FluffyThemes.animationDuration, child: _error != null diff --git a/lib/pangea/analytics_downloads/space_analytics_summary_model.dart b/lib/pangea/analytics_downloads/space_analytics_summary_model.dart index ddccf10e9..a176fff8d 100644 --- a/lib/pangea/analytics_downloads/space_analytics_summary_model.dart +++ b/lib/pangea/analytics_downloads/space_analytics_summary_model.dart @@ -97,7 +97,6 @@ class SpaceAnalyticsSummaryModel { Set blockedConstructs, int numCompletedActivities, ) { - int totalXP = 0; int numWordsTyped = 0; int numChoicesCorrect = 0; int numChoicesIncorrect = 0; @@ -114,7 +113,9 @@ class SpaceAnalyticsSummaryModel { mergeTable.addConstructsByUses(e.content.uses, blockedConstructs); for (final use in e.content.uses) { - totalXP += use.xp; + final id = use.identifier; + if (blockedConstructs.contains(id)) continue; + allUses.add(use); if (use.useType.summaryEnumType == @@ -132,8 +133,7 @@ class SpaceAnalyticsSummaryModel { sentEventIds.add(use.metadata.eventId!); } - final id = use.identifier; - final existing = id.type == ConstructTypeEnum.vocab + final existing = use.identifier.type == ConstructTypeEnum.vocab ? aggregatedVocab[id] : aggregatedMorph[id]; @@ -189,6 +189,10 @@ class SpaceAnalyticsSummaryModel { } } + final totalXP = cleanedVocab.values + .fold(0, (sum, entry) => sum + entry.points) + + cleanedMorph.values.fold(0, (sum, entry) => sum + entry.points); + final level = DerivedAnalyticsDataModel.calculateLevelWithXp(totalXP); final uniqueVocabCount = cleanedVocab.length; final uniqueMorphCount = cleanedMorph.length; diff --git a/lib/pangea/analytics_misc/analytics_constants.dart b/lib/pangea/analytics_misc/analytics_constants.dart index d680c1fd7..96fb33a13 100644 --- a/lib/pangea/analytics_misc/analytics_constants.dart +++ b/lib/pangea/analytics_misc/analytics_constants.dart @@ -2,7 +2,7 @@ class AnalyticsConstants { static const int xpPerLevel = 500; static const int vocabUseMaxXP = 30; static const int morphUseMaxXP = 500; - static const int xpForGreens = 30; + static const int xpForGreens = 50; static const int xpForFlower = 100; static const String seedSvgFileName = "Seed.svg"; static const String leafSvgFileName = "Leaf.svg"; diff --git a/lib/pangea/analytics_misc/client_analytics_extension.dart b/lib/pangea/analytics_misc/client_analytics_extension.dart index e4d36c11d..ba2be21d1 100644 --- a/lib/pangea/analytics_misc/client_analytics_extension.dart +++ b/lib/pangea/analytics_misc/client_analytics_extension.dart @@ -6,10 +6,12 @@ import 'package:flutter/foundation.dart'; import 'package:collection/collection.dart'; import 'package:matrix/matrix.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/languages/language_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -76,6 +78,14 @@ extension AnalyticsClientExtension on Client { topic: "This room stores learning analytics for $userID.", preset: CreateRoomPreset.publicChat, visibility: Visibility.private, + initialState: [ + StateEvent( + type: EventTypes.RoomJoinRules, + content: { + ModelKey.joinRule: JoinRules.knock.name, + }, + ), + ], ); if (getRoomById(roomID) == null) { // Wait for room actually appears in sync @@ -169,4 +179,38 @@ extension AnalyticsClientExtension on Client { ) .isNotEmpty; } + + Future getEventByConstructUse( + OneConstructUse use, + ) async { + if (use.metadata.eventId == null || use.metadata.roomId == null) { + return null; + } + + final room = getRoomById(use.metadata.roomId!); + if (room == null) return null; + + try { + final event = await room.getEventById(use.metadata.eventId!); + if (event == null) return null; + + final timeline = await room.getTimeline(); + return PangeaMessageEvent( + event: event, + timeline: timeline, + ownMessage: event.senderId == userID, + ); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "roomID": use.metadata.roomId, + "eventID": use.metadata.eventId, + "userID": userID, + }, + ); + return null; + } + } } diff --git a/lib/pangea/analytics_misc/construct_type_enum.dart b/lib/pangea/analytics_misc/construct_type_enum.dart index 910d3cfbe..ad7e3f0a2 100644 --- a/lib/pangea/analytics_misc/construct_type_enum.dart +++ b/lib/pangea/analytics_misc/construct_type_enum.dart @@ -4,20 +4,15 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; -import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; -import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart'; enum ConstructTypeEnum { /// for vocabulary words vocab, /// for morphs, actually called "Grammar" in the UI... :P - morph, -} + morph; -extension ConstructExtension on ConstructTypeEnum { String get string { switch (this) { case ConstructTypeEnum.vocab: @@ -37,25 +32,6 @@ extension ConstructExtension on ConstructTypeEnum { } } - int get maxXPPerLemma { - switch (this) { - case ConstructTypeEnum.vocab: - return AnalyticsConstants.vocabUseMaxXP; - case ConstructTypeEnum.morph: - return AnalyticsConstants.morphUseMaxXP; - } - } - - String? getDisplayCopy(String category, BuildContext context) { - switch (this) { - case ConstructTypeEnum.morph: - return MorphFeaturesEnumExtension.fromString(category) - .getDisplayCopy(context); - case ConstructTypeEnum.vocab: - return getVocabCategoryName(category, context); - } - } - ProgressIndicatorEnum get indicator { switch (this) { case ConstructTypeEnum.morph: @@ -64,9 +40,17 @@ extension ConstructExtension on ConstructTypeEnum { return ProgressIndicatorEnum.wordsUsed; } } -} -class ConstructTypeUtil { + String practiceButtonText(BuildContext context) { + final l10n = L10n.of(context); + switch (this) { + case ConstructTypeEnum.vocab: + return l10n.practiceVocab; + case ConstructTypeEnum.morph: + return l10n.practiceGrammar; + } + } + static ConstructTypeEnum fromString(String? string) { switch (string) { case 'v': diff --git a/lib/pangea/analytics_misc/construct_use_model.dart b/lib/pangea/analytics_misc/construct_use_model.dart index d1ef0eaeb..e3a3ddda5 100644 --- a/lib/pangea/analytics_misc/construct_use_model.dart +++ b/lib/pangea/analytics_misc/construct_use_model.dart @@ -140,8 +140,8 @@ class ConstructUses { _uses.sort((a, b) => a.timeStamp.compareTo(b.timeStamp)); } - void addUse(OneConstructUse use) { - _uses.add(use); + void addUses(List uses) { + _uses.addAll(uses); _sortUses(); } diff --git a/lib/pangea/analytics_misc/construct_use_type_enum.dart b/lib/pangea/analytics_misc/construct_use_type_enum.dart index 9dd3ba4c0..ec5cae6d8 100644 --- a/lib/pangea/analytics_misc/construct_use_type_enum.dart +++ b/lib/pangea/analytics_misc/construct_use_type_enum.dart @@ -82,6 +82,14 @@ enum ConstructUseTypeEnum { // vocab lemma audio activity corLA, incLA, + + // grammar category activity + corGC, + incGC, + + // grammar error activity + corGE, + incGE, } extension ConstructUseTypeExtension on ConstructUseTypeEnum { @@ -163,6 +171,14 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { return L10n.of(context).constructUseCorLADesc; case ConstructUseTypeEnum.incLA: return L10n.of(context).constructUseIncLADesc; + case ConstructUseTypeEnum.corGC: + return L10n.of(context).constructUseCorGCDesc; + case ConstructUseTypeEnum.incGC: + return L10n.of(context).constructUseIncGCDesc; + case ConstructUseTypeEnum.corGE: + return L10n.of(context).constructUseCorGEDesc; + case ConstructUseTypeEnum.incGE: + return L10n.of(context).constructUseIncGEDesc; } } @@ -203,6 +219,10 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corM: case ConstructUseTypeEnum.incM: case ConstructUseTypeEnum.ignM: + case ConstructUseTypeEnum.corGC: + case ConstructUseTypeEnum.incGC: + case ConstructUseTypeEnum.corGE: + case ConstructUseTypeEnum.incGE: return ActivityTypeEnum.morphId.icon; case ConstructUseTypeEnum.em: return ActivityTypeEnum.emoji.icon; @@ -235,6 +255,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corM: case ConstructUseTypeEnum.corLM: case ConstructUseTypeEnum.corLA: + case ConstructUseTypeEnum.corGC: + case ConstructUseTypeEnum.corGE: return 5; case ConstructUseTypeEnum.pvm: @@ -275,6 +297,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.incM: case ConstructUseTypeEnum.incLM: case ConstructUseTypeEnum.incLA: + case ConstructUseTypeEnum.incGC: + case ConstructUseTypeEnum.incGE: return -1; case ConstructUseTypeEnum.incPA: @@ -326,6 +350,10 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corLA: case ConstructUseTypeEnum.incLA: case ConstructUseTypeEnum.bonus: + case ConstructUseTypeEnum.corGC: + case ConstructUseTypeEnum.incGC: + case ConstructUseTypeEnum.corGE: + case ConstructUseTypeEnum.incGE: return false; } } @@ -369,6 +397,10 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.click: case ConstructUseTypeEnum.corLM: case ConstructUseTypeEnum.incLM: + case ConstructUseTypeEnum.corGC: + case ConstructUseTypeEnum.incGC: + case ConstructUseTypeEnum.corGE: + case ConstructUseTypeEnum.incGE: return LearningSkillsEnum.reading; case ConstructUseTypeEnum.pvm: return LearningSkillsEnum.speaking; @@ -398,6 +430,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corMM: case ConstructUseTypeEnum.corLM: case ConstructUseTypeEnum.corLA: + case ConstructUseTypeEnum.corGC: + case ConstructUseTypeEnum.corGE: return SpaceAnalyticsSummaryEnum.numChoicesCorrect; case ConstructUseTypeEnum.incIt: @@ -410,6 +444,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.incMM: case ConstructUseTypeEnum.incLM: case ConstructUseTypeEnum.incLA: + case ConstructUseTypeEnum.incGC: + case ConstructUseTypeEnum.incGE: return SpaceAnalyticsSummaryEnum.numChoicesIncorrect; case ConstructUseTypeEnum.ignIt: diff --git a/lib/pangea/analytics_misc/constructs_model.dart b/lib/pangea/analytics_misc/constructs_model.dart index 5f0ee420c..cb053585b 100644 --- a/lib/pangea/analytics_misc/constructs_model.dart +++ b/lib/pangea/analytics_misc/constructs_model.dart @@ -117,7 +117,7 @@ class OneConstructUse { debugger(when: kDebugMode && json['constructType'] == null); final ConstructTypeEnum constructType = json['constructType'] != null - ? ConstructTypeUtil.fromString(json['constructType']) + ? ConstructTypeEnum.fromString(json['constructType']) : ConstructTypeEnum.vocab; final useType = ConstructUseTypeUtil.fromString(json['useType']); diff --git a/lib/pangea/analytics_misc/example_message_util.dart b/lib/pangea/analytics_misc/example_message_util.dart new file mode 100644 index 000000000..f9c5713aa --- /dev/null +++ b/lib/pangea/analytics_misc/example_message_util.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; + +class ExampleMessageUtil { + static Future?> getExampleMessage( + ConstructUses construct, + Client client, { + String? form, + }) async { + for (final use in construct.cappedUses) { + if (form != null && use.form != form) continue; + + final event = await client.getEventByConstructUse(use); + if (event == null) continue; + + final spans = _buildExampleMessage(use.form, event); + if (spans != null) return spans; + } + + return null; + } + + static Future>> getExampleMessages( + ConstructUses construct, + Client client, + int maxMessages, + ) async { + final List> allSpans = []; + for (final use in construct.cappedUses) { + if (allSpans.length >= maxMessages) break; + final event = await client.getEventByConstructUse(use); + if (event == null) continue; + + final spans = _buildExampleMessage(use.form, event); + if (spans != null) { + allSpans.add(spans); + } + } + return allSpans; + } + + static List? _buildExampleMessage( + String? form, + PangeaMessageEvent messageEvent, + ) { + String? text; + List? tokens; + int targetTokenIndex = -1; + + if (messageEvent.isAudioMessage) { + final stt = messageEvent.getSpeechToTextLocal(); + if (stt == null) return null; + + tokens = stt.transcript.sttTokens.map((t) => t.token).toList(); + targetTokenIndex = tokens.indexWhere((t) => t.text.content == form); + text = stt.transcript.text; + } else { + tokens = messageEvent.messageDisplayRepresentation?.tokens; + if (tokens == null || tokens.isEmpty) return null; + + targetTokenIndex = tokens.indexWhere((t) => t.text.content == form); + text = messageEvent.messageDisplayText; + } + + if (targetTokenIndex == -1) { + return null; + } + + final targetToken = tokens[targetTokenIndex]; + + const maxContextChars = 100; + + final targetStart = targetToken.text.offset; + final targetEnd = targetStart + targetToken.text.content.characters.length; + + final totalChars = text.characters.length; + + final beforeAvailable = targetStart; + final afterAvailable = totalChars - targetEnd; + + // ---------- Dynamic budget split ---------- + int beforeBudget = maxContextChars ~/ 2; + int afterBudget = maxContextChars - beforeBudget; + + if (beforeAvailable < beforeBudget) { + afterBudget += beforeBudget - beforeAvailable; + beforeBudget = beforeAvailable; + } else if (afterAvailable < afterBudget) { + beforeBudget += afterBudget - afterAvailable; + afterBudget = afterAvailable; + } + + // ---------- BEFORE ---------- + int beforeStartOffset = 0; + bool trimmedBefore = false; + + if (beforeAvailable > beforeBudget) { + final desiredStart = targetStart - beforeBudget; + + for (int i = 0; i < targetTokenIndex; i++) { + final token = tokens[i]; + final tokenEnd = + token.text.offset + token.text.content.characters.length; + + if (tokenEnd > desiredStart) { + beforeStartOffset = token.text.offset; + trimmedBefore = true; + break; + } + } + } + + final before = text.characters + .skip(beforeStartOffset) + .take(targetStart - beforeStartOffset) + .toString(); + + // ---------- AFTER ---------- + int afterEndOffset = totalChars; + bool trimmedAfter = false; + + if (afterAvailable > afterBudget) { + final desiredEnd = targetEnd + afterBudget; + + for (int i = targetTokenIndex + 1; i < tokens.length; i++) { + final token = tokens[i]; + if (token.text.offset >= desiredEnd) { + afterEndOffset = token.text.offset; + trimmedAfter = true; + break; + } + } + } + + final after = text.characters + .skip(targetEnd) + .take(afterEndOffset - targetEnd) + .toString() + .trimRight(); + + return [ + if (trimmedBefore) const TextSpan(text: '… '), + TextSpan(text: before), + TextSpan( + text: targetToken.text.content, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan(text: after), + if (trimmedAfter) const TextSpan(text: '…'), + ]; + } +} diff --git a/lib/pangea/analytics_misc/gain_points_animation.dart b/lib/pangea/analytics_misc/gain_points_animation.dart index 221828fb9..1580a73a8 100644 --- a/lib/pangea/analytics_misc/gain_points_animation.dart +++ b/lib/pangea/analytics_misc/gain_points_animation.dart @@ -41,7 +41,7 @@ class PointsGainedAnimationState extends State @override void initState() { super.initState(); - if (widget.points == 0) return; + if (_points == 0) return; _controller = AnimationController( duration: const Duration(milliseconds: duration), @@ -77,18 +77,19 @@ class PointsGainedAnimationState extends State ); } + int get _points => widget.points.clamp(-25, 25); + void initParticleTrajectories() { - for (int i = 0; i < widget.points.abs(); i++) { - final angle = - (i - widget.points.abs() / 2) / widget.points.abs() * (pi / 3) + - (_random.nextDouble() - 0.5) * pi / 6 + - pi / 2; + for (int i = 0; i < _points.abs(); i++) { + final angle = (i - _points.abs() / 2) / _points.abs() * (pi / 3) + + (_random.nextDouble() - 0.5) * pi / 6 + + pi / 2; final speedMultiplier = 0.75 + _random.nextDouble() / 4; // Random speed multiplier. final speed = _particleSpeed * speedMultiplier * - (widget.points > 0 ? 2 : 1); // Exponential speed. + (_points > 0 ? 2 : 1); // Exponential speed. _trajectories.add( Offset( speed * cos(angle) * (widget.invert ? -1 : 1), @@ -106,7 +107,7 @@ class PointsGainedAnimationState extends State @override Widget build(BuildContext context) { - if (widget.points == 0 || + if (_points == 0 || _controller == null || _fadeAnimation == null || _progressAnimation == null) { @@ -118,10 +119,10 @@ class PointsGainedAnimationState extends State return const SizedBox(); } - final textColor = widget.points > 0 ? gainColor : loseColor; + final textColor = _points > 0 ? gainColor : loseColor; final plusWidget = Text( - widget.points > 0 ? "+" : "-", + _points > 0 ? "+" : "-", style: BotStyle.text( context, big: true, @@ -139,7 +140,7 @@ class PointsGainedAnimationState extends State child: IgnorePointer( ignoring: _controller!.isAnimating, child: Stack( - children: List.generate(widget.points.abs(), (index) { + children: List.generate(_points.abs(), (index) { return AnimatedBuilder( animation: _controller!, builder: (context, child) { diff --git a/lib/pangea/analytics_misc/growth_animation.dart b/lib/pangea/analytics_misc/growth_animation.dart new file mode 100644 index 000000000..7cd42c4fa --- /dev/null +++ b/lib/pangea/analytics_misc/growth_animation.dart @@ -0,0 +1,97 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +/// Tracks active growth animations for offset calculation +class GrowthAnimationTracker { + static int _activeCount = 0; + + static int get activeCount => _activeCount; + + static double? startAnimation() { + if (_activeCount >= 5) return null; + final index = _activeCount; + _activeCount++; + if (index == 0) return 0; + final side = index.isOdd ? 1 : -1; + return side * ((index + 1) ~/ 2) * 20.0; + } + + static void endAnimation() { + _activeCount = (_activeCount - 1).clamp(0, 999); + } +} + +class GrowthAnimation extends StatefulWidget { + final String targetID; + final ConstructLevelEnum level; + + const GrowthAnimation({ + super.key, + required this.targetID, + required this.level, + }); + + @override + State createState() => _GrowthAnimationState(); +} + +class _GrowthAnimationState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final double? _horizontalOffset; + late final double _wiggleAmplitude; + late final double _wiggleFrequency; + final Random _random = Random(); + + static const _durationMs = 1600; + static const _riseDistance = 72.0; + + @override + void initState() { + super.initState(); + _horizontalOffset = GrowthAnimationTracker.startAnimation(); + _wiggleAmplitude = 4.0 + _random.nextDouble() * 4.0; + _wiggleFrequency = 1.5 + _random.nextDouble() * 1.0; + + _controller = AnimationController( + duration: const Duration(milliseconds: _durationMs), + vsync: this, + )..forward().then((_) { + if (mounted) { + MatrixState.pAnyState.closeOverlay(widget.targetID); + } + }); + } + + @override + void dispose() { + GrowthAnimationTracker.endAnimation(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_horizontalOffset == null) return const SizedBox.shrink(); + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final t = _controller.value; + final dy = -_riseDistance * Curves.easeOut.transform(t); + final opacity = t < 0.5 ? t * 2 : (1.0 - t) * 2; + final wiggle = sin(t * pi * _wiggleFrequency) * _wiggleAmplitude; + return Transform.translate( + offset: Offset(_horizontalOffset! + wiggle, dy), + child: Opacity( + opacity: opacity.clamp(0.0, 1.0), + child: widget.level.icon(24), + ), + ); + }, + ); + } +} diff --git a/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart b/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart index 65f4a7a09..0719943e3 100644 --- a/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart +++ b/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics_details_popup/vocab_analytics_list_tile.dart'; import 'package:fluffychat/pangea/analytics_misc/analytics_navigation_util.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/constructs/construct_identifier.dart'; diff --git a/lib/pangea/analytics_page/activity_archive.dart b/lib/pangea/analytics_page/activity_archive.dart index ef5e2f6f9..c76bdff96 100644 --- a/lib/pangea/analytics_page/activity_archive.dart +++ b/lib/pangea/analytics_page/activity_archive.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:collection/collection.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; @@ -25,47 +26,58 @@ class ActivityArchive extends StatelessWidget { @override Widget build(BuildContext context) { - final Room? analyticsRoom = Matrix.of(context).client.analyticsRoomLocal(); - final archive = analyticsRoom?.archivedActivities ?? []; - final selectedRoomId = GoRouterState.of(context).pathParameters['roomid']; - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsetsGeometry.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const LearningProgressIndicators( - selected: ProgressIndicatorEnum.activities, - ), - Expanded( - child: MaxWidthBody( - withScrolling: false, - child: ListView.builder( - physics: const ClampingScrollPhysics(), - itemCount: archive.length + 1, - itemBuilder: (BuildContext context, int i) { - if (i == 0) { - return InstructionsInlineTooltip( - instructionsEnum: archive.isEmpty - ? InstructionsEnum.noSavedActivitiesYet - : InstructionsEnum.activityAnalyticsList, - padding: const EdgeInsets.all(8.0), - ); - } - i--; - return AnalyticsActivityItem( - room: archive[i], - selected: archive[i].id == selectedRoomId, - ); - }, + return StreamBuilder( + stream: Matrix.of(context) + .analyticsDataService + .updateDispatcher + .activityAnalyticsStream + .stream, + builder: (context, _) { + final Room? analyticsRoom = + Matrix.of(context).client.analyticsRoomLocal(); + final archive = analyticsRoom?.archivedActivities ?? []; + final selectedRoomId = + GoRouterState.of(context).pathParameters['roomid']; + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsetsGeometry.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const LearningProgressIndicators( + selected: ProgressIndicatorEnum.activities, ), - ), + Expanded( + child: MaxWidthBody( + withScrolling: false, + child: ListView.builder( + physics: const ClampingScrollPhysics(), + itemCount: archive.length + 1, + itemBuilder: (BuildContext context, int i) { + if (i == 0) { + return InstructionsInlineTooltip( + instructionsEnum: archive.isEmpty + ? InstructionsEnum.noSavedActivitiesYet + : InstructionsEnum.activityAnalyticsList, + padding: const EdgeInsets.all(8.0), + ); + } + i--; + return AnalyticsActivityItem( + room: archive[i], + selected: archive[i].id == selectedRoomId, + ); + }, + ), + ), + ), + ], ), - ], + ), ), - ), - ), + ); + }, ); } } @@ -82,7 +94,11 @@ class AnalyticsActivityItem extends StatelessWidget { @override Widget build(BuildContext context) { final objective = room.activityPlan?.learningObjective ?? ''; - final cefrLevel = room.activityPlan?.req.cefrLevel; + final cefrLevel = room.activitySummary?.summary?.participants + .firstWhereOrNull( + (p) => p.participantId == room.client.userID, + ) + ?.cefrLevel; final theme = Theme.of(context); return Padding( @@ -122,7 +138,7 @@ class AnalyticsActivityItem extends StatelessWidget { vertical: 4, ), child: Text( - cefrLevel.string, + cefrLevel.toUpperCase(), style: const TextStyle(fontSize: 14.0), ), ) diff --git a/lib/pangea/analytics_practice/analytics_practice_constants.dart b/lib/pangea/analytics_practice/analytics_practice_constants.dart new file mode 100644 index 000000000..a8e06083c --- /dev/null +++ b/lib/pangea/analytics_practice/analytics_practice_constants.dart @@ -0,0 +1,5 @@ +class AnalyticsPracticeConstants { + static const int timeForBonus = 60; + static const int practiceGroupSize = 10; + static const int errorBufferSize = 5; +} diff --git a/lib/pangea/analytics_practice/analytics_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart new file mode 100644 index 000000000..1bcc52d01 --- /dev/null +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -0,0 +1,561 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.dart'; +import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.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_misc/example_message_util.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_repo.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_view.dart'; +import 'package:fluffychat/pangea/common/utils/async_state.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart'; +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'; +import 'package:fluffychat/widgets/matrix.dart'; + +class SelectedMorphChoice { + final MorphFeaturesEnum feature; + final String tag; + + const SelectedMorphChoice({ + required this.feature, + required this.tag, + }); +} + +class VocabPracticeChoice { + final String choiceId; + final String choiceText; + final String? choiceEmoji; + + const VocabPracticeChoice({ + required this.choiceId, + required this.choiceText, + this.choiceEmoji, + }); +} + +class _PracticeQueueEntry { + final MessageActivityRequest request; + final Completer completer; + + _PracticeQueueEntry({ + required this.request, + required this.completer, + }); +} + +class SessionLoader extends AsyncLoader { + final ConstructTypeEnum type; + SessionLoader({required this.type}); + + @override + Future fetch() => + AnalyticsPracticeSessionRepo.get(type); +} + +class AnalyticsPractice extends StatefulWidget { + static bool bypassExitConfirmation = true; + + final ConstructTypeEnum type; + const AnalyticsPractice({ + super.key, + required this.type, + }); + + @override + AnalyticsPracticeState createState() => AnalyticsPracticeState(); +} + +class AnalyticsPracticeState extends State + with AnalyticsUpdater { + late final SessionLoader _sessionLoader; + + final ValueNotifier> + activityState = ValueNotifier(const AsyncState.idle()); + + final Queue<_PracticeQueueEntry> _queue = Queue(); + + final ValueNotifier activityTarget = + ValueNotifier(null); + + final ValueNotifier progressNotifier = ValueNotifier(0.0); + final ValueNotifier enableChoicesNotifier = ValueNotifier(true); + + final ValueNotifier selectedMorphChoice = + ValueNotifier(null); + + final ValueNotifier hintPressedNotifier = ValueNotifier(false); + + final Map> _choiceTexts = {}; + final Map> _choiceEmojis = {}; + + StreamSubscription? _languageStreamSubscription; + + @override + void initState() { + super.initState(); + _sessionLoader = SessionLoader(type: widget.type); + _startSession(); + _languageStreamSubscription = MatrixState + .pangeaController.userController.languageStream.stream + .listen((_) => _onLanguageUpdate()); + } + + @override + void dispose() { + _languageStreamSubscription?.cancel(); + _sessionLoader.dispose(); + activityState.dispose(); + activityTarget.dispose(); + progressNotifier.dispose(); + enableChoicesNotifier.dispose(); + selectedMorphChoice.dispose(); + hintPressedNotifier.dispose(); + super.dispose(); + } + + MultipleChoicePracticeActivityModel? get _currentActivity => + activityState.value is AsyncLoaded + ? (activityState.value + as AsyncLoaded) + .value + : null; + + bool get _isComplete => _sessionLoader.value?.isComplete ?? false; + + ValueNotifier> get sessionState => + _sessionLoader.state; + + AnalyticsDataService get _analyticsService => + Matrix.of(context).analyticsDataService; + + List filteredChoices( + MultipleChoicePracticeActivityModel activity, + ) { + final content = activity.multipleChoiceContent; + final choices = content.choices.toList(); + final answer = content.answers.first; + final filtered = []; + + final seenTexts = {}; + for (final id in choices) { + final text = getChoiceText(activity.storageKey, id); + + if (seenTexts.contains(text)) { + if (id != answer) { + continue; + } + + final index = filtered.indexWhere( + (choice) => choice.choiceText == text, + ); + if (index != -1) { + filtered[index] = VocabPracticeChoice( + choiceId: id, + choiceText: text, + choiceEmoji: getChoiceEmoji(activity.storageKey, id), + ); + } + continue; + } + + seenTexts.add(text); + filtered.add( + VocabPracticeChoice( + choiceId: id, + choiceText: text, + choiceEmoji: getChoiceEmoji(activity.storageKey, id), + ), + ); + } + + return filtered; + } + + String getChoiceText(String key, String choiceId) { + if (widget.type == ConstructTypeEnum.morph) { + return choiceId; + } + if (_choiceTexts.containsKey(key) && + _choiceTexts[key]!.containsKey(choiceId)) { + return _choiceTexts[key]![choiceId]!; + } + final cId = ConstructIdentifier.fromString(choiceId); + return cId?.lemma ?? choiceId; + } + + String? getChoiceEmoji(String key, String choiceId) { + if (widget.type == ConstructTypeEnum.morph) return null; + return _choiceEmojis[key]?[choiceId]; + } + + String choiceTargetId(String choiceId) => + '${widget.type.name}-choice-card-${choiceId.replaceAll(' ', '_')}'; + + void _clearState() { + activityState.value = const AsyncState.loading(); + activityTarget.value = null; + selectedMorphChoice.value = null; + hintPressedNotifier.value = false; + enableChoicesNotifier.value = true; + progressNotifier.value = 0.0; + _queue.clear(); + _choiceTexts.clear(); + _choiceEmojis.clear(); + activityState.value = const AsyncState.idle(); + + AnalyticsPractice.bypassExitConfirmation = true; + } + + void updateElapsedTime(int seconds) { + if (_sessionLoader.isLoaded) { + _sessionLoader.value!.setElapsedSeconds(seconds); + } + } + + void _playAudio() { + if (activityTarget.value == null) return; + if (widget.type != ConstructTypeEnum.vocab) return; + TtsController.tryToSpeak( + activityTarget.value!.target.tokens.first.vocabConstructID.lemma, + langCode: MatrixState.pangeaController.userController.userL2!.langCode, + ); + } + + Future _waitForAnalytics() async { + if (!_analyticsService.initCompleter.isCompleted) { + MatrixState.pangeaController.initControllers(); + await _analyticsService.initCompleter.future; + } + } + + Future _onLanguageUpdate() async { + try { + _clearState(); + await _analyticsService + .updateDispatcher.constructUpdateStream.stream.first + .timeout(const Duration(seconds: 10)); + await reloadSession(); + } catch (e) { + if (mounted) { + activityState.value = AsyncState.error( + L10n.of(context).oopsSomethingWentWrong, + ); + } + } + } + + Future _startSession() async { + await _waitForAnalytics(); + await _sessionLoader.load(); + if (_sessionLoader.isError) { + AnalyticsPractice.bypassExitConfirmation = true; + return; + } + + progressNotifier.value = _sessionLoader.value!.progress; + await _continueSession(); + } + + Future reloadSession() async { + _clearState(); + _sessionLoader.reset(); + await _startSession(); + } + + Future reloadCurrentActivity() async { + if (activityTarget.value == null) return; + + try { + activityState.value = const AsyncState.loading(); + selectedMorphChoice.value = null; + hintPressedNotifier.value = false; + + final req = activityTarget.value!; + final res = await _fetchActivity(req); + + if (!mounted) return; + activityState.value = AsyncState.loaded(res); + _playAudio(); + } catch (e) { + if (!mounted) return; + activityState.value = AsyncState.error(e); + } + } + + Future _completeSession() async { + _sessionLoader.value!.finishSession(); + setState(() {}); + + final bonus = _sessionLoader.value!.state.allBonusUses; + await _analyticsService.updateService.addAnalytics( + null, + bonus, + forceUpdate: true, + ); + AnalyticsPractice.bypassExitConfirmation = true; + } + + bool _continuing = false; + + Future _continueSession() async { + if (_continuing) return; + _continuing = true; + enableChoicesNotifier.value = true; + + try { + if (activityState.value + is AsyncIdle) { + await _initActivityData(); + } else { + // Keep trying to load activities from the queue until one succeeds or queue is empty + while (_queue.isNotEmpty) { + activityState.value = const AsyncState.loading(); + selectedMorphChoice.value = null; + hintPressedNotifier.value = false; + final nextActivityCompleter = _queue.removeFirst(); + + try { + final activity = await nextActivityCompleter.completer.future; + activityTarget.value = nextActivityCompleter.request; + _playAudio(); + activityState.value = AsyncState.loaded(activity); + AnalyticsPractice.bypassExitConfirmation = false; + return; + } catch (e) { + // Completer failed, skip to next + continue; + } + } + // Queue is empty, complete the session + await _completeSession(); + } + } catch (e) { + AnalyticsPractice.bypassExitConfirmation = true; + activityState.value = AsyncState.error(e); + } finally { + _continuing = false; + } + } + + Future _initActivityData() async { + final requests = _sessionLoader.value!.activityRequests; + if (requests.isEmpty) { + throw L10n.of(context).noActivityRequest; + } + + for (var i = 0; i < requests.length; i++) { + try { + activityState.value = const AsyncState.loading(); + final req = requests[i]; + final res = await _fetchActivity(req); + if (!mounted) return; + activityTarget.value = req; + _playAudio(); + activityState.value = AsyncState.loaded(res); + AnalyticsPractice.bypassExitConfirmation = false; + // Fill queue with remaining requests + _fillActivityQueue(requests.skip(i + 1).toList()); + return; + } catch (e) { + await recordSkippedUse(requests[i]); + // Try next request + continue; + } + } + AnalyticsPractice.bypassExitConfirmation = true; + if (!mounted) return; + activityState.value = + AsyncState.error(L10n.of(context).oopsSomethingWentWrong); + return; + } + + Future _fillActivityQueue( + List requests, + ) async { + for (final request in requests) { + final completer = Completer(); + _queue.add( + _PracticeQueueEntry( + request: request, + completer: completer, + ), + ); + try { + final res = await _fetchActivity(request); + if (!mounted) return; + completer.complete(res); + } catch (e) { + if (!mounted) return; + completer.completeError(e); + await recordSkippedUse(request); + } + } + } + + Future _fetchActivity( + MessageActivityRequest req, + ) async { + final result = await PracticeRepo.getPracticeActivity( + req, + messageInfo: {}, + ); + + if (result.isError || + result.result is! MultipleChoicePracticeActivityModel) { + throw L10n.of(context).oopsSomethingWentWrong; + } + + final activityModel = result.result as MultipleChoicePracticeActivityModel; + + // Prefetch lemma info for meaning activities before marking ready + if (activityModel is VocabMeaningPracticeActivityModel) { + final choices = activityModel.multipleChoiceContent.choices.toList(); + await _fetchLemmaInfo(activityModel.storageKey, choices); + } + + return activityModel; + } + + Future _fetchLemmaInfo( + String requestKey, + List choiceIds, + ) async { + final texts = {}; + final emojis = {}; + + for (final id in choiceIds) { + final cId = ConstructIdentifier.fromString(id); + if (cId == null) continue; + + final res = await cId.getLemmaInfo({}); + if (res.isError) { + LemmaInfoRepo.clearCache(cId.lemmaInfoRequest({})); + throw L10n.of(context).oopsSomethingWentWrong; + } + + texts[id] = res.result!.meaning; + emojis[id] = res.result!.emoji.firstOrNull; + } + + _choiceTexts.putIfAbsent(requestKey, () => {}); + _choiceEmojis.putIfAbsent(requestKey, () => {}); + + _choiceTexts[requestKey]!.addAll(texts); + _choiceEmojis[requestKey]!.addAll(emojis); + } + + Future recordSkippedUse(MessageActivityRequest request) async { + // Record a 0 XP use so that activity isn't chosen again soon + _sessionLoader.value!.incrementSkippedActivities(); + final token = request.target.tokens.first; + + final use = OneConstructUse( + useType: ConstructUseTypeEnum.ignPA, + constructType: widget.type, + metadata: ConstructUseMetaData( + roomId: null, + timeStamp: DateTime.now(), + ), + category: token.pos, + lemma: token.lemma.text, + form: token.lemma.text, + xp: 0, + ); + + await _analyticsService.updateService.addAnalytics(null, [use]); + } + + void onHintPressed() { + hintPressedNotifier.value = !hintPressedNotifier.value; + } + + Future onSelectChoice( + String choiceContent, + ) async { + if (_currentActivity == null) return; + final activity = _currentActivity!; + + // Track the selection for display + if (activity is MorphPracticeActivityModel) { + selectedMorphChoice.value = SelectedMorphChoice( + feature: activity.morphFeature, + tag: choiceContent, + ); + } + final isCorrect = activity.multipleChoiceContent.isCorrect(choiceContent); + if (isCorrect) { + enableChoicesNotifier.value = false; + } + + // Update activity record + PracticeRecordController.onSelectChoice( + choiceContent, + activity.tokens.first, + activity, + ); + + final use = activity.constructUse(choiceContent); + _sessionLoader.value!.submitAnswer(use); + await _analyticsService.updateService + .addAnalytics(choiceTargetId(choiceContent), [use]); + + if (!activity.multipleChoiceContent.isCorrect(choiceContent)) return; + + _playAudio(); + + // Display the fact that the choice was correct before loading the next activity + await Future.delayed(const Duration(milliseconds: 1000)); + + // Then mark this activity as completed, and either load the next or complete the session + _sessionLoader.value!.completeActivity(); + progressNotifier.value = _sessionLoader.value!.progress; + + if (_queue.isEmpty) { + await _completeSession(); + } else if (_isComplete) { + await _completeSession(); + } else { + await _continueSession(); + } + } + + Future?> getExampleMessage( + MessageActivityRequest activityRequest, + ) async { + final target = activityRequest.target; + final token = target.tokens.first; + final construct = target.targetTokenConstructID(token); + + if (widget.type == ConstructTypeEnum.morph) { + return activityRequest.morphExampleInfo?.exampleMessage; + } + + return ExampleMessageUtil.getExampleMessage( + await _analyticsService.getConstructUse(construct), + Matrix.of(context).client, + ); + } + + Future get derivedAnalyticsData => + _analyticsService.derivedData; + + @override + Widget build(BuildContext context) => AnalyticsPracticeView(this); +} diff --git a/lib/pangea/analytics_practice/analytics_practice_session_model.dart b/lib/pangea/analytics_practice/analytics_practice_session_model.dart new file mode 100644 index 000000000..ebeccdce0 --- /dev/null +++ b/lib/pangea/analytics_practice/analytics_practice_session_model.dart @@ -0,0 +1,280 @@ +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 exampleMessage; + + const MorphExampleInfo({ + required this.exampleMessage, + }); + + Map toJson() { + final segments = >[]; + + 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 json) { + final segments = json['segments'] as List? ?? []; + + final spans = []; + 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 toJson() => { + 'target': target.toJson(), + 'grammarErrorInfo': grammarErrorInfo?.toJson(), + 'morphExampleInfo': morphExampleInfo?.toJson(), + }; + + factory AnalyticsActivityTarget.fromJson(Map json) => + AnalyticsActivityTarget( + target: PracticeTarget.fromJson(json['target']), + grammarErrorInfo: json['grammarErrorInfo'] != null + ? GrammarErrorRequestInfo.fromJson(json['grammarErrorInfo']) + : null, + morphExampleInfo: json['morphExampleInfo'] != null + ? MorphExampleInfo.fromJson(json['morphExampleInfo']) + : null, + ); +} + +class AnalyticsPracticeSessionModel { + final DateTime startedAt; + final List practiceTargets; + final String userL1; + final String userL2; + + AnalyticsPracticeSessionState state; + + AnalyticsPracticeSessionModel({ + required this.startedAt, + required this.practiceTargets, + required this.userL1, + required this.userL2, + AnalyticsPracticeSessionState? state, + }) : state = state ?? const AnalyticsPracticeSessionState(); + + // Maximum activities to attempt (including skips) + int get _maxAttempts => (AnalyticsPracticeConstants.practiceGroupSize + + AnalyticsPracticeConstants.errorBufferSize) + .clamp(0, practiceTargets.length) + .toInt(); + + int get _completionGoal => AnalyticsPracticeConstants.practiceGroupSize + .clamp(0, practiceTargets.length); + + // Total attempted so far (completed + skipped) + int get _totalAttempted => state.currentIndex + state.skippedActivities; + + bool get isComplete { + final complete = state.finished || + state.currentIndex >= _completionGoal || + _totalAttempted >= _maxAttempts; + return complete; + } + + double get progress { + final possibleCompletions = + (state.currentIndex + _maxAttempts - _totalAttempted) + .clamp(0, _completionGoal); + return possibleCompletions > 0 + ? (state.currentIndex / possibleCompletions).clamp(0.0, 1.0) + : 1.0; + } + + List get activityRequests { + return practiceTargets.map((target) { + return MessageActivityRequest( + userL1: userL1, + userL2: userL2, + activityQualityFeedback: null, + target: target.target, + grammarErrorInfo: target.grammarErrorInfo, + morphExampleInfo: target.morphExampleInfo, + ); + }).toList(); + } + + void setElapsedSeconds(int seconds) => + state = state.copyWith(elapsedSeconds: seconds); + + void finishSession() => state = state.copyWith(finished: true); + + void completeActivity() => + state = state.copyWith(currentIndex: state.currentIndex + 1); + + void incrementSkippedActivities() => state = state.copyWith( + skippedActivities: state.skippedActivities + 1, + ); + + void submitAnswer(OneConstructUse use) => state = state.copyWith( + completedUses: [...state.completedUses, use], + ); + + factory AnalyticsPracticeSessionModel.fromJson(Map json) { + return AnalyticsPracticeSessionModel( + startedAt: DateTime.parse(json['startedAt'] as String), + practiceTargets: (json['practiceTargets'] as List) + .map((e) => AnalyticsActivityTarget.fromJson(e)) + .whereType() + .toList(), + userL1: json['userL1'] as String, + userL2: json['userL2'] as String, + state: AnalyticsPracticeSessionState.fromJson( + json, + ), + ); + } + + Map toJson() { + return { + 'startedAt': startedAt.toIso8601String(), + 'practiceTargets': practiceTargets.map((e) => e.toJson()).toList(), + 'userL1': userL1, + 'userL2': userL2, + ...state.toJson(), + }; + } +} + +class AnalyticsPracticeSessionState { + final List completedUses; + final int currentIndex; + final bool finished; + final int elapsedSeconds; + final int skippedActivities; + + const AnalyticsPracticeSessionState({ + this.completedUses = const [], + this.currentIndex = 0, + this.finished = false, + this.elapsedSeconds = 0, + this.skippedActivities = 0, + }); + + int get totalXpGained => completedUses.fold(0, (sum, use) => sum + use.xp); + + double get accuracy { + if (completedUses.isEmpty) return 0.0; + final correct = completedUses.where((use) => use.xp > 0).length; + final result = correct / completedUses.length; + return (result * 100).truncateToDouble(); + } + + bool get _giveAccuracyBonus => accuracy >= 100.0; + + bool get _giveTimeBonus => + elapsedSeconds <= AnalyticsPracticeConstants.timeForBonus; + + int get bonusXP => accuracyBonusXP + timeBonusXP; + + int get accuracyBonusXP => _giveAccuracyBonus ? _bonusXP : 0; + + int get timeBonusXP => _giveTimeBonus ? _bonusXP : 0; + + int get _bonusXP => _bonusUses.fold(0, (sum, use) => sum + use.xp); + + int get allXPGained => totalXpGained + bonusXP; + + List get _bonusUses => + completedUses.where((use) => use.xp > 0).map(_bonusUse).toList(); + + List get allBonusUses => [ + if (_giveAccuracyBonus) ..._bonusUses, + if (_giveTimeBonus) ..._bonusUses, + ]; + + OneConstructUse _bonusUse(OneConstructUse originalUse) => OneConstructUse( + useType: ConstructUseTypeEnum.bonus, + constructType: originalUse.constructType, + metadata: ConstructUseMetaData( + roomId: originalUse.metadata.roomId, + timeStamp: DateTime.now(), + ), + category: originalUse.category, + lemma: originalUse.lemma, + form: originalUse.form, + xp: ConstructUseTypeEnum.bonus.pointValue, + ); + + AnalyticsPracticeSessionState copyWith({ + List? completedUses, + int? currentIndex, + bool? finished, + int? elapsedSeconds, + int? skippedActivities, + }) { + return AnalyticsPracticeSessionState( + completedUses: completedUses ?? this.completedUses, + currentIndex: currentIndex ?? this.currentIndex, + finished: finished ?? this.finished, + elapsedSeconds: elapsedSeconds ?? this.elapsedSeconds, + skippedActivities: skippedActivities ?? this.skippedActivities, + ); + } + + Map toJson() { + return { + 'completedUses': completedUses.map((e) => e.toJson()).toList(), + 'currentIndex': currentIndex, + 'finished': finished, + 'elapsedSeconds': elapsedSeconds, + 'skippedActivities': skippedActivities, + }; + } + + factory AnalyticsPracticeSessionState.fromJson(Map json) { + return AnalyticsPracticeSessionState( + completedUses: (json['completedUses'] as List?) + ?.map((e) => OneConstructUse.fromJson(e)) + .whereType() + .toList() ?? + [], + currentIndex: json['currentIndex'] as int? ?? 0, + finished: json['finished'] as bool? ?? false, + elapsedSeconds: json['elapsedSeconds'] as int? ?? 0, + skippedActivities: json['skippedActivities'] as int? ?? 0, + ); + } +} diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart new file mode 100644 index 000000000..5e82d6b6d --- /dev/null +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -0,0 +1,416 @@ +import 'dart:math'; + +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'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +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'; +import 'package:fluffychat/widgets/matrix.dart'; + +class InsufficientDataException implements Exception {} + +class AnalyticsPracticeSessionRepo { + static Future get( + ConstructTypeEnum type, + ) async { + if (MatrixState.pangeaController.subscriptionController.isSubscribed == + false) { + throw UnsubscribedException(); + } + + final r = Random(); + final activityTypes = ActivityTypeEnum.analyticsPracticeTypes(type); + + final types = List.generate( + AnalyticsPracticeConstants.practiceGroupSize + + AnalyticsPracticeConstants.errorBufferSize, + (_) => activityTypes[r.nextInt(activityTypes.length)], + ); + + final List targets = []; + + if (type == ConstructTypeEnum.vocab) { + final constructs = await _fetchVocab(); + final targetCount = min(constructs.length, types.length); + targets.addAll([ + for (var i = 0; i < targetCount; i++) + AnalyticsActivityTarget( + target: PracticeTarget( + tokens: [constructs[i].asToken], + activityType: types[i], + ), + ), + ]); + } else { + final errorTargets = await _fetchErrors(); + targets.addAll(errorTargets); + if (targets.length < + (AnalyticsPracticeConstants.practiceGroupSize + + AnalyticsPracticeConstants.errorBufferSize)) { + final morphs = await _fetchMorphs(); + final remainingCount = (AnalyticsPracticeConstants.practiceGroupSize + + AnalyticsPracticeConstants.errorBufferSize) - + targets.length; + final morphEntries = morphs.take(remainingCount); + + for (final entry in morphEntries) { + targets.add( + AnalyticsActivityTarget( + target: PracticeTarget( + tokens: [entry.token], + activityType: ActivityTypeEnum.grammarCategory, + morphFeature: entry.feature, + ), + morphExampleInfo: MorphExampleInfo( + exampleMessage: entry.exampleMessage, + ), + ), + ); + } + + targets.shuffle(); + } + } + + if (targets.isEmpty) { + throw InsufficientDataException(); + } + + final session = AnalyticsPracticeSessionModel( + userL1: MatrixState.pangeaController.userController.userL1!.langCode, + userL2: MatrixState.pangeaController.userController.userL2!.langCode, + startedAt: DateTime.now(), + practiceTargets: targets, + ); + return session; + } + + static Future> _fetchVocab() async { + final constructs = await MatrixState + .pangeaController.matrixState.analyticsDataService + .getAggregatedConstructs(ConstructTypeEnum.vocab) + .then((map) => map.values.toList()); + + // sort by last used descending, nulls first + constructs.sort((a, b) { + final dateA = a.lastUsed; + final dateB = b.lastUsed; + if (dateA == null && dateB == null) return 0; + if (dateA == null) return -1; + if (dateB == null) return 1; + return dateA.compareTo(dateB); + }); + + final Set seemLemmas = {}; + final targets = []; + for (final construct in constructs) { + if (seemLemmas.contains(construct.lemma)) continue; + seemLemmas.add(construct.lemma); + targets.add(construct.id); + if (targets.length >= + (AnalyticsPracticeConstants.practiceGroupSize + + AnalyticsPracticeConstants.errorBufferSize)) { + break; + } + } + return targets; + } + + static Future> _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 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; + final dateB = b.lastUsed; + if (dateA == null && dateB == null) return 0; + if (dateA == null) return -1; + if (dateB == null) return 1; + return dateA.compareTo(dateB); + }); + + final targets = []; + final Set seenForms = {}; + + for (final entry in constructs) { + if (targets.length >= + (AnalyticsPracticeConstants.practiceGroupSize + + AnalyticsPracticeConstants.errorBufferSize)) { + break; + } + + final feature = MorphFeaturesEnumExtension.fromString(entry.id.category); + + // Only include features that are in the valid list (have multiple tags) + if (feature == MorphFeaturesEnum.Unknown || + (validFeatures.isNotEmpty && !validFeatures.contains(feature.name))) { + continue; + } + + List? exampleMessage; + for (final use in entry.cappedUses) { + if (targets.length >= + (AnalyticsPracticeConstants.practiceGroupSize + + AnalyticsPracticeConstants.errorBufferSize)) { + break; + } + + if (use.lemma.isEmpty) continue; + final form = use.form; + if (seenForms.contains(form) || form == null) { + 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( + text: form, + saveVocab: true, + form: form, + ), + text: PangeaTokenText.fromString(form), + pos: 'other', + morph: {feature: use.lemma}, + ); + targets.add( + MorphPracticeTarget( + feature: feature, + token: token, + exampleMessage: exampleMessage, + ), + ); + break; + } + } + + return targets; + } + + static Future> _fetchErrors() async { + // Fetch all recent uses in one call (not filtering blocked constructs) + final allRecentUses = await MatrixState + .pangeaController.matrixState.analyticsDataService + .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 idsToEvents = {}; + + for (final use in grammarErrorUses) { + final eventID = use.metadata.eventId; + if (eventID == null || idsToEvents.containsKey(eventID)) continue; + + final roomID = use.metadata.roomId; + if (roomID == null) { + idsToEvents[eventID] = null; + continue; + } + + final room = client.getRoomById(roomID); + final event = await room?.getEventById(eventID); + if (event == null || event.redacted) { + idsToEvents[eventID] = null; + continue; + } + + final timeline = await room!.getTimeline(); + idsToEvents[eventID] = PangeaMessageEvent( + event: event, + timeline: timeline, + ownMessage: event.senderId == client.userID, + ); + } + + final l2Code = + MatrixState.pangeaController.userController.userL2!.langCodeShort; + + final events = idsToEvents.values.whereType().toList(); + final eventsWithContent = events.where((e) { + final originalSent = e.originalSent; + final choreo = originalSent?.choreo; + final tokens = originalSent?.tokens; + return originalSent?.langCode.split("-").first == l2Code && + choreo != null && + tokens != null && + tokens.isNotEmpty && + choreo.choreoSteps.any( + (step) => + step.acceptedOrIgnoredMatch?.isGrammarMatch == true && + step.acceptedOrIgnoredMatch?.match.bestChoice != null, + ); + }); + + final targets = []; + for (final event in eventsWithContent) { + final originalSent = event.originalSent!; + final choreo = originalSent.choreo!; + final tokens = originalSent.tokens!; + + for (int i = 0; i < choreo.choreoSteps.length; i++) { + final step = choreo.choreoSteps[i]; + final igcMatch = step.acceptedOrIgnoredMatch; + final stepText = choreo.stepText(stepIndex: i - 1); + if (igcMatch?.isGrammarMatch != true || + igcMatch?.match.bestChoice == null) { + continue; + } + + if (igcMatch!.match.offset == 0 && + igcMatch.match.length >= stepText.trim().characters.length) { + continue; + } + + if (igcMatch.match.isNormalizationError()) { + // Skip normalization errors + continue; + } + + final choices = igcMatch.match.choices!.map((c) => c.value).toList(); + final choiceTokens = tokens + .where( + (token) => + token.lemma.saveVocab && + choices.any( + (choice) => choice.contains(token.text.content), + ), + ) + .toList(); + + // 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 { + translation = await event.requestRespresentationByL1(); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'context': 'AnalyticsPracticeSessionRepo._fetchErrors', + 'message': 'Failed to fetch translation for analytics practice', + 'event_id': event.eventId, + }, + ); + } + + if (translation == null) continue; + + targets.add( + AnalyticsActivityTarget( + target: PracticeTarget( + tokens: choiceTokens, + activityType: ActivityTypeEnum.grammarError, + morphFeature: null, + ), + grammarErrorInfo: GrammarErrorRequestInfo( + choreo: choreo, + stepIndex: i, + eventID: event.eventId, + translation: translation, + ), + ), + ); + } + } + + return targets; + } +} + +class MorphPracticeTarget { + final PangeaToken token; + final MorphFeaturesEnum feature; + final List exampleMessage; + + MorphPracticeTarget({ + required this.token, + required this.feature, + required this.exampleMessage, + }); +} diff --git a/lib/pangea/analytics_practice/analytics_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart new file mode 100644 index 000000000..9ada4f9d2 --- /dev/null +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -0,0 +1,728 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; +import 'package:fluffychat/pangea/analytics_practice/choice_cards/audio_choice_card.dart'; +import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart'; +import 'package:fluffychat/pangea/analytics_practice/choice_cards/grammar_choice_card.dart'; +import 'package:fluffychat/pangea/analytics_practice/choice_cards/meaning_choice_card.dart'; +import 'package:fluffychat/pangea/analytics_practice/completed_activity_session_view.dart'; +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'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'package:fluffychat/widgets/layouts/max_width_body.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class AnalyticsPracticeView extends StatelessWidget { + final AnalyticsPracticeState controller; + + const AnalyticsPracticeView(this.controller, {super.key}); + + @override + Widget build(BuildContext context) { + const loading = Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator.adaptive(), + ), + ); + return Scaffold( + appBar: AppBar( + title: Row( + spacing: 8.0, + children: [ + Expanded( + child: ValueListenableBuilder( + valueListenable: controller.progressNotifier, + builder: (context, progress, __) { + return AnimatedProgressBar( + height: 20.0, + widthPercent: progress, + barColor: Theme.of(context).colorScheme.primary, + ); + }, + ), + ), + //keep track of state to update timer + ValueListenableBuilder( + valueListenable: controller.sessionState, + builder: (context, state, __) { + if (state is AsyncLoaded) { + return PracticeTimerWidget( + key: ValueKey(state.value.startedAt), + initialSeconds: state.value.state.elapsedSeconds, + onTimeUpdate: controller.updateElapsedTime, + isRunning: !state.value.isComplete, + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + child: MaxWidthBody( + withScrolling: false, + showBorder: false, + child: ValueListenableBuilder( + valueListenable: controller.sessionState, + builder: (context, state, __) { + return switch (state) { + AsyncError(:final error) => + ErrorIndicator( + message: error.toLocalizedString(context), + ), + AsyncLoaded(:final value) => + value.isComplete + ? CompletedActivitySessionView(state.value, controller) + : _AnalyticsActivityView(controller), + _ => loading, + }; + }, + ), + ), + ), + ); + } +} + +class _AnalyticsActivityView extends StatelessWidget { + final AnalyticsPracticeState controller; + + const _AnalyticsActivityView( + this.controller, + ); + + @override + Widget build(BuildContext context) { + final isColumnMode = FluffyThemes.isColumnMode(context); + TextStyle? titleStyle = isColumnMode + ? Theme.of(context).textTheme.titleLarge + : Theme.of(context).textTheme.titleMedium; + titleStyle = titleStyle?.copyWith(fontWeight: FontWeight.bold); + + return ListView( + children: [ + //per-activity instructions, add switch statement once there are more types + const InstructionsInlineTooltip( + instructionsEnum: InstructionsEnum.selectMeaning, + padding: EdgeInsets.symmetric( + vertical: 8.0, + ), + ), + SizedBox( + height: 75.0, + child: ValueListenableBuilder( + valueListenable: controller.activityTarget, + builder: (context, target, __) => target != null + ? Column( + children: [ + Text( + target.promptText(context), + textAlign: TextAlign.center, + style: titleStyle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (controller.widget.type == ConstructTypeEnum.vocab) + PhoneticTranscriptionWidget( + text: + target.target.tokens.first.vocabConstructID.lemma, + textLanguage: MatrixState + .pangeaController.userController.userL2!, + style: const TextStyle(fontSize: 14.0), + ), + ], + ) + : const SizedBox.shrink(), + ), + ), + const SizedBox(height: 16.0), + Center( + child: _AnalyticsPracticeCenterContent(controller: controller), + ), + const SizedBox(height: 16.0), + _ActivityChoicesWidget(controller), + const SizedBox(height: 16.0), + _WrongAnswerFeedback(controller: controller), + ], + ); + } +} + +class _AnalyticsPracticeCenterContent extends StatelessWidget { + final AnalyticsPracticeState controller; + + const _AnalyticsPracticeCenterContent({ + required this.controller, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller.activityTarget, + builder: (context, target, __) => switch (target?.target.activityType) { + null => const SizedBox(), + ActivityTypeEnum.grammarError => SizedBox( + height: 160.0, + child: SingleChildScrollView( + child: ValueListenableBuilder( + valueListenable: controller.activityState, + builder: (context, state, __) => switch (state) { + AsyncLoaded( + value: final GrammarErrorPracticeActivityModel activity + ) => + Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ErrorBlankWidget( + key: ValueKey( + '${activity.eventID}_${activity.errorOffset}_${activity.errorLength}', + ), + activity: activity, + ), + const SizedBox(height: 12), + ], + ), + _ => const SizedBox(), + }, + ), + ), + ), + 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!), + ), + ), + ), + }, + ); + } +} + +class _ExampleMessageWidget extends StatelessWidget { + final Future?> future; + + const _ExampleMessageWidget(this.future); + + @override + Widget build(BuildContext context) { + return FutureBuilder?>( + future: future, + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data == null) { + return const SizedBox(); + } + + 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, + ), + children: snapshot.data!, + ), + ), + ); + }, + ); + } +} + +class _CorrectAnswerHint extends StatelessWidget { + final AnalyticsPracticeState controller; + + const _CorrectAnswerHint({ + required this.controller, + }); + + @override + Widget build(BuildContext context) { + 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) { + 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 || + 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; + + final chars = text.characters; + final totalLength = chars.length; + + // ---------- BEFORE ---------- + int beforeStart = 0; + bool trimmedBefore = false; + + if (errorOffset > maxContextChars) { + int desiredStart = errorOffset - maxContextChars; + + // Snap left to nearest whitespace to avoid cutting words + while (desiredStart > 0 && chars.elementAt(desiredStart) != ' ') { + desiredStart--; + } + + beforeStart = desiredStart; + trimmedBefore = true; + } + + final before = + chars.skip(beforeStart).take(errorOffset - beforeStart).toString(); + + // ---------- AFTER ---------- + int afterEnd = totalLength; + bool trimmedAfter = false; + + final errorEnd = errorOffset + errorLength; + final afterChars = totalLength - errorEnd; + + if (afterChars > maxContextChars) { + int desiredEnd = errorEnd + maxContextChars; + + // Snap right to nearest whitespace + while (desiredEnd < totalLength && chars.elementAt(desiredEnd) != ' ') { + desiredEnd++; + } + + afterEnd = desiredEnd; + trimmedAfter = true; + } + + final after = chars.skip(errorEnd).take(afterEnd - errorEnd).toString(); + + return Column( + children: [ + 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: 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: '…'), + ], + ), + ), + 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 HintButton extends StatelessWidget { + final VoidCallback onPressed; + final bool depressed; + + const HintButton({ + required this.onPressed, + required this.depressed, + super.key, + }); + + @override + Widget build(BuildContext context) { + 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, + ), + ], + ), + ); + } +} + +class _ActivityChoicesWidget extends StatelessWidget { + final AnalyticsPracticeState controller; + + const _ActivityChoicesWidget( + this.controller, + ); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller.activityState, + builder: (context, state, __) { + return switch (state) { + AsyncLoading() => const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator.adaptive(), + ), + ), + AsyncError(:final error) => + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + //allow try to reload activity in case of error + ErrorIndicator(message: error.toString()), + const SizedBox(height: 16), + TextButton.icon( + onPressed: controller.reloadCurrentActivity, + icon: const Icon(Icons.refresh), + label: Text(L10n.of(context).tryAgain), + ), + ], + ), + AsyncLoaded(:final value) => + ValueListenableBuilder( + valueListenable: controller.enableChoicesNotifier, + builder: (context, enabled, __) { + final choices = controller.filteredChoices(value); + return Column( + spacing: 8.0, + mainAxisAlignment: MainAxisAlignment.center, + children: choices + .map( + (choice) => _ChoiceCard( + activity: value, + targetId: controller.choiceTargetId(choice.choiceId), + choiceId: choice.choiceId, + onPressed: () => controller.onSelectChoice( + choice.choiceId, + ), + cardHeight: 60.0, + choiceText: choice.choiceText, + choiceEmoji: choice.choiceEmoji, + enabled: enabled, + ), + ) + .toList(), + ); + }, + ), + _ => Container( + constraints: const BoxConstraints(maxHeight: 400.0), + child: const Center( + child: CircularProgressIndicator.adaptive(), + ), + ), + }; + }, + ); + } +} + +class _ChoiceCard extends StatelessWidget { + final MultipleChoicePracticeActivityModel activity; + final String choiceId; + final String targetId; + final VoidCallback onPressed; + final double cardHeight; + + final String choiceText; + final String? choiceEmoji; + final bool enabled; + + const _ChoiceCard({ + required this.activity, + required this.choiceId, + required this.targetId, + required this.onPressed, + required this.cardHeight, + required this.choiceText, + required this.choiceEmoji, + this.enabled = true, + }); + + @override + Widget build(BuildContext context) { + final isCorrect = activity.multipleChoiceContent.isCorrect(choiceId); + final activityType = activity.activityType; + final constructId = activity.tokens.first.vocabConstructID; + + switch (activity.activityType) { + case ActivityTypeEnum.lemmaMeaning: + return MeaningChoiceCard( + key: ValueKey( + '${constructId.string}_${activityType.name}_meaning_$choiceId', + ), + choiceId: choiceId, + targetId: targetId, + displayText: choiceText, + emoji: choiceEmoji, + onPressed: onPressed, + isCorrect: isCorrect, + height: cardHeight, + isEnabled: enabled, + ); + + case ActivityTypeEnum.lemmaAudio: + return AudioChoiceCard( + key: ValueKey( + '${constructId.string}_${activityType.name}_audio_$choiceId', + ), + text: choiceId, + targetId: targetId, + onPressed: onPressed, + isCorrect: isCorrect, + height: cardHeight, + isEnabled: enabled, + ); + + case ActivityTypeEnum.grammarCategory: + return GrammarChoiceCard( + key: ValueKey( + '${constructId.string}_${activityType.name}_grammar_$choiceId', + ), + choiceId: choiceId, + targetId: targetId, + feature: (activity as MorphPracticeActivityModel).morphFeature, + tag: choiceText, + onPressed: onPressed, + isCorrect: isCorrect, + height: cardHeight, + enabled: enabled, + ); + + case ActivityTypeEnum.grammarError: + final activity = this.activity as GrammarErrorPracticeActivityModel; + return GameChoiceCard( + key: ValueKey( + '${activity.errorLength}_${activity.errorOffset}_${activity.eventID}_${activityType.name}_grammar_error_$choiceId', + ), + shouldFlip: false, + targetId: targetId, + onPressed: onPressed, + isCorrect: isCorrect, + height: cardHeight, + isEnabled: enabled, + child: Text(choiceText), + ); + + default: + return GameChoiceCard( + key: ValueKey( + '${constructId.string}_${activityType.name}_basic_$choiceId', + ), + shouldFlip: false, + targetId: targetId, + onPressed: onPressed, + isCorrect: isCorrect, + height: cardHeight, + isEnabled: enabled, + child: Text(choiceText), + ); + } + } +} diff --git a/lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/audio_choice_card.dart similarity index 88% rename from lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart rename to lib/pangea/analytics_practice/choice_cards/audio_choice_card.dart index e80359415..a7a26864e 100644 --- a/lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart +++ b/lib/pangea/analytics_practice/choice_cards/audio_choice_card.dart @@ -1,14 +1,15 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart'; import 'package:fluffychat/pangea/common/widgets/word_audio_button.dart'; -import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart'; import 'package:fluffychat/widgets/matrix.dart'; /// Displays an audio button with a select label in a row layout /// TODO: needs a better design and button handling class AudioChoiceCard extends StatelessWidget { final String text; + final String targetId; final VoidCallback onPressed; final bool isCorrect; final double height; @@ -16,6 +17,7 @@ class AudioChoiceCard extends StatelessWidget { const AudioChoiceCard({ required this.text, + required this.targetId, required this.onPressed, required this.isCorrect, this.height = 72.0, @@ -27,7 +29,7 @@ class AudioChoiceCard extends StatelessWidget { Widget build(BuildContext context) { return GameChoiceCard( shouldFlip: false, - transformId: text, + targetId: targetId, onPressed: onPressed, isCorrect: isCorrect, height: height, diff --git a/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart new file mode 100644 index 000000000..88ee9b7f9 --- /dev/null +++ b/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/widgets/hover_builder.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +/// A unified choice card that handles flipping, color tinting, hovering, and alt widgets +class GameChoiceCard extends StatefulWidget { + final Widget child; + final Widget? altChild; + final VoidCallback onPressed; + final bool isCorrect; + final double height; + final bool shouldFlip; + final String targetId; + final bool isEnabled; + + const GameChoiceCard({ + required this.child, + required this.onPressed, + required this.isCorrect, + required this.targetId, + this.altChild, + this.height = 72.0, + this.shouldFlip = false, + this.isEnabled = true, + super.key, + }); + + @override + State createState() => _GameChoiceCardState(); +} + +class _GameChoiceCardState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _scaleAnim; + + bool _clicked = false; + bool _revealed = false; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: const Duration(milliseconds: 220), + vsync: this, + ); + + _scaleAnim = CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ).drive(Tween(begin: 1.0, end: 0.0)); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _handleTap() async { + if (!widget.isEnabled) return; + widget.onPressed(); + + if (widget.shouldFlip) { + if (_controller.isAnimating || _revealed) return; + + await _controller.forward(); + setState(() => _revealed = true); + await _controller.reverse(); + } else { + if (_clicked) return; + setState(() => _clicked = true); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + final baseColor = colorScheme.surfaceContainerHighest; + final hoverColor = colorScheme.onSurface.withValues(alpha: 0.08); + final tintColor = widget.isCorrect + ? AppConfig.success.withValues(alpha: 0.3) + : AppConfig.error.withValues(alpha: 0.3); + + return CompositedTransformTarget( + link: MatrixState.pAnyState.layerLinkAndKey(widget.targetId).link, + child: HoverBuilder( + builder: (context, hovered) => SizedBox( + width: double.infinity, + height: widget.height, + child: GestureDetector( + onTap: _handleTap, + child: widget.shouldFlip + ? AnimatedBuilder( + animation: _scaleAnim, + builder: (context, _) { + final scale = _scaleAnim.value; + final showContent = scale > 0.05; + + return Transform.scale( + scaleY: scale, + child: _CardContainer( + height: widget.height, + baseColor: baseColor, + overlayColor: _revealed + ? tintColor + : (hovered ? hoverColor : Colors.transparent), + child: Opacity( + opacity: showContent ? 1 : 0, + child: _revealed ? widget.altChild! : widget.child, + ), + ), + ); + }, + ) + : _CardContainer( + height: widget.height, + baseColor: baseColor, + overlayColor: _clicked + ? tintColor + : (hovered ? hoverColor : Colors.transparent), + child: widget.child, + ), + ), + ), + ), + ); + } +} + +class _CardContainer extends StatelessWidget { + final double height; + final Color baseColor; + final Color overlayColor; + final Widget child; + + const _CardContainer({ + required this.height, + required this.baseColor, + required this.overlayColor, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: height, + alignment: Alignment.center, + decoration: BoxDecoration( + color: baseColor, + borderRadius: BorderRadius.circular(16), + ), + foregroundDecoration: BoxDecoration( + color: overlayColor, + borderRadius: BorderRadius.circular(16), + ), + child: child, + ); + } +} diff --git a/lib/pangea/analytics_practice/choice_cards/grammar_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/grammar_choice_card.dart new file mode 100644 index 000000000..230944160 --- /dev/null +++ b/lib/pangea/analytics_practice/choice_cards/grammar_choice_card.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart'; +import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/morphs/morph_icon.dart'; + +/// Choice card for meaning activity with emoji, and alt text on flip +class GrammarChoiceCard extends StatelessWidget { + final String choiceId; + final String targetId; + + final MorphFeaturesEnum feature; + final String tag; + + final VoidCallback onPressed; + final bool isCorrect; + final double height; + final bool enabled; + + const GrammarChoiceCard({ + required this.choiceId, + required this.targetId, + required this.feature, + required this.tag, + required this.onPressed, + required this.isCorrect, + this.height = 72.0, + this.enabled = true, + super.key, + }); + + @override + Widget build(BuildContext context) { + final baseTextSize = + (Theme.of(context).textTheme.titleMedium?.fontSize ?? 16) * + (height / 72.0).clamp(1.0, 1.4); + final emojiSize = baseTextSize * 1.5; + final copy = getGrammarCopy( + category: feature.name, + lemma: tag, + context: context, + ) ?? + tag; + + return GameChoiceCard( + shouldFlip: false, + targetId: targetId, + onPressed: onPressed, + isCorrect: isCorrect, + height: height, + isEnabled: enabled, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: height, + height: height, + child: Center( + child: MorphIcon( + morphFeature: feature, + morphTag: tag, + size: Size(emojiSize, emojiSize), + ), + ), + ), + Expanded( + child: Text( + copy, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: TextStyle( + fontSize: baseTextSize, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/meaning_choice_card.dart similarity index 93% rename from lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart rename to lib/pangea/analytics_practice/choice_cards/meaning_choice_card.dart index 5faeb0398..f51394054 100644 --- a/lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart +++ b/lib/pangea/analytics_practice/choice_cards/meaning_choice_card.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart'; /// Choice card for meaning activity with emoji, and alt text on flip class MeaningChoiceCard extends StatelessWidget { final String choiceId; + final String targetId; final String displayText; final String? emoji; final VoidCallback onPressed; @@ -15,6 +16,7 @@ class MeaningChoiceCard extends StatelessWidget { const MeaningChoiceCard({ required this.choiceId, + required this.targetId, required this.displayText, this.emoji, required this.onPressed, @@ -33,7 +35,7 @@ class MeaningChoiceCard extends StatelessWidget { return GameChoiceCard( shouldFlip: true, - transformId: choiceId, + targetId: targetId, onPressed: onPressed, isCorrect: isCorrect, height: height, diff --git a/lib/pangea/analytics_practice/completed_activity_session_view.dart b/lib/pangea/analytics_practice/completed_activity_session_view.dart new file mode 100644 index 000000000..d9aefee22 --- /dev/null +++ b/lib/pangea/analytics_practice/completed_activity_session_view.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; +import 'package:fluffychat/pangea/analytics_practice/percent_marker_bar.dart'; +import 'package:fluffychat/pangea/analytics_practice/stat_card.dart'; +import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class CompletedActivitySessionView extends StatelessWidget { + final AnalyticsPracticeSessionModel session; + final AnalyticsPracticeState controller; + const CompletedActivitySessionView( + this.session, + this.controller, { + super.key, + }); + + String _formatTime(int seconds) { + final minutes = seconds ~/ 60; + final remainingSeconds = seconds % 60; + return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + final username = + Matrix.of(context).client.userID?.split(':').first.substring(1) ?? ''; + + final double accuracy = session.state.accuracy; + final int elapsedSeconds = session.state.elapsedSeconds; + + final bool accuracyAchievement = accuracy == 100; + final bool timeAchievement = elapsedSeconds <= 60; + + return Stack( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16, right: 16, left: 16), + child: Column( + children: [ + Text( + L10n.of(context).congratulationsYouveCompletedPractice, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: FutureBuilder( + future: Matrix.of(context).client.fetchOwnProfile(), + builder: (context, snapshot) { + final avatarUrl = snapshot.data?.avatarUrl; + return Avatar( + name: username, + showPresence: false, + size: 100, + mxContent: avatarUrl, + userId: Matrix.of(context).client.userID, + ); + }, + ), + ), + Column( + children: [ + Padding( + padding: const EdgeInsets.only( + top: 16.0, + bottom: 16.0, + ), + child: FutureBuilder( + future: controller.derivedAnalyticsData, + builder: (context, snapshot) => AnimatedProgressBar( + height: 20.0, + widthPercent: snapshot.hasData + ? snapshot.data!.levelProgress + : 0.0, + backgroundColor: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + duration: const Duration(milliseconds: 500), + ), + ), + ), + Text( + "+ ${session.state.allXPGained} XP", + style: + Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppConfig.goldLight, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + StatCard( + icon: Icons.my_location, + text: "${L10n.of(context).accuracy}: $accuracy%", + isAchievement: accuracyAchievement, + achievementText: "+ ${session.state.accuracyBonusXP} XP", + child: PercentMarkerBar( + height: 20.0, + widthPercent: accuracy / 100.0, + markerWidth: 20.0, + markerColor: AppConfig.success, + backgroundColor: !accuracyAchievement + ? Theme.of(context) + .colorScheme + .surfaceContainerHighest + : Color.alphaBlend( + AppConfig.goldLight.withValues(alpha: 0.3), + Theme.of(context) + .colorScheme + .surfaceContainerHighest, + ), + ), + ), + StatCard( + icon: Icons.alarm, + text: + "${L10n.of(context).time}: ${_formatTime(elapsedSeconds)}", + isAchievement: timeAchievement, + achievementText: "+ ${session.state.timeBonusXP} XP", + child: TimeStarsWidget( + elapsedSeconds: elapsedSeconds, + ), + ), + Column( + children: [ + //expanded row button + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + ), + onPressed: () => controller.reloadSession(), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + L10n.of(context).anotherRound, + ), + ], + ), + ), + const SizedBox(height: 16), + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + ), + onPressed: () { + context.go('/rooms/analytics/vocab'); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + L10n.of(context).quit, + ), + ], + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + const StarRainWidget( + showBlast: true, + rainDuration: Duration(seconds: 5), + ), + ], + ); + } +} + +class TimeStarsWidget extends StatelessWidget { + final int elapsedSeconds; + + const TimeStarsWidget({ + required this.elapsedSeconds, + super.key, + }); + + int get starCount { + const timeForBonus = AnalyticsPracticeConstants.timeForBonus; + if (elapsedSeconds <= timeForBonus) return 5; + if (elapsedSeconds <= timeForBonus * 1.5) return 4; + if (elapsedSeconds <= timeForBonus * 2) return 3; + if (elapsedSeconds <= timeForBonus * 2.5) return 2; + return 1; // anything above 2.5x timeForBonus + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate( + 5, + (index) => Icon( + index < starCount ? Icons.star : Icons.star_outline, + color: AppConfig.goldLight, + size: 36, + ), + ), + ); + } +} diff --git a/lib/pangea/analytics_practice/grammar_error_practice_generator.dart b/lib/pangea/analytics_practice/grammar_error_practice_generator.dart new file mode 100644 index 000000000..9284a3c9f --- /dev/null +++ b/lib/pangea/analytics_practice/grammar_error_practice_generator.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; +import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; + +class GrammarErrorPracticeGenerator { + static Future get( + MessageActivityRequest req, + ) async { + assert( + req.grammarErrorInfo != null, + 'Grammar error info must be provided for grammar error practice', + ); + + final choreo = req.grammarErrorInfo!.choreo; + final stepIndex = req.grammarErrorInfo!.stepIndex; + final eventID = req.grammarErrorInfo!.eventID; + + final igcMatch = + choreo.choreoSteps[stepIndex].acceptedOrIgnoredMatch?.match; + assert(igcMatch?.choices != null, 'IGC match must have choices'); + assert(igcMatch?.bestChoice != null, 'IGC match must have a best choice'); + + final correctChoice = igcMatch!.bestChoice!.value; + final choices = igcMatch.choices!.map((c) => c.value).toList(); + + final stepText = choreo.stepText(stepIndex: stepIndex - 1); + final errorSpan = stepText.characters + .skip(igcMatch.offset) + .take(igcMatch.length) + .toString(); + + if (!req.grammarErrorInfo!.translation.contains(errorSpan)) { + choices.add(errorSpan); + } + + choices.shuffle(); + return MessageActivityResponse( + activity: GrammarErrorPracticeActivityModel( + tokens: req.target.tokens, + langCode: req.userL2, + multipleChoiceContent: MultipleChoiceActivity( + choices: choices.toSet(), + answers: {correctChoice}, + ), + text: stepText, + errorOffset: igcMatch.offset, + errorLength: igcMatch.length, + eventID: eventID, + translation: req.grammarErrorInfo!.translation, + ), + ); + } +} diff --git a/lib/pangea/analytics_practice/morph_category_activity_generator.dart b/lib/pangea/analytics_practice/morph_category_activity_generator.dart new file mode 100644 index 000000000..78ef8ba78 --- /dev/null +++ b/lib/pangea/analytics_practice/morph_category_activity_generator.dart @@ -0,0 +1,67 @@ +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'; +import 'package:fluffychat/pangea/morphs/morph_repo.dart'; +import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; +import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class MorphCategoryActivityGenerator { + static Future get( + MessageActivityRequest req, + ) async { + if (req.target.morphFeature == null) { + throw ArgumentError( + "MorphCategoryActivityGenerator requires a targetMorphFeature", + ); + } + + final feature = req.target.morphFeature!; + final morphTag = req.target.tokens.first.getMorphTag(feature); + if (morphTag == null) { + throw ArgumentError( + "Token does not have the specified morph feature", + ); + } + + MorphFeaturesAndTags morphs = defaultMorphMapping; + + try { + final resp = await MorphsRepo.get(); + morphs = resp; + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: {"l2": MatrixState.pangeaController.userController.userL2}, + ); + } + + final List allTags = morphs.getDisplayTags(feature.name); + final List possibleDistractors = allTags + .where( + (tag) => tag.toLowerCase() != morphTag.toLowerCase() && tag != "X", + ) + .toList(); + + final choices = possibleDistractors.take(3).toList(); + choices.add(morphTag); + choices.shuffle(); + + return MessageActivityResponse( + activity: MorphCategoryPracticeActivityModel( + tokens: req.target.tokens, + langCode: req.userL2, + morphFeature: feature, + multipleChoiceContent: MultipleChoiceActivity( + choices: choices.toSet(), + answers: {morphTag}, + ), + morphExampleInfo: + req.morphExampleInfo ?? const MorphExampleInfo(exampleMessage: []), + ), + ); + } +} diff --git a/lib/pangea/vocab_practice/percent_marker_bar.dart b/lib/pangea/analytics_practice/percent_marker_bar.dart similarity index 100% rename from lib/pangea/vocab_practice/percent_marker_bar.dart rename to lib/pangea/analytics_practice/percent_marker_bar.dart diff --git a/lib/pangea/vocab_practice/vocab_timer_widget.dart b/lib/pangea/analytics_practice/practice_timer_widget.dart similarity index 87% rename from lib/pangea/vocab_practice/vocab_timer_widget.dart rename to lib/pangea/analytics_practice/practice_timer_widget.dart index 2ef4d06af..005efe0cc 100644 --- a/lib/pangea/vocab_practice/vocab_timer_widget.dart +++ b/lib/pangea/analytics_practice/practice_timer_widget.dart @@ -2,12 +2,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; -class VocabTimerWidget extends StatefulWidget { +class PracticeTimerWidget extends StatefulWidget { final int initialSeconds; final ValueChanged onTimeUpdate; final bool isRunning; - const VocabTimerWidget({ + const PracticeTimerWidget({ required this.initialSeconds, required this.onTimeUpdate, this.isRunning = true, @@ -15,10 +15,10 @@ class VocabTimerWidget extends StatefulWidget { }); @override - VocabTimerWidgetState createState() => VocabTimerWidgetState(); + PracticeTimerWidgetState createState() => PracticeTimerWidgetState(); } -class VocabTimerWidgetState extends State { +class PracticeTimerWidgetState extends State { final Stopwatch _stopwatch = Stopwatch(); late int _initialSeconds; Timer? _timer; @@ -33,7 +33,7 @@ class VocabTimerWidgetState extends State { } @override - void didUpdateWidget(VocabTimerWidget oldWidget) { + void didUpdateWidget(PracticeTimerWidget oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.isRunning && !widget.isRunning) { _stopTimer(); diff --git a/lib/pangea/vocab_practice/stat_card.dart b/lib/pangea/analytics_practice/stat_card.dart similarity index 100% rename from lib/pangea/vocab_practice/stat_card.dart rename to lib/pangea/analytics_practice/stat_card.dart diff --git a/lib/pangea/vocab_practice/vocab_audio_activity_generator.dart b/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart similarity index 85% rename from lib/pangea/vocab_practice/vocab_audio_activity_generator.dart rename to lib/pangea/analytics_practice/vocab_audio_activity_generator.dart index 5ac6eab4f..7b2954f51 100644 --- a/lib/pangea/vocab_practice/vocab_audio_activity_generator.dart +++ b/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart @@ -7,7 +7,7 @@ class VocabAudioActivityGenerator { static Future get( MessageActivityRequest req, ) async { - final token = req.targetTokens.first; + final token = req.target.tokens.first; final choices = await LemmaActivityGenerator.lemmaActivityDistractors(token); @@ -15,9 +15,8 @@ class VocabAudioActivityGenerator { choicesList.shuffle(); return MessageActivityResponse( - activity: PracticeActivityModel( - activityType: req.targetType, - targetTokens: [token], + activity: VocabAudioPracticeActivityModel( + tokens: req.target.tokens, langCode: req.userL2, multipleChoiceContent: MultipleChoiceActivity( choices: choicesList.toSet(), diff --git a/lib/pangea/vocab_practice/vocab_meaning_activity_generator.dart b/lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart similarity index 86% rename from lib/pangea/vocab_practice/vocab_meaning_activity_generator.dart rename to lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart index 28ac3a02c..7acc77b0d 100644 --- a/lib/pangea/vocab_practice/vocab_meaning_activity_generator.dart +++ b/lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart @@ -7,7 +7,7 @@ class VocabMeaningActivityGenerator { static Future get( MessageActivityRequest req, ) async { - final token = req.targetTokens.first; + final token = req.target.tokens.first; final choices = await LemmaActivityGenerator.lemmaActivityDistractors(token); @@ -18,9 +18,8 @@ class VocabMeaningActivityGenerator { final Set constructIdChoices = choices.map((c) => c.string).toSet(); return MessageActivityResponse( - activity: PracticeActivityModel( - activityType: req.targetType, - targetTokens: [token], + activity: VocabMeaningPracticeActivityModel( + tokens: req.target.tokens, langCode: req.userL2, multipleChoiceContent: MultipleChoiceActivity( choices: constructIdChoices, diff --git a/lib/pangea/analytics_summary/learning_progress_indicators.dart b/lib/pangea/analytics_summary/learning_progress_indicators.dart index c15a11b05..7147d4540 100644 --- a/lib/pangea/analytics_summary/learning_progress_indicators.dart +++ b/lib/pangea/analytics_summary/learning_progress_indicators.dart @@ -44,12 +44,10 @@ class LearningProgressIndicators extends StatelessWidget { final userL2 = MatrixState.pangeaController.userController.userL2; final analyticsRoom = Matrix.of(context).client.analyticsRoomLocal(); - final archivedActivitiesCount = - analyticsRoom?.archivedActivitiesCount ?? 0; + final updater = analyticsService.updateDispatcher; return StreamBuilder( - stream: - analyticsService.updateDispatcher.constructUpdateStream.stream, + stream: updater.constructUpdateStream.stream, builder: (context, _) { return Row( children: [ @@ -60,61 +58,72 @@ class LearningProgressIndicators extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Row( - spacing: isColumnMode ? 16.0 : 4.0, - children: [ - ...ConstructTypeEnum.values.map( - (c) => HoverButton( - selected: selected == c.indicator, + Row( + spacing: isColumnMode ? 16.0 : 0.0, + children: [ + ...ConstructTypeEnum.values.map( + (c) => HoverButton( + selected: selected == c.indicator, + onPressed: () { + AnalyticsNavigationUtil.navigateToAnalytics( + context: context, + view: c.indicator, + ); + }, + child: ProgressIndicatorBadge( + indicator: c.indicator, + loading: analyticsService.isInitializing, + points: analyticsService.numConstructs(c), + ), + ), + ), + StreamBuilder( + stream: updater.activityAnalyticsStream.stream, + builder: (context, _) { + final archivedActivitiesCount = + analyticsRoom?.archivedActivitiesCount ?? + 0; + return HoverButton( + selected: selected == + ProgressIndicatorEnum.activities, onPressed: () { AnalyticsNavigationUtil .navigateToAnalytics( context: context, - view: c.indicator, + view: ProgressIndicatorEnum.activities, ); }, - child: ProgressIndicatorBadge( - indicator: c.indicator, - loading: analyticsService.isInitializing, - points: analyticsService.numConstructs(c), - ), - ), - ), - HoverButton( - selected: selected == - ProgressIndicatorEnum.activities, - onPressed: () { - AnalyticsNavigationUtil.navigateToAnalytics( - context: context, - view: ProgressIndicatorEnum.activities, - ); - }, - child: Tooltip( - message: ProgressIndicatorEnum.activities - .tooltip(context), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - size: 18, - Icons.radar, - color: Theme.of(context) - .colorScheme - .primary, - weight: 1000, + child: Tooltip( + message: ProgressIndicatorEnum.activities + .tooltip(context), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + vertical: 2.0, ), - const SizedBox(width: 6.0), - AnimatedFloatingNumber( - number: archivedActivitiesCount, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + size: 18, + Icons.radar, + color: Theme.of(context) + .colorScheme + .primary, + weight: 1000, + ), + const SizedBox(width: 6.0), + AnimatedFloatingNumber( + number: archivedActivitiesCount, + ), + ], ), - ], + ), ), - ), - ), - ], - ), + ); + }, + ), + ], ), HoverButton( onPressed: () => showDialog( @@ -136,6 +145,7 @@ class LearningProgressIndicators extends StatelessWidget { .colorScheme .primary, ), + textScaler: TextScaler.noScaling, ), if (userL1 != null && userL2 != null) const Icon(Icons.chevron_right_outlined), @@ -151,6 +161,7 @@ class LearningProgressIndicators extends StatelessWidget { .colorScheme .primary, ), + textScaler: TextScaler.noScaling, ), ], ), @@ -158,75 +169,75 @@ class LearningProgressIndicators extends StatelessWidget { ], ), const SizedBox(height: 6), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: HoverBuilder( - builder: (context, hovered) { - return Container( - decoration: BoxDecoration( - color: hovered && canSelect - ? Theme.of(context) - .colorScheme - .primary - .withAlpha((0.2 * 255).round()) - : Colors.transparent, - borderRadius: BorderRadius.circular(36.0), - ), - padding: const EdgeInsets.symmetric( - vertical: 2.0, - horizontal: 4.0, - ), - child: MouseRegion( - cursor: canSelect - ? SystemMouseCursors.click - : MouseCursor.defer, - child: GestureDetector( - onTap: canSelect - ? () { - AnalyticsNavigationUtil - .navigateToAnalytics( - context: context, - view: ProgressIndicatorEnum.level, - ); - } - : null, - child: FutureBuilder( - future: analyticsService.derivedData, - builder: (context, snapshot) { - return Row( - spacing: 8.0, - children: [ - Expanded( - child: LearningProgressBar( - height: 24.0, - loading: !snapshot.hasData, - progress: snapshot - .data?.levelProgress ?? - 0.0, - ), + HoverBuilder( + builder: (context, hovered) { + return Container( + decoration: BoxDecoration( + color: (hovered && canSelect) || + (selected == ProgressIndicatorEnum.level) + ? Theme.of(context) + .colorScheme + .primary + .withAlpha((0.2 * 255).round()) + : Colors.transparent, + borderRadius: BorderRadius.circular(36.0), + ), + padding: const EdgeInsets.symmetric( + vertical: 2.0, + horizontal: 4.0, + ), + child: MouseRegion( + cursor: canSelect + ? SystemMouseCursors.click + : MouseCursor.defer, + child: GestureDetector( + onTap: canSelect + ? () { + AnalyticsNavigationUtil + .navigateToAnalytics( + context: context, + view: ProgressIndicatorEnum.level, + ); + } + : null, + child: FutureBuilder( + future: analyticsService.derivedData, + builder: (context, snapshot) { + final cached = + analyticsService.cachedDerivedData; + final data = snapshot.data ?? cached; + return Row( + spacing: 8.0, + children: [ + Expanded( + child: LearningProgressBar( + height: 24.0, + loading: data == null, + progress: + data?.levelProgress ?? 0.0, ), - if (snapshot.hasData) - Text( - "⭐ ${snapshot.data!.level}", - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context) - .colorScheme - .primary, - ), - ), - ], - ); - }, - ), + ), + if (data != null) + Text( + "⭐ ${data.level}", + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context) + .colorScheme + .primary, + ), + ), + ], + ); + }, ), ), - ); - }, - ), + ), + ); + }, ), const SizedBox(height: 16.0), ], diff --git a/lib/pangea/analytics_summary/progress_indicator.dart b/lib/pangea/analytics_summary/progress_indicator.dart index 579ba205f..c690a3cbf 100644 --- a/lib/pangea/analytics_summary/progress_indicator.dart +++ b/lib/pangea/analytics_summary/progress_indicator.dart @@ -19,28 +19,31 @@ class ProgressIndicatorBadge extends StatelessWidget { Widget build(BuildContext context) { return Tooltip( message: indicator.tooltip(context), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - size: 18, - indicator.icon, - color: indicator.color(context), - weight: 1000, - ), - const SizedBox(width: 6.0), - !loading - ? AnimatedFloatingNumber( - number: points, - ) - : const SizedBox( - height: 8, - width: 8, - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 2.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + size: 18, + indicator.icon, + color: indicator.color(context), + weight: 1000, + ), + const SizedBox(width: 6.0), + !loading + ? AnimatedFloatingNumber( + number: points, + ) + : const SizedBox( + height: 8, + width: 8, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), ), - ), - ], + ], + ), ), ); } @@ -125,6 +128,7 @@ class AnimatedFloatingNumberState extends State Text( widget.number.toString(), style: indicatorStyle, + textScaler: TextScaler.noScaling, ), ], ); diff --git a/lib/pangea/bot/utils/bot_room_extension.dart b/lib/pangea/bot/utils/bot_room_extension.dart index b658235ed..830ca6a84 100644 --- a/lib/pangea/bot/utils/bot_room_extension.dart +++ b/lib/pangea/bot/utils/bot_room_extension.dart @@ -3,6 +3,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; 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'; extension BotRoomExtension on Room { @@ -23,11 +24,40 @@ extension BotRoomExtension on Room { return BotOptionsModel.fromJson(stateEvent.content); } - Future setBotOptions(BotOptionsModel options) => - client.setRoomStateWithKey( - id, - PangeaEventTypes.botOptions, - '', - options.toJson(), - ); + Future setBotOptions(BotOptionsModel options) async { + const maxRetries = 3; + Duration retryDelay = const Duration(seconds: 5); + + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + if (attempt > 1) { + await Future.delayed(retryDelay); + retryDelay *= 2; + } + + await client.setRoomStateWithKey( + id, + PangeaEventTypes.botOptions, + '', + options.toJson(), + ); + + return; + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'roomId': id, + 'options': options.toJson(), + 'attempt': attempt, + }, + ); + + if (attempt == maxRetries) { + rethrow; + } + } + } + } } diff --git a/lib/pangea/bot/widgets/bot_chat_settings_dialog.dart b/lib/pangea/bot/widgets/bot_chat_settings_dialog.dart index 87c603de2..ea1a19f63 100644 --- a/lib/pangea/bot/widgets/bot_chat_settings_dialog.dart +++ b/lib/pangea/bot/widgets/bot_chat_settings_dialog.dart @@ -1,20 +1,19 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; import 'package:fluffychat/pangea/bot/utils/bot_room_extension.dart'; -import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart'; +import 'package:fluffychat/pangea/chat_settings/utils/bot_client_extension.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/language_level_dropdown.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/common/widgets/dropdown_text_button.dart'; import 'package:fluffychat/pangea/languages/language_model.dart'; import 'package:fluffychat/pangea/languages/p_language_store.dart'; import 'package:fluffychat/pangea/learning_settings/language_level_type_enum.dart'; import 'package:fluffychat/pangea/learning_settings/p_language_dropdown.dart'; +import 'package:fluffychat/pangea/learning_settings/voice_dropdown.dart'; +import 'package:fluffychat/pangea/user/user_model.dart' as user; import 'package:fluffychat/widgets/matrix.dart'; class BotChatSettingsDialog extends StatefulWidget { @@ -51,66 +50,65 @@ class BotChatSettingsDialogState extends State { bool get _isActivity => widget.room.isActivitySession; + user.Profile get _userProfile => + MatrixState.pangeaController.userController.profile; + + Future _update(user.Profile Function(user.Profile) update) async { + try { + await MatrixState.pangeaController.userController + .updateProfile(update, waitForDataInSync: true) + .timeout(const Duration(seconds: 15)); + await Matrix.of(context).client.updateBotOptions( + _userProfile.userSettings, + ); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'roomId': widget.room.id, + 'model': _userProfile.toJson(), + }, + ); + } + } + Future _setLanguage(LanguageModel? lang) async { + if (lang == null || + lang.langCode == _userProfile.userSettings.targetLanguage) { + return; + } + setState(() { _selectedLang = lang; _selectedVoice = null; }); - final model = widget.room.botOptions ?? BotOptionsModel(); - model.targetLanguage = lang?.langCode; - model.targetVoice = null; - - try { - await widget.room.setBotOptions(model); - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - 'roomId': widget.room.id, - 'langCode': lang?.langCode, - }, - ); - } + await _update((model) { + model.userSettings.targetLanguage = lang.langCode; + model.userSettings.voice = null; + return model; + }); } Future _setLevel(LanguageLevelTypeEnum? level) async { - if (level == null) return; - + if (level == null || level == _userProfile.userSettings.cefrLevel) return; setState(() => _selectedLevel = level); - final model = widget.room.botOptions ?? BotOptionsModel(); - model.languageLevel = level; - try { - await widget.room.setBotOptions(model); - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - 'roomId': widget.room.id, - 'level': level.name, - }, - ); - } + + await _update((model) { + model.userSettings.cefrLevel = level; + return model; + }); } Future _setVoice(String? voice) async { + if (voice == _userProfile.userSettings.voice) return; + setState(() => _selectedVoice = voice); - final model = widget.room.botOptions ?? BotOptionsModel(); - model.targetVoice = voice; - try { - await widget.room.setBotOptions(model); - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - 'roomId': widget.room.id, - 'voice': voice, - }, - ); - } + await _update((model) { + model.userSettings.voice = voice; + return model; + }); } @override @@ -154,36 +152,17 @@ class BotChatSettingsDialogState extends State { initialLevel: _selectedLevel, onChanged: _setLevel, enabled: !widget.room.isActivitySession, + // width: 300, + // maxHeight: 300, ), - DropdownButtonFormField2( - customButton: _selectedVoice != null - ? CustomDropdownTextButton(text: _selectedVoice!) - : null, - menuItemStyleData: const MenuItemStyleData( - padding: EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16.0, - ), - ), - decoration: InputDecoration( - labelText: L10n.of(context).voice, - ), - isExpanded: true, - dropdownStyleData: DropdownStyleData( - maxHeight: kIsWeb ? 250 : null, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(14.0), - ), - ), - items: (_selectedLang?.voices ?? []).map((voice) { - return DropdownMenuItem( - value: voice, - child: Text(voice), - ); - }).toList(), + VoiceDropdown( onChanged: _setVoice, value: _selectedVoice, + language: _selectedLang, + enabled: !widget.room.isActivitySession || + (_selectedLang != null && + _selectedLang == + MatrixState.pangeaController.userController.userL2), ), const SizedBox(), ], diff --git a/lib/pangea/chat/widgets/chat_floating_action_button.dart b/lib/pangea/chat/widgets/chat_floating_action_button.dart index b7bf6a8cc..88122aa3c 100644 --- a/lib/pangea/chat/widgets/chat_floating_action_button.dart +++ b/lib/pangea/chat/widgets/chat_floating_action_button.dart @@ -22,6 +22,7 @@ class ChatFloatingActionButton extends StatelessWidget { controller.choreographer.errorService, controller.choreographer.itController.open, controller.scrollController, + controller.scrollableNotifier, ], ), builder: (context, _) { diff --git a/lib/pangea/chat/widgets/chat_input_bar.dart b/lib/pangea/chat/widgets/chat_input_bar.dart index 78a0cdf4f..599c25abb 100644 --- a/lib/pangea/chat/widgets/chat_input_bar.dart +++ b/lib/pangea/chat/widgets/chat_input_bar.dart @@ -31,18 +31,30 @@ class ChatInputBar extends StatelessWidget { valueListenable: controller.choreographer.itController.open, builder: (context, open, __) { return open - ? InstructionsInlineTooltip( - instructionsEnum: InstructionsEnum.clickBestOption, - animate: false, - padding: EdgeInsets.only( - left: 16.0, - right: 16.0, - top: FluffyThemes.isColumnMode(context) ? 16.0 : 8.0, + ? Container( + constraints: const BoxConstraints( + maxWidth: FluffyThemes.maxTimelineWidth, + ), + alignment: Alignment.center, + child: InstructionsInlineTooltip( + instructionsEnum: InstructionsEnum.clickBestOption, + animate: false, + padding: EdgeInsets.only( + left: 16.0, + right: 16.0, + top: FluffyThemes.isColumnMode(context) ? 16.0 : 8.0, + ), ), ) - : ActivityRoleTooltip( - room: controller.room, - hide: controller.choreographer.itController.open, + : Container( + constraints: const BoxConstraints( + maxWidth: FluffyThemes.maxTimelineWidth, + ), + alignment: Alignment.center, + child: ActivityRoleTooltip( + room: controller.room, + hide: controller.choreographer.itController.open, + ), ); }, ), diff --git a/lib/pangea/chat/widgets/request_regeneration_button.dart b/lib/pangea/chat/widgets/request_regeneration_button.dart deleted file mode 100644 index eaa4cc56b..000000000 --- a/lib/pangea/chat/widgets/request_regeneration_button.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/l10n/l10n.dart'; - -class RequestRegenerationButton extends StatelessWidget { - final Color textColor; - final VoidCallback onPressed; - - const RequestRegenerationButton({ - super.key, - required this.textColor, - required this.onPressed, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only( - bottom: 8.0, - left: 16.0, - right: 16.0, - ), - child: TextButton( - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: const Size( - 0, - 0, - ), - ), - onPressed: onPressed, - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 4.0, - children: [ - Icon( - Icons.refresh, - color: textColor.withAlpha( - 164, - ), - size: 14, - ), - Text( - L10n.of( - context, - ).requestRegeneration, - style: TextStyle( - color: textColor.withAlpha( - 164, - ), - fontSize: 11, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/pangea/chat_list/support_client_extension.dart b/lib/pangea/chat_list/support_client_extension.dart new file mode 100644 index 000000000..c932df41e --- /dev/null +++ b/lib/pangea/chat_list/support_client_extension.dart @@ -0,0 +1,12 @@ +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/common/config/environment.dart'; + +extension SupportClientExtension on Client { + bool get hasSupportDM => rooms.any((r) => r.isSupportDM); +} + +extension SupportRoomExtension on Room { + bool get isSupportDM => + isDirectChat && directChatMatrixID == Environment.supportUserId; +} diff --git a/lib/pangea/chat_list/widgets/public_room_bottom_sheet.dart b/lib/pangea/chat_list/widgets/public_room_bottom_sheet.dart index 65d8587a8..b2894fc27 100644 --- a/lib/pangea/chat_list/widgets/public_room_bottom_sheet.dart +++ b/lib/pangea/chat_list/widgets/public_room_bottom_sheet.dart @@ -8,7 +8,6 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/extensions/pangea_rooms_chunk_extension.dart'; import 'package:fluffychat/pangea/join_codes/space_code_controller.dart'; -import 'package:fluffychat/pangea/navigation/navigation_util.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; @@ -30,7 +29,7 @@ class PublicRoomBottomSheet extends StatefulWidget { assert(roomAlias != null || chunk != null); } - static Future show({ + static Future show({ required BuildContext context, String? roomAlias, PublicRoomsChunk? chunk, @@ -91,26 +90,13 @@ class PublicRoomBottomSheetState extends State { notFoundError: L10n.of(context).notTheCodeError, ); if (resp != null) { - Navigator.of(context).pop(true); - } - } - - void _goToRoom(String roomID) { - if (chunk?.roomType != 'm.space' && !client.getRoomById(roomID)!.isSpace) { - NavigationUtil.goToSpaceRoute( - roomID, - [], - context, - ); - } else { - context.go('/rooms/spaces/$roomID/details'); + Navigator.of(context).pop(resp); } } Future _joinRoom() async { if (_isRoomMember) { - _goToRoom(room!.id); - Navigator.of(context).pop(); + Navigator.of(context).pop(room!.id); return; } @@ -131,15 +117,13 @@ class PublicRoomBottomSheetState extends State { ); if (result.result != null) { - _goToRoom(result.result!); - Navigator.of(context).pop(true); + Navigator.of(context).pop(result.result!); } } Future _knockRoom() async { if (_isRoomMember) { - _goToRoom(room!.id); - Navigator.of(context).pop(); + Navigator.of(context).pop(room!.id); return; } diff --git a/lib/pangea/chat_settings/models/bot_options_model.dart b/lib/pangea/chat_settings/models/bot_options_model.dart index 4108890d4..8800212b7 100644 --- a/lib/pangea/chat_settings/models/bot_options_model.dart +++ b/lib/pangea/chat_settings/models/bot_options_model.dart @@ -5,26 +5,28 @@ import 'package:flutter/foundation.dart'; import 'package:fluffychat/pangea/chat_settings/constants/bot_mode.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/learning_settings/gender_enum.dart'; import 'package:fluffychat/pangea/learning_settings/language_level_type_enum.dart'; class BotOptionsModel { - LanguageLevelTypeEnum languageLevel; - String topic; - List keywords; - bool safetyModeration; - String mode; - String? discussionTopic; - String? discussionKeywords; - bool? discussionTriggerReactionEnabled; - String? discussionTriggerReactionKey; - String? customSystemPrompt; - bool? customTriggerReactionEnabled; - String? customTriggerReactionKey; - String? textAdventureGameMasterInstructions; - String? targetLanguage; - String? targetVoice; + final LanguageLevelTypeEnum languageLevel; + final String topic; + final List keywords; + final bool safetyModeration; + final String mode; + final String? discussionTopic; + final String? discussionKeywords; + final bool? discussionTriggerReactionEnabled; + final String? discussionTriggerReactionKey; + final String? customSystemPrompt; + final bool? customTriggerReactionEnabled; + final String? customTriggerReactionKey; + final String? textAdventureGameMasterInstructions; + final String? targetLanguage; + final String? targetVoice; + final Map userGenders; - BotOptionsModel({ + const BotOptionsModel({ //////////////////////////////////////////////////////////////////////////// // General Bot Options //////////////////////////////////////////////////////////////////////////// @@ -35,6 +37,7 @@ class BotOptionsModel { this.mode = BotMode.discussion, this.targetLanguage, this.targetVoice, + this.userGenders = const {}, //////////////////////////////////////////////////////////////////////////// // Discussion Mode Options @@ -58,6 +61,22 @@ class BotOptionsModel { }); factory BotOptionsModel.fromJson(json) { + final genderEntry = json[ModelKey.targetGender]; + Map targetGenders = {}; + if (genderEntry is Map) { + targetGenders = Map.fromEntries( + genderEntry.entries.map( + (e) => MapEntry( + e.key, + GenderEnum.values.firstWhere( + (g) => g.name == e.value, + orElse: () => GenderEnum.unselected, + ), + ), + ), + ); + } + return BotOptionsModel( ////////////////////////////////////////////////////////////////////////// // General Bot Options @@ -73,6 +92,7 @@ class BotOptionsModel { mode: json[ModelKey.mode] ?? BotMode.discussion, targetLanguage: json[ModelKey.targetLanguage], targetVoice: json[ModelKey.targetVoice], + userGenders: targetGenders, ////////////////////////////////////////////////////////////////////////// // Discussion Mode Options @@ -103,6 +123,11 @@ class BotOptionsModel { Map toJson() { final data = {}; try { + final Map 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; @@ -121,6 +146,7 @@ class BotOptionsModel { data[ModelKey.customTriggerReactionKey] = customTriggerReactionKey ?? "⏩"; data[ModelKey.textAdventureGameMasterInstructions] = textAdventureGameMasterInstructions; + data[ModelKey.targetGender] = gendersEntry; return data; } catch (e, s) { debugger(when: kDebugMode); @@ -133,50 +159,47 @@ class BotOptionsModel { } } - //TODO: define enum with all possible values - updateBotOption(String key, dynamic value) { - switch (key) { - case ModelKey.languageLevel: - languageLevel = value; - break; - case ModelKey.safetyModeration: - safetyModeration = value; - break; - case ModelKey.mode: - mode = value; - break; - case ModelKey.discussionTopic: - discussionTopic = value; - break; - case ModelKey.discussionKeywords: - discussionKeywords = value; - break; - case ModelKey.discussionTriggerReactionEnabled: - discussionTriggerReactionEnabled = value; - break; - case ModelKey.discussionTriggerReactionKey: - discussionTriggerReactionKey = value; - break; - case ModelKey.customSystemPrompt: - customSystemPrompt = value; - break; - case ModelKey.customTriggerReactionEnabled: - customTriggerReactionEnabled = value; - break; - case ModelKey.customTriggerReactionKey: - customTriggerReactionKey = value; - break; - case ModelKey.textAdventureGameMasterInstructions: - textAdventureGameMasterInstructions = value; - break; - case ModelKey.targetLanguage: - targetLanguage = value; - break; - case ModelKey.targetVoice: - targetVoice = value; - break; - default: - throw Exception('Invalid key for bot options - $key'); - } + BotOptionsModel copyWith({ + LanguageLevelTypeEnum? languageLevel, + String? topic, + List? keywords, + bool? safetyModeration, + String? mode, + String? discussionTopic, + String? discussionKeywords, + bool? discussionTriggerReactionEnabled, + String? discussionTriggerReactionKey, + String? customSystemPrompt, + bool? customTriggerReactionEnabled, + String? customTriggerReactionKey, + String? textAdventureGameMasterInstructions, + String? targetLanguage, + String? targetVoice, + Map? userGenders, + }) { + return BotOptionsModel( + languageLevel: languageLevel ?? this.languageLevel, + topic: topic ?? this.topic, + keywords: keywords ?? this.keywords, + safetyModeration: safetyModeration ?? this.safetyModeration, + mode: mode ?? this.mode, + discussionTopic: discussionTopic ?? this.discussionTopic, + discussionKeywords: discussionKeywords ?? this.discussionKeywords, + discussionTriggerReactionEnabled: discussionTriggerReactionEnabled ?? + this.discussionTriggerReactionEnabled, + discussionTriggerReactionKey: + discussionTriggerReactionKey ?? this.discussionTriggerReactionKey, + customSystemPrompt: customSystemPrompt ?? this.customSystemPrompt, + customTriggerReactionEnabled: + customTriggerReactionEnabled ?? this.customTriggerReactionEnabled, + customTriggerReactionKey: + customTriggerReactionKey ?? this.customTriggerReactionKey, + textAdventureGameMasterInstructions: + textAdventureGameMasterInstructions ?? + this.textAdventureGameMasterInstructions, + targetLanguage: targetLanguage ?? this.targetLanguage, + targetVoice: targetVoice ?? this.targetVoice, + userGenders: userGenders ?? this.userGenders, + ); } } diff --git a/lib/pangea/chat_settings/pages/pangea_invitation_selection.dart b/lib/pangea/chat_settings/pages/pangea_invitation_selection.dart index abcbf06c4..bdf7e050c 100644 --- a/lib/pangea/chat_settings/pages/pangea_invitation_selection.dart +++ b/lib/pangea/chat_settings/pages/pangea_invitation_selection.dart @@ -9,10 +9,10 @@ import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/chat_settings/pages/pangea_invitation_selection_view.dart'; -import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/extensions/join_rule_extension.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/user/user_search_extension.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -148,6 +148,20 @@ class PangeaInvitationSelectionController return parents.first; } + bool get showInviteAllInSpaceButton { + final roomParticipants = participants; + if (roomParticipants == null || + filter != InvitationFilter.space || + spaceParent == null) { + return false; + } + + final spaceParticipants = spaceParent!.getParticipants(); + return spaceParticipants.any( + (participant) => !roomParticipants.any((p) => p.id == participant.id), + ); + } + List get availableFilters => InvitationFilter.values .where( (f) => switch (f) { @@ -323,20 +337,11 @@ class PangeaInvitationSelectionController setState(() => foundProfiles = []); } - String pangeaSearchText = text; - if (!pangeaSearchText.startsWith("@")) { - pangeaSearchText = "@$pangeaSearchText"; - } - if (!pangeaSearchText.contains(":")) { - pangeaSearchText = "$pangeaSearchText:${Environment.homeServer}"; - } - setState(() => loading = true); final matrix = Matrix.of(context); SearchUserDirectoryResponse response; try { - response = - await matrix.client.searchUserDirectory(pangeaSearchText, limit: 100); + response = await matrix.client.searchUser(text, limit: 100); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text((e).toLocalizedString(context))), diff --git a/lib/pangea/chat_settings/pages/pangea_invitation_selection_view.dart b/lib/pangea/chat_settings/pages/pangea_invitation_selection_view.dart index d4515a353..0b050a1ed 100644 --- a/lib/pangea/chat_settings/pages/pangea_invitation_selection_view.dart +++ b/lib/pangea/chat_settings/pages/pangea_invitation_selection_view.dart @@ -157,27 +157,34 @@ class PangeaInvitationSelectionView extends StatelessWidget { final participants = room.getParticipants().map((user) => user.id).toSet(); return controller.filter == InvitationFilter.public - ? ListView.builder( - itemCount: controller.foundProfiles.length, - itemBuilder: (BuildContext context, int i) => - _InviteContactListTile( - profile: controller.foundProfiles[i], - isMember: participants.contains( - controller.foundProfiles[i].userId, - ), - onTap: () => controller.inviteAction( - controller.foundProfiles[i].userId, - ), - controller: controller, - ), - ) + ? controller.foundProfiles.isEmpty + ? Padding( + padding: const EdgeInsets.all(24.0), + child: Text( + room.isSpace + ? L10n.of(context).publicInviteDescSpace + : L10n.of(context).publicInviteDescChat, + ), + ) + : ListView.builder( + itemCount: controller.foundProfiles.length, + itemBuilder: (BuildContext context, int i) => + _InviteContactListTile( + profile: controller.foundProfiles[i], + isMember: participants.contains( + controller.foundProfiles[i].userId, + ), + onTap: () => controller.inviteAction( + controller.foundProfiles[i].userId, + ), + controller: controller, + ), + ) : ListView.builder( itemCount: contacts.length + 2, itemBuilder: (BuildContext context, int i) { if (i == 0) { - return controller.filter == - InvitationFilter.space && - controller.spaceParent != null + return controller.showInviteAllInSpaceButton ? ListTile( leading: ClipPath( clipper: MapClipper(), diff --git a/lib/pangea/chat_settings/pages/space_details_content.dart b/lib/pangea/chat_settings/pages/space_details_content.dart index 6a4db73b4..b57233edd 100644 --- a/lib/pangea/chat_settings/pages/space_details_content.dart +++ b/lib/pangea/chat_settings/pages/space_details_content.dart @@ -296,7 +296,7 @@ class SpaceDetailsContent extends StatelessWidget { ], Flexible( child: Column( - spacing: 12.0, + spacing: isColumnMode ? 12.0 : 6.0, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -311,7 +311,7 @@ class SpaceDetailsContent extends StatelessWidget { : FontWeight.bold, ), ), - if (isColumnMode && room.coursePlan != null) + if (room.coursePlan != null) CourseInfoChips( room.coursePlan!.uuid, fontSize: 12.0, diff --git a/lib/pangea/chat_settings/utils/bot_client_extension.dart b/lib/pangea/chat_settings/utils/bot_client_extension.dart index 6fbb1c636..c1ad861b7 100644 --- a/lib/pangea/chat_settings/utils/bot_client_extension.dart +++ b/lib/pangea/chat_settings/utils/bot_client_extension.dart @@ -1,29 +1,33 @@ import 'package:collection/collection.dart'; import 'package:matrix/matrix.dart'; +import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/bot/utils/bot_room_extension.dart'; import 'package:fluffychat/pangea/chat/constants/default_power_level.dart'; 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'; extension BotClientExtension on Client { bool get hasBotDM => rooms.any((r) => r.isBotDM); + Room? get botDM => rooms.firstWhereOrNull((r) => r.isBotDM); - Room? get botDM => rooms.firstWhereOrNull( - (room) { - if (room.isDirectChat && - room.directChatMatrixID == BotName.byEnvironment) { - return true; - } - if (room.botOptions?.mode == BotMode.directChat) { - return true; - } - return false; - }, - ); + // All 2-member rooms with the bot + List get _targetBotChats => rooms.where((r) { + return + // bot settings exist + r.botOptions != null && + // there is no activity plan + r.activityPlan == null && + // it's just the bot and one other user in the room + r.summary.mJoinedMemberCount == 2 && + r.getParticipants().any((u) => u.id == BotName.byEnvironment); + }).toList(); Future startChatWithBot() => startDirectChat( BotName.byEnvironment, @@ -45,27 +49,52 @@ extension BotClientExtension on Client { ], ); - Future updateBotOptions() async { - if (!isLogged() || botDM == null) return; + Future updateBotOptions(UserSettings userSettings) async { + final targetBotRooms = [..._targetBotChats]; + if (targetBotRooms.isEmpty) return; - final targetLanguage = - MatrixState.pangeaController.userController.userL2?.langCode; - final cefrLevel = MatrixState - .pangeaController.userController.profile.userSettings.cefrLevel; - final updateBotOptions = botDM!.botOptions ?? BotOptionsModel(); + try { + final futures = []; + for (final targetBotRoom in targetBotRooms) { + final botOptions = targetBotRoom.botOptions ?? const BotOptionsModel(); + final targetLanguage = userSettings.targetLanguage; + final languageLevel = userSettings.cefrLevel; + final voice = userSettings.voice; + final gender = userSettings.gender; - if (updateBotOptions.targetLanguage == targetLanguage && - updateBotOptions.languageLevel == cefrLevel) { - return; + if (botOptions.targetLanguage == targetLanguage && + botOptions.languageLevel == languageLevel && + botOptions.targetVoice == voice && + botOptions.userGenders[userID] == gender) { + continue; + } + + final updatedGenders = + Map.from(botOptions.userGenders); + + if (updatedGenders[userID] != gender) { + updatedGenders[userID!] = gender; + } + + final updated = botOptions.copyWith( + targetLanguage: targetLanguage, + languageLevel: languageLevel, + targetVoice: voice, + userGenders: updatedGenders, + ); + futures.add(targetBotRoom.setBotOptions(updated)); + } + + await Future.wait(futures); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'userSettings': userSettings.toJson(), + 'targetBotRooms': targetBotRooms.map((r) => r.id).toList(), + }, + ); } - - if (targetLanguage != null && - updateBotOptions.targetLanguage != targetLanguage) { - updateBotOptions.targetVoice = null; - } - - updateBotOptions.targetLanguage = targetLanguage; - updateBotOptions.languageLevel = cefrLevel; - await botDM!.setBotOptions(updateBotOptions); } } diff --git a/lib/pangea/chat_settings/widgets/language_level_dropdown.dart b/lib/pangea/chat_settings/widgets/language_level_dropdown.dart index 6dafafaad..cf0b5d11d 100644 --- a/lib/pangea/chat_settings/widgets/language_level_dropdown.dart +++ b/lib/pangea/chat_settings/widgets/language_level_dropdown.dart @@ -1,90 +1,72 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:dropdown_button2/dropdown_button2.dart'; - -import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/common/widgets/dropdown_text_button.dart'; import 'package:fluffychat/pangea/learning_settings/language_level_type_enum.dart'; class LanguageLevelDropdown extends StatelessWidget { final LanguageLevelTypeEnum? initialLevel; final Function(LanguageLevelTypeEnum)? onChanged; - final FormFieldValidator? validator; final bool enabled; - final Color? backgroundColor; const LanguageLevelDropdown({ super.key, this.initialLevel = LanguageLevelTypeEnum.a1, this.onChanged, - this.validator, this.enabled = true, - this.backgroundColor, }); @override Widget build(BuildContext context) { final l10n = L10n.of(context); - return DropdownButtonFormField2( - customButton: initialLevel != null && - LanguageLevelTypeEnum.values.contains(initialLevel) - ? CustomDropdownTextButton(text: initialLevel!.title(context)) - : null, - menuItemStyleData: MenuItemStyleData( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16.0, + return ButtonTheme( + alignedDropdown: true, + child: DropdownButtonFormField( + itemHeight: null, + decoration: InputDecoration( + labelText: l10n.cefrLevelLabel, ), - height: FluffyThemes.isColumnMode(context) ? 100.0 : 150.0, - ), - decoration: InputDecoration( - labelText: l10n.cefrLevelLabel, - ), - isExpanded: true, - dropdownStyleData: DropdownStyleData( - maxHeight: kIsWeb ? 500 : null, - decoration: BoxDecoration( - color: backgroundColor ?? - Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(14.0), - ), - ), - items: - LanguageLevelTypeEnum.values.map((LanguageLevelTypeEnum levelOption) { - return DropdownMenuItem( - value: levelOption, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.min, - children: [ - Text(levelOption.title(context)), - Flexible( - child: Text( - levelOption.description(context), - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 14, + selectedItemBuilder: (context) => LanguageLevelTypeEnum.values + .map((levelOption) => Text(levelOption.title(context))) + .toList(), + isExpanded: true, + dropdownColor: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14.0), + onChanged: enabled + ? (value) { + if (value != null) onChanged?.call(value); + } + : null, + initialValue: initialLevel, + items: LanguageLevelTypeEnum.values + .map((LanguageLevelTypeEnum levelOption) { + return DropdownMenuItem( + value: levelOption, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + Text(levelOption.title(context)), + Flexible( + child: Text( + levelOption.description(context), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 14, + ), + maxLines: 5, + overflow: TextOverflow.ellipsis, + ), ), - maxLines: 5, - overflow: TextOverflow.ellipsis, - ), + ], ), - ], - ), - ); - }).toList(), - onChanged: enabled - ? (value) { - if (value != null) onChanged?.call(value); - } - : null, - value: initialLevel, - validator: validator, - enableFeedback: enabled, + ), + ); + }).toList(), + ), ); } } diff --git a/lib/pangea/choreographer/choreo_constants.dart b/lib/pangea/choreographer/choreo_constants.dart index 85b9e6186..a303266ce 100644 --- a/lib/pangea/choreographer/choreo_constants.dart +++ b/lib/pangea/choreographer/choreo_constants.dart @@ -11,4 +11,5 @@ class ChoreoConstants { static const int msBeforeIGCStart = 10000; static const int maxLength = 1000; static const String inputTransformTargetKey = 'input_text_field'; + static const int defaultErrorBackoffSeconds = 5; } diff --git a/lib/pangea/choreographer/choreographer.dart b/lib/pangea/choreographer/choreographer.dart index 52348dea6..169f5edeb 100644 --- a/lib/pangea/choreographer/choreographer.dart +++ b/lib/pangea/choreographer/choreographer.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:async/async.dart'; + import 'package:fluffychat/pangea/choreographer/assistance_state_enum.dart'; import 'package:fluffychat/pangea/choreographer/choreo_constants.dart'; import 'package:fluffychat/pangea/choreographer/choreo_mode_enum.dart'; @@ -45,6 +47,11 @@ class Choreographer extends ChangeNotifier { String? _lastChecked; ChoreoModeEnum _choreoMode = ChoreoModeEnum.igc; + DateTime? _lastIgcError; + DateTime? _lastTokensError; + int _igcErrorBackoff = ChoreoConstants.defaultErrorBackoffSeconds; + int _tokenErrorBackoff = ChoreoConstants.defaultErrorBackoffSeconds; + StreamSubscription? _languageSub; StreamSubscription? _settingsUpdateSub; StreamSubscription? _acceptedContinuanceSub; @@ -68,6 +75,12 @@ class Choreographer extends ChangeNotifier { openMatches: [], ); + bool _backoffRequest(DateTime? error, int backoffSeconds) { + if (error == null) return false; + final secondsSinceError = DateTime.now().difference(error).inSeconds; + return secondsSinceError <= backoffSeconds; + } + void _initialize() { textController = PangeaTextController(choreographer: this); textController.addListener(_onChange); @@ -82,7 +95,14 @@ class Choreographer extends ChangeNotifier { itController.editing.addListener(_onSubmitSourceTextEdits); igcController = IgcController( - (e) => errorService.setErrorAndLock(ChoreoError(raw: e)), + (e) { + errorService.setErrorAndLock(ChoreoError(raw: e)); + _lastIgcError = DateTime.now(); + _igcErrorBackoff *= 2; + }, + () { + _igcErrorBackoff = ChoreoConstants.defaultErrorBackoffSeconds; + }, ); _languageSub ??= MatrixState @@ -111,7 +131,7 @@ class Choreographer extends ChangeNotifier { _choreoRecord = null; itController.closeIT(); itController.clearSourceText(); - itController.clearDissmissed(); + itController.clearSession(); igcController.clear(); _resetDebounceTimer(); _setChoreoMode(ChoreoModeEnum.igc); @@ -233,7 +253,8 @@ class Choreographer extends ChangeNotifier { !ToolSetting.interactiveTranslator.enabled) || (!ToolSetting.autoIGC.enabled && !manual && - _choreoMode != ChoreoModeEnum.it)) { + _choreoMode != ChoreoModeEnum.it) || + _backoffRequest(_lastIgcError, _igcErrorBackoff)) { return; } @@ -275,7 +296,9 @@ class Choreographer extends ChangeNotifier { MatrixState.pangeaController.userController.userL2?.langCode; final l1LangCode = MatrixState.pangeaController.userController.userL1?.langCode; - if (l1LangCode != null && l2LangCode != null) { + if (l1LangCode != null && + l2LangCode != null && + !_backoffRequest(_lastTokensError, _tokenErrorBackoff)) { final res = await TokensRepo.get( MatrixState.pangeaController.userController.accessToken, TokensRequestModel( @@ -283,7 +306,21 @@ class Choreographer extends ChangeNotifier { senderL1: l1LangCode, senderL2: l2LangCode, ), + ).timeout( + const Duration(seconds: 10), + onTimeout: () { + return Result.error("Token request timed out"); + }, ); + + if (res.isError) { + _lastTokensError = DateTime.now(); + _tokenErrorBackoff *= 2; + } else { + // reset backoff on success + _tokenErrorBackoff = ChoreoConstants.defaultErrorBackoffSeconds; + } + tokensResp = res.isValue ? res.result : null; } @@ -316,6 +353,7 @@ class Choreographer extends ChangeNotifier { } void _onOpenIT() { + inputFocus.unfocus(); final itMatch = igcController.openMatches.firstWhere( (match) => match.updatedMatch.isITStart, orElse: () => @@ -334,14 +372,15 @@ class Choreographer extends ChangeNotifier { } void _onCloseIT() { - if (currentText.isEmpty && itController.sourceText.value != null) { + if (itController.dismissed && + currentText.isEmpty && + itController.sourceText.value != null) { textController.setSystemText( itController.sourceText.value!, EditTypeEnum.itDismissed, ); } - debugPrint("DISMISSED: ${itController.dismissed}"); if (itController.dismissed) { _timesDismissedIT.value = _timesDismissedIT.value + 1; } diff --git a/lib/pangea/choreographer/igc/igc_controller.dart b/lib/pangea/choreographer/igc/igc_controller.dart index afe661ff9..9b78cb439 100644 --- a/lib/pangea/choreographer/igc/igc_controller.dart +++ b/lib/pangea/choreographer/igc/igc_controller.dart @@ -18,7 +18,8 @@ import 'package:fluffychat/widgets/matrix.dart'; class IgcController { final Function(Object) onError; - IgcController(this.onError); + final VoidCallback onFetch; + IgcController(this.onError, this.onFetch); bool _isFetching = false; String? _currentText; @@ -321,6 +322,8 @@ class IgcController { onError(res.asError!); clear(); return; + } else { + onFetch(); } if (!_isFetching) return; diff --git a/lib/pangea/choreographer/igc/span_data_model.dart b/lib/pangea/choreographer/igc/span_data_model.dart index 220e245f2..ac65a1347 100644 --- a/lib/pangea/choreographer/igc/span_data_model.dart +++ b/lib/pangea/choreographer/igc/span_data_model.dart @@ -132,6 +132,9 @@ class SpanData { return choices![index]; } + String get errorSpan => + fullText.characters.skip(offset).take(length).toString(); + bool isNormalizationError() { final correctChoice = choices ?.firstWhereOrNull( @@ -139,8 +142,6 @@ class SpanData { ) ?.value; - final errorSpan = fullText.characters.skip(offset).take(length).toString(); - final l2Code = MatrixState.pangeaController.userController.userL2?.langCodeShort; diff --git a/lib/pangea/choreographer/igc/text_normalization_util.dart b/lib/pangea/choreographer/igc/text_normalization_util.dart index d510a71ca..08364c3e7 100644 --- a/lib/pangea/choreographer/igc/text_normalization_util.dart +++ b/lib/pangea/choreographer/igc/text_normalization_util.dart @@ -27,7 +27,7 @@ String normalizeString(String input, String languageCode) { ); // Step 5: Normalize whitespace (collapse multiple spaces, trim) - return normalized.replaceAll(RegExp(r'\s+'), ' ').trim(); + return normalized.replaceAll(RegExp(r'\s+'), '').trim(); } catch (e, s) { ErrorHandler.logError( e: e, diff --git a/lib/pangea/choreographer/it/it_bar.dart b/lib/pangea/choreographer/it/it_bar.dart index db088a019..fc856954b 100644 --- a/lib/pangea/choreographer/it/it_bar.dart +++ b/lib/pangea/choreographer/it/it_bar.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart'; import 'package:fluffychat/pangea/choreographer/choreo_constants.dart'; import 'package:fluffychat/pangea/choreographer/choreographer.dart'; import 'package:fluffychat/pangea/choreographer/it/completed_it_step_model.dart'; @@ -200,6 +201,7 @@ class ITBarState extends State with SingleTickerProviderStateMixin { setEditing: widget.choreographer.itController.setEditingSourceText, editing: widget.choreographer.itController.editing, + progress: widget.choreographer.itController.progress, sourceTextController: _sourceTextController, sourceText: _sourceText, onSubmitEdits: (_) { @@ -267,6 +269,7 @@ class _ITBarHeader extends StatelessWidget { final Function(bool) setEditing; final ValueNotifier editing; + final ValueNotifier progress; final TextEditingController sourceTextController; final ValueNotifier sourceText; @@ -274,6 +277,7 @@ class _ITBarHeader extends StatelessWidget { required this.onClose, required this.setEditing, required this.editing, + required this.progress, required this.onSubmitEdits, required this.sourceTextController, required this.sourceText, @@ -316,8 +320,26 @@ class _ITBarHeader extends StatelessWidget { ], ), secondChild: Row( - mainAxisAlignment: MainAxisAlignment.end, children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0), + child: ValueListenableBuilder( + valueListenable: progress, + builder: (context, value, __) => AnimatedProgressBar( + height: 20.0, + widthPercent: value, + backgroundColor: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + barColor: Theme.of(context) + .colorScheme + .primary + .withAlpha(180), + ), + ), + ), + ), IconButton( color: Theme.of(context).colorScheme.primary, onPressed: () => setEditing(true), diff --git a/lib/pangea/choreographer/it/it_controller.dart b/lib/pangea/choreographer/it/it_controller.dart index a90c20c00..34efef30d 100644 --- a/lib/pangea/choreographer/it/it_controller.dart +++ b/lib/pangea/choreographer/it/it_controller.dart @@ -24,11 +24,14 @@ class ITController { final ValueNotifier _currentITStep = ValueNotifier(null); final ValueNotifier _open = ValueNotifier(false); final ValueNotifier _editing = ValueNotifier(false); + final ValueNotifier _progress = ValueNotifier(0.0); ITController(this.onError); ValueNotifier get open => _open; ValueNotifier get editing => _editing; + ValueNotifier get progress => _progress; + ValueNotifier get currentITStep => _currentITStep; ValueNotifier get sourceText => _sourceText; StreamController acceptedContinuanceStream = @@ -62,8 +65,9 @@ class ITController { _sourceText.value = null; } - void clearDissmissed() { + void clearSession() { dismissed = false; + _progress.value = 0.0; } void dispose() { @@ -102,6 +106,7 @@ class ITController { _queue.clear(); _currentITStep.value = null; _goldRouteTracker = null; + _progress.value = 0.0; _sourceText.value = text; setEditingSourceText(false); _continueIT(); @@ -142,6 +147,14 @@ class ITController { chosen: chosenIndex, ), ); + final progress = (_goldRouteTracker!.continuances.indexWhere( + (c) => + c.text == + _currentITStep.value!.continuances[chosenIndex].text, + ) + + 1) / + _goldRouteTracker!.continuances.length; + _progress.value = progress; _continueIT(); } diff --git a/lib/pangea/common/config/environment.dart b/lib/pangea/common/config/environment.dart index 970017bd6..53388f2dc 100644 --- a/lib/pangea/common/config/environment.dart +++ b/lib/pangea/common/config/environment.dart @@ -107,7 +107,9 @@ class Environment { static String get stripeManagementUrl { return appConfigOverride?.stripeManagementUrl ?? dotenv.env["STRIPE_MANAGEMENT_LINK"] ?? - 'https://billing.stripe.com/p/login/dR6dSkf5p6rBc4EcMM'; + (isStagingEnvironment + ? 'https://billing.stripe.com/p/login/test_9AQaI8d3O9lmaXe5kk' + : 'https://billing.stripe.com/p/login/dR6dSkf5p6rBc4EcMM'); } static String get supportUserId { diff --git a/lib/pangea/common/constants/local.key.dart b/lib/pangea/common/constants/local.key.dart index 9d722ca6c..1aae92846 100644 --- a/lib/pangea/common/constants/local.key.dart +++ b/lib/pangea/common/constants/local.key.dart @@ -5,6 +5,7 @@ class PLocalKey { static const String dismissedPaywall = 'dismissedPaywall'; static const String paywallBackoff = 'paywallBackoff'; static const String clickedCancelSubscription = 'clickedCancelSubscription'; + static const String subscriptionEndDate = 'subscriptionWillEnd'; static const String messagesSinceUpdate = 'messagesSinceLastUpdate'; static const String completedActivities = 'completedActivities'; static const String justInputtedCode = 'justInputtedCode'; diff --git a/lib/pangea/common/constants/model_keys.dart b/lib/pangea/common/constants/model_keys.dart index 9296826b9..84b69e97b 100644 --- a/lib/pangea/common/constants/model_keys.dart +++ b/lib/pangea/common/constants/model_keys.dart @@ -109,6 +109,7 @@ class ModelKey { static const String transcription = "transcription"; static const String botTranscription = 'bot_transcription'; + static const String voice = "voice"; // bot options static const String languageLevel = "difficulty"; @@ -131,6 +132,7 @@ class ModelKey { static const String targetLanguage = "target_language"; static const String sourceLanguage = "source_language"; static const String targetVoice = "target_voice"; + static const String targetGender = "users_genders"; static const String prevEventId = "prev_event_id"; static const String prevLastUpdated = "prev_last_updated"; diff --git a/lib/pangea/common/controllers/pangea_controller.dart b/lib/pangea/common/controllers/pangea_controller.dart index 3d80b2204..71a4a5987 100644 --- a/lib/pangea/common/controllers/pangea_controller.dart +++ b/lib/pangea/common/controllers/pangea_controller.dart @@ -18,6 +18,7 @@ import 'package:fluffychat/pangea/languages/p_language_store.dart'; import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart'; import 'package:fluffychat/pangea/text_to_speech/tts_controller.dart'; import 'package:fluffychat/pangea/user/pangea_push_rules_extension.dart'; +import 'package:fluffychat/pangea/user/style_settings_repo.dart'; import 'package:fluffychat/pangea/user/user_controller.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../utils/firebase_analytics.dart'; @@ -55,7 +56,7 @@ class PangeaController { TtsController.setAvailableLanguages(); } - void _onLogin(BuildContext context) { + void _onLogin(BuildContext context, String? userID) { initControllers(); _registerSubscriptions(); @@ -64,6 +65,12 @@ class PangeaController { Provider.of(context, listen: false).setLocale(l1); }); subscriptionController.reinitialize(); + + StyleSettingsRepo.settings(userID!).then((settings) { + AppConfig.fontSizeFactor = settings.fontSizeFactor; + AppConfig.useActivityImageAsChatBackground = + settings.useActivityImageBackground; + }); } void _onLogout(BuildContext context) { @@ -91,7 +98,7 @@ class PangeaController { _onLogout(context); break; case LoginState.loggedIn: - _onLogin(context); + _onLogin(context, userID); break; } @@ -112,8 +119,9 @@ class PangeaController { userController.languageStream.stream.listen(_onLanguageUpdate); _settingsSubscription?.cancel(); - _settingsSubscription = userController.settingsUpdateStream.stream - .listen((_) => matrixState.client.updateBotOptions()); + _settingsSubscription = userController.settingsUpdateStream.stream.listen( + (update) => matrixState.client.updateBotOptions(update.userSettings), + ); _joinSpaceSubscription?.cancel(); _joinSpaceSubscription ??= matrixState.client.onSync.stream @@ -171,7 +179,7 @@ class PangeaController { } _clearCache(exclude: exclude); - matrixState.client.updateBotOptions(); + matrixState.client.updateBotOptions(userController.profile.userSettings); } static final List _storageKeys = [ diff --git a/lib/pangea/common/utils/async_state.dart b/lib/pangea/common/utils/async_state.dart index a2f15adbe..908f9e021 100644 --- a/lib/pangea/common/utils/async_state.dart +++ b/lib/pangea/common/utils/async_state.dart @@ -72,7 +72,7 @@ abstract class AsyncLoader { T? get value => isLoaded ? (state.value as AsyncLoaded).value : null; - final Completer completer = Completer(); + Completer completer = Completer(); void dispose() { _disposed = true; @@ -109,4 +109,10 @@ abstract class AsyncLoader { } } } + + void reset() { + if (_disposed) return; + state.value = AsyncState.idle(); + completer = Completer(); + } } diff --git a/lib/pangea/common/utils/overlay.dart b/lib/pangea/common/utils/overlay.dart index fcdaaf668..23bc2ff27 100644 --- a/lib/pangea/common/utils/overlay.dart +++ b/lib/pangea/common/utils/overlay.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:fluffychat/pangea/analytics_misc/gain_points_animation.dart'; +import 'package:fluffychat/pangea/analytics_misc/growth_animation.dart'; import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart'; import 'package:fluffychat/pangea/choreographer/choreo_constants.dart'; import 'package:fluffychat/pangea/choreographer/choreographer.dart'; @@ -13,6 +14,8 @@ import 'package:fluffychat/pangea/common/utils/any_state_holder.dart'; import 'package:fluffychat/pangea/common/widgets/anchored_overlay_widget.dart'; import 'package:fluffychat/pangea/common/widgets/overlay_container.dart'; import 'package:fluffychat/pangea/common/widgets/transparent_backdrop.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/learning_settings/language_mismatch_popup.dart'; import '../../../config/themes.dart'; import '../../../widgets/matrix.dart'; @@ -309,6 +312,30 @@ class OverlayUtil { closePrevOverlay: false, backDropToDismiss: false, ignorePointer: true, + canPop: false, + ); + } + + static void showGrowthAnimation( + BuildContext context, + String targetId, + ConstructLevelEnum level, + ConstructIdentifier constructId, + ) { + final overlayKey = "${targetId}_growth_${constructId.string}"; + showOverlay( + overlayKey: overlayKey, + followerAnchor: Alignment.topCenter, + targetAnchor: Alignment.topCenter, + context: context, + child: GrowthAnimation( + targetID: overlayKey, + level: level, + ), + transformTargetId: targetId, + closePrevOverlay: false, + backDropToDismiss: false, + ignorePointer: true, ); } diff --git a/lib/pangea/common/widgets/anchored_overlay_widget.dart b/lib/pangea/common/widgets/anchored_overlay_widget.dart index 208295582..60fd2dc9a 100644 --- a/lib/pangea/common/widgets/anchored_overlay_widget.dart +++ b/lib/pangea/common/widgets/anchored_overlay_widget.dart @@ -29,7 +29,7 @@ class AnchoredOverlayWidget extends StatefulWidget { class _AnchoredOverlayWidgetState extends State { bool _visible = false; - static const double overlayWidth = 200.0; + static const double overlayWidth = 300.0; @override void initState() { @@ -79,7 +79,7 @@ class _AnchoredOverlayWidgetState extends State { child: CustomPaint( painter: CutoutBackgroundPainter( holeRect: widget.anchorRect, - backgroundColor: Colors.black54, + backgroundColor: Colors.black.withAlpha(180), borderRadius: widget.borderRadius ?? 0.0, padding: widget.padding ?? 6.0, ), diff --git a/lib/pangea/common/widgets/shimmer_background.dart b/lib/pangea/common/widgets/shimmer_background.dart index 0fbc5104e..e3f5b83c7 100644 --- a/lib/pangea/common/widgets/shimmer_background.dart +++ b/lib/pangea/common/widgets/shimmer_background.dart @@ -9,6 +9,7 @@ class ShimmerBackground extends StatelessWidget { final Color shimmerColor; final Color? baseColor; final bool enabled; + final BorderRadius? borderRadius; const ShimmerBackground({ super.key, @@ -16,33 +17,38 @@ class ShimmerBackground extends StatelessWidget { this.shimmerColor = AppConfig.goldLight, this.baseColor, this.enabled = true, + this.borderRadius, }); @override Widget build(BuildContext context) { + if (!enabled) { + return child; + } + + final borderRadius = + this.borderRadius ?? BorderRadius.circular(AppConfig.borderRadius); return Stack( children: [ child, - if (enabled) - Positioned.fill( - child: IgnorePointer( - child: ClipRRect( - borderRadius: BorderRadius.circular(AppConfig.borderRadius), - child: Shimmer.fromColors( - baseColor: baseColor ?? shimmerColor.withValues(alpha: 0.1), - highlightColor: shimmerColor.withValues(alpha: 0.6), - direction: ShimmerDirection.ltr, - child: Container( - decoration: BoxDecoration( - color: shimmerColor.withValues(alpha: 0.3), - borderRadius: - BorderRadius.circular(AppConfig.borderRadius), - ), + Positioned.fill( + child: IgnorePointer( + child: ClipRRect( + borderRadius: borderRadius, + child: Shimmer.fromColors( + baseColor: baseColor ?? shimmerColor.withValues(alpha: 0.1), + highlightColor: shimmerColor.withValues(alpha: 0.6), + direction: ShimmerDirection.ltr, + child: Container( + decoration: BoxDecoration( + color: shimmerColor.withValues(alpha: 0.3), + borderRadius: borderRadius, ), ), ), ), ), + ), ], ); } diff --git a/lib/pangea/common/widgets/shrinkable_text.dart b/lib/pangea/common/widgets/shrinkable_text.dart index 18968ec48..8a3177ed2 100644 --- a/lib/pangea/common/widgets/shrinkable_text.dart +++ b/lib/pangea/common/widgets/shrinkable_text.dart @@ -4,11 +4,13 @@ class ShrinkableText extends StatelessWidget { final String text; final double maxWidth; final TextStyle? style; + final Alignment? alignment; const ShrinkableText({ super.key, required this.text, required this.maxWidth, + this.alignment, this.style, }); @@ -18,6 +20,7 @@ class ShrinkableText extends StatelessWidget { builder: (context, constraints) { return Container( constraints: BoxConstraints(maxWidth: maxWidth), + alignment: alignment, child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, diff --git a/lib/pangea/common/widgets/tutorial_overlay_message.dart b/lib/pangea/common/widgets/tutorial_overlay_message.dart index 8cb544401..aed582a2c 100644 --- a/lib/pangea/common/widgets/tutorial_overlay_message.dart +++ b/lib/pangea/common/widgets/tutorial_overlay_message.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:fluffychat/config/app_config.dart'; + class TutorialOverlayMessage extends StatelessWidget { final String message; @@ -12,34 +14,34 @@ class TutorialOverlayMessage extends StatelessWidget { Widget build(BuildContext context) { return Center( child: Container( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.all(8.0), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSurface, + // color: Theme.of(context).colorScheme.onSurface, + color: Color.alphaBlend( + Theme.of(context).colorScheme.surface.withAlpha(70), + AppConfig.gold, + ), borderRadius: BorderRadius.circular(12.0), ), - width: 200, alignment: Alignment.center, - child: RichText( - text: TextSpan( - style: TextStyle( - color: Theme.of(context).colorScheme.surface, + child: Row( + spacing: 4.0, + children: [ + Icon( + Icons.lightbulb, + size: 20.0, + color: Theme.of(context).colorScheme.onSurface, ), - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Icon( - Icons.info_outlined, - size: 16.0, - color: Theme.of(context).colorScheme.surface, - ), + Flexible( + child: Text( + message, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + textAlign: TextAlign.center, ), - const WidgetSpan(child: SizedBox(width: 4.0)), - TextSpan( - text: message, - ), - ], - ), - textAlign: TextAlign.center, + ), + ], ), ), ); diff --git a/lib/pangea/constructs/construct_identifier.dart b/lib/pangea/constructs/construct_identifier.dart index ffc7136bb..63943c074 100644 --- a/lib/pangea/constructs/construct_identifier.dart +++ b/lib/pangea/constructs/construct_identifier.dart @@ -11,7 +11,10 @@ import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/user_lemma_info_extension.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.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/lemmas/lemma_info_repo.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; @@ -190,4 +193,15 @@ class ConstructIdentifier { category: category, ); } + + PangeaToken get asToken => PangeaToken( + lemma: Lemma( + text: lemma, + saveVocab: true, + form: lemma, + ), + pos: category, + text: PangeaTokenText.fromString(lemma), + morph: {}, + ); } diff --git a/lib/pangea/course_chats/course_chats_page.dart b/lib/pangea/course_chats/course_chats_page.dart index 6dc095122..a26eeb56d 100644 --- a/lib/pangea/course_chats/course_chats_page.dart +++ b/lib/pangea/course_chats/course_chats_page.dart @@ -237,7 +237,10 @@ class CourseChatsController extends State Logs().w('Unable to load hierarchy', e, s); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toLocalizedString(context))), + SnackBar( + content: Text(e.toLocalizedString(context)), + showCloseIcon: true, + ), ); } } finally { @@ -442,7 +445,7 @@ class CourseChatsController extends State void joinChildRoom(SpaceRoomsChunk item) async { final space = widget.client.getRoomById(widget.roomId); - final joined = await PublicRoomBottomSheet.show( + final roomId = await PublicRoomBottomSheet.show( context: context, chunk: item, via: space?.spaceChildren @@ -451,10 +454,12 @@ class CourseChatsController extends State ) ?.via, ); - if (mounted && joined == true) { + if (mounted && roomId != null) { setState(() { discoveredChildren?.remove(item); }); + + NavigationUtil.goToSpaceRoute(roomId, [], context); } } diff --git a/lib/pangea/course_creation/public_course_preview.dart b/lib/pangea/course_creation/public_course_preview.dart new file mode 100644 index 000000000..15b5f3424 --- /dev/null +++ b/lib/pangea/course_creation/public_course_preview.dart @@ -0,0 +1,188 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/chat_settings/utils/room_summary_extension.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/course_creation/public_course_preview_view.dart'; +import 'package:fluffychat/pangea/course_plans/course_activities/activity_summaries_provider.dart'; +import 'package:fluffychat/pangea/course_plans/courses/course_plan_builder.dart'; +import 'package:fluffychat/pangea/join_codes/space_code_controller.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class PublicCoursePreview extends StatefulWidget { + final String? roomID; + + const PublicCoursePreview({ + super.key, + required this.roomID, + }); + + @override + PublicCoursePreviewController createState() => + PublicCoursePreviewController(); +} + +class PublicCoursePreviewController extends State + with CoursePlanProvider, ActivitySummariesProvider { + RoomSummaryResponse? roomSummary; + Object? roomSummaryError; + bool loadingRoomSummary = false; + + @override + initState() { + super.initState(); + _loadSummary(); + } + + @override + void didUpdateWidget(covariant PublicCoursePreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.roomID != oldWidget.roomID) { + _loadSummary(); + } + } + + bool get loading => loadingCourse || loadingRoomSummary; + bool get hasError => + (courseError != null || (!loadingCourse && course == null)) || + (roomSummaryError != null || + (!loadingRoomSummary && roomSummary == null)); + + Future _loadSummary() async { + try { + if (widget.roomID == null) { + throw Exception("roomID is required"); + } + + setState(() { + loadingRoomSummary = true; + roomSummaryError = null; + }); + + await loadRoomSummaries([widget.roomID!]); + if (roomSummaries == null || !roomSummaries!.containsKey(widget.roomID)) { + throw Exception("Room summary not found"); + } + + roomSummary = roomSummaries![widget.roomID]; + } catch (e, s) { + roomSummaryError = e; + loadingCourse = false; + + ErrorHandler.logError( + e: e, + s: s, + data: {'roomID': widget.roomID, 'roomSummary': roomSummary?.toJson()}, + ); + } finally { + if (mounted) { + setState(() { + loadingRoomSummary = false; + }); + } + } + + if (roomSummary?.coursePlan != null) { + await loadCourse(roomSummary!.coursePlan!.uuid).then((_) => loadTopics()); + } else { + ErrorHandler.logError( + e: Exception("No course plan found in room summary"), + data: {'roomID': widget.roomID, 'roomSummary': roomSummary?.toJson()}, + ); + if (mounted) { + setState(() { + roomSummaryError = Exception("No course plan found in room summary"); + loadingCourse = false; + }); + } + } + } + + Future joinWithCode(String code) async { + if (code.isEmpty) { + return; + } + + final roomId = await SpaceCodeController.joinSpaceWithCode( + context, + code, + ); + + if (roomId != null) { + final room = Matrix.of(context).client.getRoomById(roomId); + room?.isSpace ?? true + ? context.go('/rooms/spaces/$roomId/details') + : context.go('/rooms/$roomId'); + } + } + + Future joinCourse() async { + if (widget.roomID == null) { + throw Exception("roomID is required"); + } + + final roomID = widget.roomID; + + final client = Matrix.of(context).client; + final r = client.getRoomById(roomID!); + if (r != null && r.membership == Membership.join) { + if (mounted) { + context.go("/rooms/spaces/${r.id}/details"); + } + return; + } + + final knock = roomSummary?.joinRule == JoinRules.knock; + final resp = await showFutureLoadingDialog( + context: context, + future: () async { + String roomId; + try { + roomId = knock + ? await client.knockRoom(widget.roomID!) + : await client.joinRoom(widget.roomID!); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: {'roomID': widget.roomID}, + ); + rethrow; + } + + Room? room = client.getRoomById(roomId); + if (!knock && room?.membership != Membership.join) { + await client.waitForRoomInSync(roomId, join: true); + room = client.getRoomById(roomId); + } + + if (knock) return; + if (room == null) { + ErrorHandler.logError( + e: Exception("Failed to load joined room in public course preview"), + data: {'roomID': widget.roomID}, + ); + throw Exception("Failed to join room"); + } + context.go("/rooms/spaces/$roomId/details"); + }, + ); + + if (!knock || resp.isError) return; + await showOkAlertDialog( + context: context, + title: L10n.of(context).youHaveKnocked, + message: L10n.of(context).knockDesc, + ); + } + + @override + Widget build(BuildContext context) => PublicCoursePreviewView(this); +} diff --git a/lib/pangea/course_creation/public_course_preview_view.dart b/lib/pangea/course_creation/public_course_preview_view.dart new file mode 100644 index 000000000..213560f07 --- /dev/null +++ b/lib/pangea/course_creation/public_course_preview_view.dart @@ -0,0 +1,388 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/chat_settings/utils/room_summary_extension.dart'; +import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; +import 'package:fluffychat/pangea/common/widgets/url_image_widget.dart'; +import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart'; +import 'package:fluffychat/pangea/course_creation/public_course_preview.dart'; +import 'package:fluffychat/pangea/course_plans/map_clipper.dart'; +import 'package:fluffychat/pangea/course_settings/pin_clipper.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/user_dialog.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class PublicCoursePreviewView extends StatelessWidget { + final PublicCoursePreviewController controller; + const PublicCoursePreviewView( + this.controller, { + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + const double titleFontSize = 16.0; + const double descFontSize = 12.0; + + const double largeIconSize = 24.0; + const double smallIconSize = 12.0; + + return Scaffold( + appBar: AppBar( + title: Text(L10n.of(context).joinWithClassCode), + ), + body: SafeArea( + child: Container( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500.0), + child: Builder( + builder: (context) { + if (controller.loading) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + + if (controller.hasError) { + return Center( + child: ErrorIndicator( + message: L10n.of(context).oopsSomethingWentWrong, + ), + ); + } + + final course = controller.course!; + final summary = controller.roomSummary!; + + Uri? avatarUrl = course.imageUrl; + if (summary.avatarUrl != null) { + avatarUrl = Uri.tryParse(summary.avatarUrl!); + } + + final displayname = summary.displayName ?? course.title; + + return Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only( + top: 12.0, + left: 12.0, + right: 12.0, + ), + child: ListView.builder( + itemCount: course.topicIds.length + 2, + itemBuilder: (context, index) { + if (index == 0) { + return Column( + spacing: 8.0, + children: [ + ClipPath( + clipper: MapClipper(), + child: ImageByUrl( + imageUrl: avatarUrl, + width: 100.0, + borderRadius: BorderRadius.circular(0.0), + replacement: Avatar( + name: displayname, + size: 100.0, + borderRadius: BorderRadius.circular( + 0.0, + ), + ), + ), + ), + Text( + displayname, + style: const TextStyle( + fontSize: titleFontSize, + ), + ), + if (summary.adminUserIDs.isNotEmpty) + _CourseAdminDisplay(summary), + Text( + course.description, + style: const TextStyle( + fontSize: descFontSize, + ), + ), + Row( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + CourseInfoChips( + course.uuid, + fontSize: descFontSize, + iconSize: smallIconSize, + ), + CourseInfoChip( + icon: Icons.person, + text: + L10n.of(context).countParticipants( + summary.membershipSummary.length, + ), + fontSize: descFontSize, + iconSize: smallIconSize, + ), + ], + ), + Padding( + padding: const EdgeInsets.only( + top: 4.0, + bottom: 8.0, + ), + child: Row( + spacing: 4.0, + children: [ + const Icon( + Icons.map, + size: largeIconSize, + ), + Text( + L10n.of(context).coursePlan, + style: const TextStyle( + fontSize: titleFontSize, + ), + ), + ], + ), + ), + ], + ); + } + + index--; + + if (index >= course.topicIds.length) { + return const SizedBox(height: 12.0); + } + + final topicId = course.topicIds[index]; + final topic = course.loadedTopics[topicId]; + + if (topic == null) { + return const SizedBox(); + } + + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + ), + child: Row( + spacing: 8.0, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipPath( + clipper: PinClipper(), + child: ImageByUrl( + imageUrl: topic.imageUrl, + width: 45.0, + replacement: Container( + width: 45.0, + height: 45.0, + decoration: BoxDecoration( + color: theme.colorScheme.secondary, + ), + ), + ), + ), + Flexible( + child: Column( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + topic.title, + style: const TextStyle( + fontSize: titleFontSize, + ), + ), + Text( + topic.description, + style: const TextStyle( + fontSize: descFontSize, + ), + ), + Padding( + padding: const EdgeInsetsGeometry + .symmetric( + vertical: 2.0, + ), + child: Row( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + if (topic.location != null) + CourseInfoChip( + icon: Icons.location_on, + text: topic.location!, + fontSize: descFontSize, + iconSize: smallIconSize, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + ), + ), + Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + top: BorderSide( + color: theme.dividerColor, + width: 1.0, + ), + ), + ), + padding: const EdgeInsets.all(12.0), + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + spacing: 8.0, + children: [ + if (summary.joinRule == JoinRules.knock) ...[ + TextField( + decoration: InputDecoration( + hintText: L10n.of(context).enterCodeToJoin, + ), + onSubmitted: controller.joinWithCode, + ), + Row( + spacing: 8.0, + children: [ + const Expanded( + child: Divider(), + ), + Text(L10n.of(context).or), + const Expanded( + child: Divider(), + ), + ], + ), + ], + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + theme.colorScheme.primaryContainer, + foregroundColor: + theme.colorScheme.onPrimaryContainer, + ), + onPressed: controller.joinCourse, + child: Row( + spacing: 8.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.map_outlined), + Text( + summary.joinRule == JoinRules.knock + ? L10n.of(context).knock + : L10n.of(context).join, + style: const TextStyle( + fontSize: titleFontSize, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ), + ), + ); + } +} + +class _CourseAdminDisplay extends StatelessWidget { + final RoomSummaryResponse summary; + const _CourseAdminDisplay(this.summary); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Wrap( + alignment: WrapAlignment.center, + spacing: 12.0, + runSpacing: 12.0, + children: [ + ...summary.adminUserIDs.map((adminId) { + return FutureBuilder( + future: Matrix.of(context).client.getProfileFromUserId( + adminId, + ), + builder: (context, snapshot) { + final profile = snapshot.data; + final displayName = + profile?.displayName ?? adminId.localpart ?? adminId; + return InkWell( + onTap: profile != null + ? () => UserDialog.show( + context: context, + profile: profile, + ) + : null, + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(18.0), + ), + padding: const EdgeInsets.all(4.0), + child: Opacity( + opacity: 0.5, + child: Row( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + children: [ + Avatar( + size: 18.0, + mxContent: profile?.avatarUrl, + name: displayName, + userId: adminId, + ), + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 80.0, + ), + child: Text( + displayName, + style: TextStyle( + fontSize: 12.0, + color: theme.colorScheme.onPrimaryContainer, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], + ), + ), + ), + ); + }, + ); + }), + ], + ); + } +} diff --git a/lib/pangea/course_creation/selected_course_page.dart b/lib/pangea/course_creation/selected_course_page.dart index 65642cd6e..86468890f 100644 --- a/lib/pangea/course_creation/selected_course_page.dart +++ b/lib/pangea/course_creation/selected_course_page.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart' as sdk; -import 'package:matrix/matrix.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/course_creation/selected_course_view.dart'; @@ -12,10 +11,11 @@ import 'package:fluffychat/pangea/course_plans/courses/course_plan_builder.dart' import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart'; import 'package:fluffychat/pangea/course_plans/courses/course_plan_room_extension.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/join_codes/space_code_controller.dart'; import 'package:fluffychat/pangea/spaces/client_spaces_extension.dart'; import 'package:fluffychat/widgets/matrix.dart'; -enum SelectedCourseMode { launch, addToSpace, join } +enum SelectedCourseMode { launch, addToSpace } class SelectedCourse extends StatefulWidget { final String courseId; @@ -25,15 +25,11 @@ class SelectedCourse extends StatefulWidget { /// In join mode, the ID of the space to join that already has this course. final String? spaceId; - /// In join mode, the room info for the space that already has this course. - final PublicRoomsChunk? roomChunk; - const SelectedCourse( this.courseId, this.mode, { super.key, this.spaceId, - this.roomChunk, }); @override @@ -62,8 +58,6 @@ class SelectedCourseController extends State return L10n.of(context).newCourse; case SelectedCourseMode.addToSpace: return L10n.of(context).addCoursePlan; - case SelectedCourseMode.join: - return L10n.of(context).joinWithClassCode; } } @@ -73,8 +67,24 @@ class SelectedCourseController extends State return L10n.of(context).createCourse; case SelectedCourseMode.addToSpace: return L10n.of(context).addCoursePlan; - case SelectedCourseMode.join: - return L10n.of(context).joinWithClassCode; + } + } + + Future joinWithCode(String code) async { + if (code.isEmpty) { + return; + } + + final roomId = await SpaceCodeController.joinSpaceWithCode( + context, + code, + ); + + if (roomId != null) { + final room = Matrix.of(context).client.getRoomById(roomId); + room?.isSpace ?? true + ? context.go('/rooms/spaces/$roomId/details') + : context.go('/rooms/$roomId'); } } @@ -84,8 +94,6 @@ class SelectedCourseController extends State return launchCourse(widget.courseId, course); case SelectedCourseMode.addToSpace: return addCourseToSpace(course); - case SelectedCourseMode.join: - return joinCourse(); } } @@ -99,7 +107,7 @@ class SelectedCourseController extends State .createPangeaSpace( name: course.title, topic: course.description, - visibility: sdk.Visibility.private, + visibility: sdk.Visibility.public, joinRules: sdk.JoinRules.knock, initialState: [ sdk.StateEvent( @@ -146,30 +154,6 @@ class SelectedCourseController extends State context.go("/rooms/spaces/${space.id}/details?tab=course"); } - Future joinCourse() async { - if (widget.roomChunk == null) { - throw Exception("Room chunk is null"); - } - - final client = Matrix.of(context).client; - final roomId = await client.joinRoom( - widget.roomChunk!.roomId, - ); - - final room = client.getRoomById(roomId); - if (room == null || room.membership != Membership.join) { - await client.waitForRoomInSync(roomId, join: true); - } - - if (client.getRoomById(roomId) == null) { - throw Exception("Failed to join room"); - } - - if (mounted) { - context.go("/rooms/spaces/$roomId/details"); - } - } - @override Widget build(BuildContext context) => SelectedCourseView(this); } diff --git a/lib/pangea/course_creation/selected_course_view.dart b/lib/pangea/course_creation/selected_course_view.dart index fad7bfbe8..29d57d970 100644 --- a/lib/pangea/course_creation/selected_course_view.dart +++ b/lib/pangea/course_creation/selected_course_view.dart @@ -60,13 +60,7 @@ class SelectedCourseView extends StatelessWidget { child: ListView.builder( itemCount: course.topicIds.length + 2, itemBuilder: (context, index) { - String displayname = course.title; - final roomChunk = controller.widget.roomChunk; - if (roomChunk != null) { - displayname = roomChunk.name ?? - roomChunk.canonicalAlias ?? - L10n.of(context).emptyChat; - } + final String displayname = course.title; if (index == 0) { return Column( @@ -75,9 +69,7 @@ class SelectedCourseView extends StatelessWidget { ClipPath( clipper: MapClipper(), child: ImageByUrl( - imageUrl: controller.widget - .roomChunk?.avatarUrl ?? - course.imageUrl, + imageUrl: course.imageUrl, width: 100.0, borderRadius: BorderRadius.circular(0.0), @@ -269,30 +261,38 @@ class SelectedCourseView extends StatelessWidget { ), Padding( padding: const EdgeInsets.only(top: 8.0), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: - theme.colorScheme.primaryContainer, - foregroundColor: - theme.colorScheme.onPrimaryContainer, - ), - onPressed: () => showFutureLoadingDialog( - context: context, - future: () => controller.submit(course), - ), - child: Row( - spacing: 8.0, - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.map_outlined), - Text( - controller.buttonText, - style: const TextStyle( - fontSize: titleFontSize, - ), + child: Column( + spacing: 8.0, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme + .colorScheme.primaryContainer, + foregroundColor: theme + .colorScheme.onPrimaryContainer, ), - ], - ), + onPressed: () => + showFutureLoadingDialog( + context: context, + future: () => + controller.submit(course), + ), + child: Row( + spacing: 8.0, + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + const Icon(Icons.map_outlined), + Text( + controller.buttonText, + style: const TextStyle( + fontSize: titleFontSize, + ), + ), + ], + ), + ), + ], ), ), ], diff --git a/lib/pangea/course_plans/courses/course_plan_builder.dart b/lib/pangea/course_plans/courses/course_plan_builder.dart index 2f8733e63..3f4310328 100644 --- a/lib/pangea/course_plans/courses/course_plan_builder.dart +++ b/lib/pangea/course_plans/courses/course_plan_builder.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get_storage/get_storage.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart'; import 'package:fluffychat/pangea/course_plans/courses/get_localized_courses_request.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -47,7 +48,12 @@ mixin CoursePlanProvider on State { ), ); await course!.fetchMediaUrls(); - } catch (e) { + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: {'courseId': courseId}, + ); courseError = e; } finally { if (mounted) setState(() => loadingCourse = false); diff --git a/lib/pangea/course_settings/course_settings.dart b/lib/pangea/course_settings/course_settings.dart index 5edd6122c..fb081c5b5 100644 --- a/lib/pangea/course_settings/course_settings.dart +++ b/lib/pangea/course_settings/course_settings.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; +import 'package:http/http.dart'; import 'package:matrix/matrix.dart'; import 'package:shimmer/shimmer.dart'; @@ -54,6 +55,17 @@ class CourseSettings extends StatelessWidget { } if (controller.course == null || controller.courseError != null) { + if (controller.courseError is Response && + (controller.courseError as Response).statusCode == 500) { + return Center( + child: Text( + L10n.of(context).courseLoadingError, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + } + return room.canChangeStateEvent(PangeaEventTypes.coursePlan) ? Column( spacing: 50.0, diff --git a/lib/pangea/download/download_file_util.dart b/lib/pangea/download/download_file_util.dart index 3adab21ca..95b3111ea 100644 --- a/lib/pangea/download/download_file_util.dart +++ b/lib/pangea/download/download_file_util.dart @@ -7,7 +7,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:universal_html/html.dart' as webfile; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pages/chat/recording_dialog.dart'; import 'package:fluffychat/pangea/download/download_type_enum.dart'; class DownloadUtil { @@ -25,35 +25,32 @@ class DownloadUtil { ..click(); return; } + + final allowed = await Permission.storage.request().isGranted; + if (!allowed) { + throw PermissionException(); + } + if (await Permission.storage.request().isGranted) { Directory? directory; - try { - if (Platform.isIOS) { - directory = await getApplicationDocumentsDirectory(); - } else { - directory = Directory('/storage/emulated/0/Download'); - if (!await directory.exists()) { - directory = await getExternalStorageDirectory(); - } + + if (Platform.isIOS) { + directory = await getApplicationDocumentsDirectory(); + } else { + directory = Directory('/storage/emulated/0/Download'); + if (!await directory.exists()) { + directory = await getExternalStorageDirectory(); } - } catch (err, s) { - debugPrint("Failed to get download folder path"); - ErrorHandler.logError( - e: err, - s: s, - data: {}, - ); } - if (directory != null) { - final File f = File("${directory.path}/$filename"); - File resp; - if (fileType == DownloadType.txt || fileType == DownloadType.csv) { - resp = await f.writeAsString(contents); - } else { - resp = await f.writeAsBytes(contents); - } - OpenFile.open(resp.path); + + final File f = File("${directory!.path}/$filename"); + File resp; + if (fileType == DownloadType.txt || fileType == DownloadType.csv) { + resp = await f.writeAsString(contents); + } else { + resp = await f.writeAsBytes(contents); } + OpenFile.open(resp.path); } } } diff --git a/lib/pangea/events/constants/pangea_event_types.dart b/lib/pangea/events/constants/pangea_event_types.dart index bf5570f52..80fbd1323 100644 --- a/lib/pangea/events/constants/pangea_event_types.dart +++ b/lib/pangea/events/constants/pangea_event_types.dart @@ -33,6 +33,8 @@ class PangeaEventTypes { static const String report = 'm.report'; static const textToSpeechRule = "p.rule.text_to_speech"; + static const analyticsInviteRule = "p.rule.analytics_invite"; + static const analyticsInviteContent = "p.analytics_request"; /// A request to the server to generate activities static const activityRequest = "pangea.activity_req"; diff --git a/lib/pangea/events/event_wrappers/pangea_message_event.dart b/lib/pangea/events/event_wrappers/pangea_message_event.dart index f1322fdf2..67809bb92 100644 --- a/lib/pangea/events/event_wrappers/pangea_message_event.dart +++ b/lib/pangea/events/event_wrappers/pangea_message_event.dart @@ -114,7 +114,7 @@ class PangeaMessageEvent { .map( (e) => RepresentationEvent( event: e, - parentMessageEvent: _event, + parentMessageEvent: _latestEdit, timeline: timeline, ), ) @@ -181,14 +181,14 @@ class PangeaMessageEvent { final original = PangeaRepresentation( langCode: lang ?? LanguageKeys.unknownLanguage, - text: _event.body, + text: _latestEdit.body, originalSent: true, originalWritten: false, ); _representations!.add( RepresentationEvent( - parentMessageEvent: _event, + parentMessageEvent: _latestEdit, content: original, tokens: tokens, choreo: _embeddedChoreo, @@ -202,7 +202,7 @@ class PangeaMessageEvent { e: err, s: s, data: { - "event": _event.toJson(), + "event": _latestEdit.toJson(), }, ); } @@ -211,7 +211,7 @@ class PangeaMessageEvent { try { _representations!.add( RepresentationEvent( - parentMessageEvent: _event, + parentMessageEvent: _latestEdit, content: PangeaRepresentation.fromJson( _latestEdit.content[ModelKey.originalWritten] as Map, @@ -229,7 +229,7 @@ class PangeaMessageEvent { e: err, s: s, data: { - "event": _event.toJson(), + "event": _latestEdit.toJson(), }, ); } @@ -278,7 +278,8 @@ class PangeaMessageEvent { /// Gets the message display text for the current language code. /// If the message display text is not available for the current language code, /// it returns the message body. - String get messageDisplayText => messageDisplayRepresentation?.text ?? body; + String get messageDisplayText => + messageDisplayRepresentation?.text ?? _latestEdit.body; TextDirection get textDirection => LanguageConstants.rtlLanguageCodes.contains(messageDisplayLangCode) @@ -300,7 +301,11 @@ class PangeaMessageEvent { (filter?.call(element) ?? true), ); - Event? getTextToSpeechLocal(String langCode, String text) { + Event? getTextToSpeechLocal( + String langCode, + String text, + String? voice, + ) { for (final audio in allAudio) { final dataMap = audio.content.tryGetMap(ModelKey.transcription); if (dataMap == null || !dataMap.containsKey(ModelKey.tokens)) continue; @@ -310,7 +315,9 @@ class PangeaMessageEvent { dataMap as dynamic, ); - if (audioData.langCode == langCode && audioData.text == text) { + if (audioData.langCode == langCode && + audioData.text == text && + audioData.voice == voice) { return audio; } } catch (e, s) { @@ -365,7 +372,7 @@ class PangeaMessageEvent { String langCode, String? voice, ) async { - final local = getTextToSpeechLocal(langCode, messageDisplayText); + final local = getTextToSpeechLocal(langCode, messageDisplayText, voice); if (local != null) { final file = await local.getPangeaAudioFile(); if (file != null) return file; @@ -421,7 +428,7 @@ class PangeaMessageEvent { 'waveform': response.waveform, }, ModelKey.transcription: response - .toPangeaAudioEventData(rep?.text ?? body, langCode) + .toPangeaAudioEventData(rep?.text ?? body, langCode, voice) .toJson(), }, ).then((eventId) async { @@ -492,7 +499,7 @@ class PangeaMessageEvent { _representations = null; return room.sendPangeaEvent( content: representation.toJson(), - parentEventId: eventId, + parentEventId: _latestEdit.eventId, type: PangeaEventTypes.representation, ); } @@ -661,7 +668,7 @@ class PangeaMessageEvent { ) async { final repEvent = await room.sendPangeaEvent( content: representation.toJson(), - parentEventId: eventId, + parentEventId: _latestEdit.eventId, type: PangeaEventTypes.representation, ); return repEvent?.eventId; diff --git a/lib/pangea/events/repo/tokens_repo.dart b/lib/pangea/events/repo/tokens_repo.dart index 0f9d8b4d6..0b849bc3c 100644 --- a/lib/pangea/events/repo/tokens_repo.dart +++ b/lib/pangea/events/repo/tokens_repo.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:async/async.dart'; import 'package:http/http.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/network/requests.dart'; @@ -62,7 +63,18 @@ class TokensRepo { final Map json = jsonDecode(utf8.decode(res.bodyBytes).toString()); - return TokensResponseModel.fromJson(json); + final tokens = TokensResponseModel.fromJson(json); + if (tokens.tokens.any((t) => t.pos == 'other')) { + ErrorHandler.logError( + e: Exception('Received token with pos "other"'), + data: { + "request": request.toJson(), + "response": json, + }, + level: SentryLevel.warning, + ); + } + return tokens; } static Future> _getResult( diff --git a/lib/pangea/instructions/instructions_enum.dart b/lib/pangea/instructions/instructions_enum.dart index da6c019a7..a3cecfcdc 100644 --- a/lib/pangea/instructions/instructions_enum.dart +++ b/lib/pangea/instructions/instructions_enum.dart @@ -32,8 +32,9 @@ enum InstructionsEnum { setLemmaEmoji, disableLanguageTools, selectMeaning, - clickTextMessages, - clickAudioMessages, + dismissSupportChat, + shimmerNewToken, + shimmerTranslation, } extension InstructionsEnumExtension on InstructionsEnum { @@ -65,8 +66,9 @@ extension InstructionsEnumExtension on InstructionsEnum { case InstructionsEnum.noSavedActivitiesYet: case InstructionsEnum.setLemmaEmoji: case InstructionsEnum.disableLanguageTools: - case InstructionsEnum.clickTextMessages: - case InstructionsEnum.clickAudioMessages: + case InstructionsEnum.dismissSupportChat: + case InstructionsEnum.shimmerNewToken: + case InstructionsEnum.shimmerTranslation: ErrorHandler.logError( e: Exception("No title for this instruction"), m: 'InstructionsEnumExtension.title', @@ -128,8 +130,9 @@ extension InstructionsEnumExtension on InstructionsEnum { case InstructionsEnum.noSavedActivitiesYet: return l10n.noSavedActivitiesYet; case InstructionsEnum.setLemmaEmoji: - case InstructionsEnum.clickTextMessages: - case InstructionsEnum.clickAudioMessages: + case InstructionsEnum.dismissSupportChat: + case InstructionsEnum.shimmerNewToken: + case InstructionsEnum.shimmerTranslation: return ""; case InstructionsEnum.disableLanguageTools: return l10n.disableLanguageToolsDesc; diff --git a/lib/pangea/instructions/instructions_inline_tooltip.dart b/lib/pangea/instructions/instructions_inline_tooltip.dart index 6059cb90a..5839d5657 100644 --- a/lib/pangea/instructions/instructions_inline_tooltip.dart +++ b/lib/pangea/instructions/instructions_inline_tooltip.dart @@ -134,25 +134,28 @@ class InlineTooltipState extends State crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.lightbulb, - size: 20, - color: Theme.of(context).colorScheme.onSurface, + Padding( + padding: const EdgeInsets.only(right: 6.0), + child: Icon( + Icons.lightbulb, + size: 20, + color: Theme.of(context).colorScheme.onSurface, + ), ), - const SizedBox(width: 8), Flexible( child: Center( child: Text( widget.message, style: widget.textStyle ?? (FluffyThemes.isColumnMode(context) - ? Theme.of(context).textTheme.titleLarge - : Theme.of(context).textTheme.bodyLarge), + ? Theme.of(context).textTheme.titleSmall + : Theme.of(context).textTheme.bodyMedium), textAlign: TextAlign.center, ), ), ), IconButton( + padding: const EdgeInsets.only(left: 6.0), constraints: const BoxConstraints(), icon: Icon( Icons.close_outlined, diff --git a/lib/pangea/join_codes/space_code_controller.dart b/lib/pangea/join_codes/space_code_controller.dart index 1606f2792..e1590a130 100644 --- a/lib/pangea/join_codes/space_code_controller.dart +++ b/lib/pangea/join_codes/space_code_controller.dart @@ -13,7 +13,6 @@ import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/join_codes/knock_space_extension.dart'; import 'package:fluffychat/pangea/join_codes/space_code_repo.dart'; import 'package:fluffychat/pangea/join_codes/too_many_requests_dialog.dart'; -import 'package:fluffychat/pangea/spaces/space_constants.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../common/controllers/base_controller.dart'; @@ -21,18 +20,6 @@ import '../common/controllers/base_controller.dart'; class NotFoundException implements Exception {} class SpaceCodeController extends BaseController { - static ValueNotifier codeNotifier = ValueNotifier(null); - - static Future onOpenAppViaUrl(Uri url) async { - if (url.fragment.isEmpty) return; - final fragment = Uri.parse(url.fragment); - final code = fragment.queryParameters[SpaceConstants.classCode]; - if (code != null && fragment.path.contains('join_with_link')) { - await SpaceCodeRepo.setSpaceCode(code); - codeNotifier.value = code; - } - } - static Future joinCachedSpaceCode(BuildContext context) async { final String? spaceCode = SpaceCodeRepo.spaceCode; if (spaceCode == null) return null; diff --git a/lib/pangea/learning_settings/language_mismatch_repo.dart b/lib/pangea/learning_settings/language_mismatch_repo.dart index 0096bff42..430f3a586 100644 --- a/lib/pangea/learning_settings/language_mismatch_repo.dart +++ b/lib/pangea/learning_settings/language_mismatch_repo.dart @@ -5,14 +5,10 @@ class LanguageMismatchRepo { static const Duration displayInterval = Duration(minutes: 30); static String _roomKey(String roomId) => 'language_mismatch_room_$roomId'; - static String _eventKey(String eventId) => 'language_mismatch_event_$eventId'; static bool shouldShowByRoom(String roomId) => _get(_roomKey(roomId)); - static bool shouldShowByEvent(String eventId) => _get(_eventKey(eventId)); static Future setRoom(String roomId) async => _set(_roomKey(roomId)); - static Future setEvent(String eventId) async => - _set(_eventKey(eventId)); static Future _set(String key) async { await _storage.write( diff --git a/lib/pangea/learning_settings/p_settings_switch_list_tile.dart b/lib/pangea/learning_settings/p_settings_switch_list_tile.dart index 188debae1..a0e928def 100644 --- a/lib/pangea/learning_settings/p_settings_switch_list_tile.dart +++ b/lib/pangea/learning_settings/p_settings_switch_list_tile.dart @@ -44,7 +44,6 @@ class PSettingsSwitchListTileState @override Widget build(BuildContext context) { return SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, value: currentValue, title: Text(widget.title), activeThumbColor: AppConfig.activeToggleColor, diff --git a/lib/pangea/learning_settings/settings_learning.dart b/lib/pangea/learning_settings/settings_learning.dart index f3e5f8765..64986f00b 100644 --- a/lib/pangea/learning_settings/settings_learning.dart +++ b/lib/pangea/learning_settings/settings_learning.dart @@ -41,7 +41,6 @@ class SettingsLearningController extends State { PangeaController pangeaController = MatrixState.pangeaController; late Profile _profile; - final GlobalKey formKey = GlobalKey(); String? languageMatchError; final ScrollController scrollController = ScrollController(); @@ -110,18 +109,16 @@ class SettingsLearningController extends State { updateToolSetting(ToolSetting.enableTTS, false); } - if (formKey.currentState!.validate()) { - await showFutureLoadingDialog( - context: context, - future: () async => pangeaController.userController - .updateProfile( - (_) => _profile, - waitForDataInSync: true, - ) - .timeout(const Duration(seconds: 15)), - ); - Navigator.of(context).pop(); - } + await showFutureLoadingDialog( + context: context, + future: () async => pangeaController.userController + .updateProfile( + (_) => _profile, + waitForDataInSync: true, + ) + .timeout(const Duration(seconds: 15)), + ); + Navigator.of(context).pop(); } Future resetInstructionTooltips() async { @@ -136,7 +133,6 @@ class SettingsLearningController extends State { waitForDataInSync: true, ), onError: (e, s) { - debugPrint("Error resetting instruction tooltips: $e"); debugger(when: kDebugMode); ErrorHandler.logError( e: e, @@ -153,11 +149,12 @@ class SettingsLearningController extends State { LanguageModel? sourceLanguage, LanguageModel? targetLanguage, }) async { - if (sourceLanguage != null) { + if (sourceLanguage != null && sourceLanguage != selectedSourceLanguage) { _profile.userSettings.sourceLanguage = sourceLanguage.langCode; } - if (targetLanguage != null) { + if (targetLanguage != null && targetLanguage != selectedTargetLanguage) { _profile.userSettings.targetLanguage = targetLanguage.langCode; + _profile.userSettings.voice = null; if (!_profile.toolSettings.enableTTS && isTTSSupported) { updateToolSetting(ToolSetting.enableTTS, true); } @@ -181,6 +178,11 @@ class SettingsLearningController extends State { if (mounted) setState(() {}); } + void setVoice(String? voice) { + _profile.userSettings.voice = voice; + if (mounted) setState(() {}); + } + void changeCountry(Country? country) { _profile.userSettings.country = country?.name; if (mounted) setState(() {}); @@ -343,6 +345,8 @@ class SettingsLearningController extends State { LanguageLevelTypeEnum get cefrLevel => _profile.userSettings.cefrLevel; + String? get selectedVoice => _profile.userSettings.voice; + Country? get country => CountryService().findByName(_profile.userSettings.country); diff --git a/lib/pangea/learning_settings/settings_learning_view.dart b/lib/pangea/learning_settings/settings_learning_view.dart index d6037fc87..1f11f2872 100644 --- a/lib/pangea/learning_settings/settings_learning_view.dart +++ b/lib/pangea/learning_settings/settings_learning_view.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/language_level_dropdown.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; @@ -14,6 +13,7 @@ import 'package:fluffychat/pangea/learning_settings/p_language_dropdown.dart'; import 'package:fluffychat/pangea/learning_settings/p_settings_switch_list_tile.dart'; import 'package:fluffychat/pangea/learning_settings/settings_learning.dart'; import 'package:fluffychat/pangea/learning_settings/tool_settings_enum.dart'; +import 'package:fluffychat/pangea/learning_settings/voice_dropdown.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -45,21 +45,23 @@ class SettingsLearningView extends StatelessWidget { ) : null, ), - body: Form( - key: controller.formKey, - child: ListTileTheme( - iconColor: Theme.of(context).textTheme.bodyLarge!.color, - child: MaxWidthBody( - withScrolling: false, - child: Column( - children: [ - Expanded( - child: SingleChildScrollView( - controller: controller.scrollController, + body: ListTileTheme( + iconColor: Theme.of(context).textTheme.bodyLarge!.color, + child: MaxWidthBody( + withScrolling: false, + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + controller: controller.scrollController, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 32.0), child: Column( + spacing: 16.0, children: [ Padding( - padding: const EdgeInsets.all(16.0), + padding: + const EdgeInsets.symmetric(horizontal: 16.0), child: Column( spacing: 16.0, children: [ @@ -99,171 +101,112 @@ class SettingsLearningView extends StatelessWidget { .colorScheme .surfaceContainerHigh, ), - AnimatedSize( - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - child: controller.userL1?.langCodeShort == - controller.userL2?.langCodeShort - ? Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, + if (controller.userL1?.langCodeShort == + controller.userL2?.langCodeShort) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + child: Row( + spacing: 8.0, + children: [ + Icon( + Icons.info_outlined, + color: Theme.of(context) + .colorScheme + .error, + ), + Flexible( + child: Text( + L10n.of(context) + .noIdenticalLanguages, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .error, + ), ), - child: Row( - spacing: 8.0, - children: [ - Icon( - Icons.info_outlined, - color: Theme.of(context) - .colorScheme - .error, - ), - Flexible( - child: Text( - L10n.of(context) - .noIdenticalLanguages, - style: TextStyle( - color: Theme.of(context) - .colorScheme - .error, - ), - ), - ), - ], - ), - ) - : const SizedBox.shrink(), - ), - CountryPickerDropdown(controller), + ), + ], + ), + ), LanguageLevelDropdown( initialLevel: controller.cefrLevel, onChanged: controller.setCefrLevel, ), + VoiceDropdown( + value: controller.selectedVoice, + language: controller.selectedTargetLanguage, + onChanged: controller.setVoice, + ), + CountryPickerDropdown(controller), GenderDropdown( initialGender: controller.gender, onChanged: controller.setGender, ), - Container( - decoration: BoxDecoration( - border: Border.all( - color: Colors.white54, - ), - borderRadius: BorderRadius.circular(8.0), - ), - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - ProfileSettingsSwitchListTile.adaptive( - defaultValue: - controller.getToolSetting( - ToolSetting.autoIGC, - ), - title: ToolSetting.autoIGC - .toolName(context), - subtitle: ToolSetting.autoIGC - .toolDescription(context), - onChange: (bool value) => - controller.updateToolSetting( - ToolSetting.autoIGC, - value, - ), - enabled: true, - ), - ProfileSettingsSwitchListTile.adaptive( - defaultValue: - controller.getToolSetting( - ToolSetting.enableAutocorrect, - ), - title: ToolSetting.enableAutocorrect - .toolName(context), - subtitle: ToolSetting - .enableAutocorrect - .toolDescription(context), - onChange: (bool value) { - controller.updateToolSetting( - ToolSetting.enableAutocorrect, - value, - ); - if (value) { - controller - .showKeyboardSettingsDialog(); - } - }, - enabled: true, - ), - ], - ), - ), - for (final toolSetting - in ToolSetting.values.where( - (tool) => - tool.isAvailableSetting && - tool != ToolSetting.autoIGC && - tool != ToolSetting.enableAutocorrect, - )) - Column( - children: [ - ProfileSettingsSwitchListTile.adaptive( - defaultValue: controller - .getToolSetting(toolSetting), - title: toolSetting.toolName(context), - subtitle: toolSetting == - ToolSetting.enableTTS && - !controller.isTTSSupported - ? null - : toolSetting - .toolDescription(context), - onChange: (bool value) => - controller.updateToolSetting( - toolSetting, - value, - ), - ), - ], - ), - SwitchListTile.adaptive( - value: controller.publicProfile, - onChanged: controller.setPublicProfile, - title: Text( - L10n.of(context).publicProfileTitle, - ), - subtitle: Text( - L10n.of(context).publicProfileDesc, - ), - activeThumbColor: - AppConfig.activeToggleColor, - contentPadding: EdgeInsets.zero, - ), - ResetInstructionsListTile( - controller: controller, - ), ], ), ), + ...ToolSetting.values + .where( + (tool) => tool.isAvailableSetting, + ) + .map( + (toolSetting) => _ProfileSwitchTile( + value: + controller.getToolSetting(toolSetting), + setting: toolSetting, + onChanged: (v) { + controller.updateToolSetting( + toolSetting, + v, + ); + if (v && + toolSetting == + ToolSetting.enableTTS) { + controller.showKeyboardSettingsDialog(); + } + }, + ), + ), + SwitchListTile.adaptive( + value: controller.publicProfile, + onChanged: controller.setPublicProfile, + title: Text( + L10n.of(context).publicProfileTitle, + ), + subtitle: Text( + L10n.of(context).publicProfileDesc, + ), + activeThumbColor: AppConfig.activeToggleColor, + ), + ResetInstructionsListTile( + controller: controller, + ), ], ), ), ), - Padding( - padding: const EdgeInsets.all(16.0), - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: controller.haveSettingsBeenChanged - ? controller.submit - : null, - child: Text(L10n.of(context).saveChanges), - ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: controller.haveSettingsBeenChanged + ? controller.submit + : null, + child: Text(L10n.of(context).saveChanges), ), ), - ], - ), + ), + ], ), ), ), ); if (!controller.widget.isDialog) return dialogContent; - return FullWidthDialog( dialogContent: dialogContent, maxWidth: 600, @@ -273,3 +216,25 @@ class SettingsLearningView extends StatelessWidget { ); } } + +class _ProfileSwitchTile extends StatelessWidget { + final bool value; + final ToolSetting setting; + final Function(bool) onChanged; + + const _ProfileSwitchTile({ + required this.value, + required this.setting, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return ProfileSettingsSwitchListTile.adaptive( + defaultValue: value, + title: setting.toolName(context), + subtitle: setting.toolDescription(context), + onChange: onChanged, + ); + } +} diff --git a/lib/pangea/learning_settings/voice_dropdown.dart b/lib/pangea/learning_settings/voice_dropdown.dart new file mode 100644 index 000000000..ba105c60d --- /dev/null +++ b/lib/pangea/learning_settings/voice_dropdown.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import 'package:dropdown_button2/dropdown_button2.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/common/widgets/dropdown_text_button.dart'; +import 'package:fluffychat/pangea/languages/language_model.dart'; + +class VoiceDropdown extends StatelessWidget { + final String? value; + final LanguageModel? language; + final Function(String?) onChanged; + final bool enabled; + + const VoiceDropdown({ + super.key, + this.value, + this.language, + required this.onChanged, + this.enabled = true, + }); + + @override + Widget build(BuildContext context) { + final voices = (language?.voices ?? []); + final value = + this.value != null && voices.contains(this.value) ? this.value : null; + + return DropdownButtonFormField2( + customButton: + value != null ? CustomDropdownTextButton(text: value) : null, + menuItemStyleData: const MenuItemStyleData( + padding: EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, + ), + ), + decoration: InputDecoration( + labelText: L10n.of(context).voiceDropdownTitle, + ), + isExpanded: true, + dropdownStyleData: DropdownStyleData( + maxHeight: 250, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14.0), + ), + ), + items: voices.map((voice) { + return DropdownMenuItem( + value: voice, + child: Text(voice), + ); + }).toList(), + onChanged: enabled ? onChanged : null, + value: voices.contains(value) ? value : null, + ); + } +} diff --git a/lib/pangea/lemmas/construct_xp_widget.dart b/lib/pangea/lemmas/construct_xp_widget.dart deleted file mode 100644 index 093c33806..000000000 --- a/lib/pangea/lemmas/construct_xp_widget.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; - -class ConstructXpWidget extends StatelessWidget { - final ConstructLevelEnum level; - final int points; - final Widget icon; - - const ConstructXpWidget({ - super.key, - required this.level, - required this.points, - required this.icon, - }); - - @override - Widget build(BuildContext context) { - final Color textColor = Theme.of(context).brightness != Brightness.light - ? level.color(context) - : level.darkColor(context); - - return Row( - spacing: 16.0, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - icon, - Text( - "$points XP", - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: textColor, - ), - ), - ], - ); - } -} diff --git a/lib/pangea/lemmas/lemma_highlight_emoji_row.dart b/lib/pangea/lemmas/lemma_highlight_emoji_row.dart index 4dec278b9..21396f612 100644 --- a/lib/pangea/lemmas/lemma_highlight_emoji_row.dart +++ b/lib/pangea/lemmas/lemma_highlight_emoji_row.dart @@ -6,6 +6,7 @@ import 'package:shimmer/shimmer.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.dart'; +import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/common/widgets/shimmer_background.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/lemmas/lemma_meaning_builder.dart'; @@ -49,35 +50,15 @@ class LemmaHighlightEmojiRowState extends State constructId: widget.cId, messageInfo: widget.messageInfo, builder: (context, controller) { - if (controller.error != null) { - return const SizedBox.shrink(); - } - - final emojis = controller.lemmaInfo?.emoji; - return SizedBox( - height: 70.0, - child: Row( - spacing: 4.0, - mainAxisSize: MainAxisSize.min, - children: emojis == null || emojis.isEmpty - ? List.generate( - 3, - (_) => Shimmer.fromColors( - baseColor: Colors.transparent, - highlightColor: - Theme.of(context).colorScheme.primary.withAlpha(70), - child: Container( - height: 55.0, - width: 55.0, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: - BorderRadius.circular(AppConfig.borderRadius), - ), - ), - ), - ) - : emojis.map( + return switch (controller.state) { + AsyncError() => const SizedBox.shrink(), + AsyncLoaded(value: final lemmaInfo) => SizedBox( + height: 70.0, + child: Row( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + children: [ + ...lemmaInfo.emoji.map( (emoji) { final targetId = "${widget.targetId}-$emoji"; return EmojiChoiceItem( @@ -94,9 +75,35 @@ class LemmaHighlightEmojiRowState extends State enabled: widget.enabled, ); }, - ).toList(), - ), - ); + ), + ], + ), + ), + _ => SizedBox( + height: 70.0, + child: Row( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + children: List.generate( + 3, + (_) => Shimmer.fromColors( + baseColor: Colors.transparent, + highlightColor: + Theme.of(context).colorScheme.primary.withAlpha(70), + child: Container( + height: 55.0, + width: 55.0, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + ), + ), + ), + ), + ), + ), + }; }, ); } @@ -175,52 +182,61 @@ class EmojiChoiceItemState extends State { @override Widget build(BuildContext context) { return HoverBuilder( - builder: (context, hovered) => GestureDetector( - onTap: widget.enabled ? widget.onSelectEmoji : null, - child: Stack( - children: [ - ShimmerBackground( - enabled: shimmer, - shimmerColor: (Theme.of(context).brightness == Brightness.dark) - ? Colors.white - : Theme.of(context).colorScheme.primary, - baseColor: Colors.transparent, - child: CompositedTransformTarget( - link: MatrixState.pAnyState - .layerLinkAndKey(widget.transformTargetId) - .link, - child: AnimatedContainer( - key: MatrixState.pAnyState + builder: (context, hovered) => MouseRegion( + cursor: widget.enabled + ? SystemMouseCursors.click + : SystemMouseCursors.basic, + child: GestureDetector( + onTap: widget.enabled ? widget.onSelectEmoji : null, + child: Stack( + children: [ + ShimmerBackground( + enabled: shimmer, + shimmerColor: (Theme.of(context).brightness == Brightness.dark) + ? Colors.white + : Theme.of(context).colorScheme.primary, + baseColor: Colors.transparent, + child: CompositedTransformTarget( + link: MatrixState.pAnyState .layerLinkAndKey(widget.transformTargetId) - .key, - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: widget.enabled && (hovered || widget.selected) - ? Theme.of(context).colorScheme.secondary.withAlpha(30) - : Colors.transparent, - borderRadius: BorderRadius.circular(AppConfig.borderRadius), - border: widget.selected - ? Border.all( - color: Colors.transparent, - width: 4, - ) - : null, - ), - child: Text( - widget.emoji, - style: Theme.of(context).textTheme.headlineSmall, + .link, + child: AnimatedContainer( + key: MatrixState.pAnyState + .layerLinkAndKey(widget.transformTargetId) + .key, + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: widget.enabled && (hovered || widget.selected) + ? Theme.of(context) + .colorScheme + .secondary + .withAlpha(30) + : Colors.transparent, + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + border: widget.selected + ? Border.all( + color: Colors.transparent, + width: 4, + ) + : null, + ), + child: Text( + widget.emoji, + style: Theme.of(context).textTheme.headlineSmall, + ), ), ), ), - ), - if (widget.badge != null) - Positioned( - right: 6, - bottom: 6, - child: widget.badge!, - ), - ], + if (widget.badge != null) + Positioned( + right: 6, + bottom: 6, + child: widget.badge!, + ), + ], + ), ), ), ); diff --git a/lib/pangea/lemmas/lemma_info_repo.dart b/lib/pangea/lemmas/lemma_info_repo.dart index 00b95922a..0ed7b408b 100644 --- a/lib/pangea/lemmas/lemma_info_repo.dart +++ b/lib/pangea/lemmas/lemma_info_repo.dart @@ -89,10 +89,6 @@ class LemmaInfoRepo { _cache.remove(key); } - static void clearAllCache() { - _cache.clear(); - } - static Future> _safeFetch( String token, LemmaInfoRequest request, diff --git a/lib/pangea/lemmas/lemma_meaning_builder.dart b/lib/pangea/lemmas/lemma_meaning_builder.dart index b6cfcd0f3..c7c99ae36 100644 --- a/lib/pangea/lemmas/lemma_meaning_builder.dart +++ b/lib/pangea/lemmas/lemma_meaning_builder.dart @@ -8,29 +8,11 @@ import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; import 'package:fluffychat/widgets/matrix.dart'; -class _LemmaMeaningLoader extends AsyncLoader { - final LemmaInfoRequest request; - _LemmaMeaningLoader(this.request) : super(); - - @override - Future fetch() async { - final result = await LemmaInfoRepo.get( - MatrixState.pangeaController.userController.accessToken, - request, - ); - - if (result.isError) { - throw result.asError!.error; - } - - return result.asValue!.value; - } -} - class LemmaMeaningBuilder extends StatefulWidget { final String langCode; final ConstructIdentifier constructId; final Map messageInfo; + final ValueNotifier? reloadNotifier; final Widget Function( BuildContext context, @@ -43,6 +25,7 @@ class LemmaMeaningBuilder extends StatefulWidget { required this.constructId, required this.builder, required this.messageInfo, + this.reloadNotifier, }); @override @@ -50,12 +33,16 @@ class LemmaMeaningBuilder extends StatefulWidget { } class LemmaMeaningBuilderState extends State { - late _LemmaMeaningLoader _loader; + final ValueNotifier> _loader = + ValueNotifier(const AsyncState.idle()); + + int _loadVersion = 0; @override void initState() { super.initState(); - _reload(); + _load(); + widget.reloadNotifier?.addListener(_load); } @override @@ -63,24 +50,22 @@ class LemmaMeaningBuilderState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.constructId != widget.constructId || oldWidget.langCode != widget.langCode) { - _loader.dispose(); - _reload(); + _load(); } } @override void dispose() { + widget.reloadNotifier?.removeListener(_load); _loader.dispose(); super.dispose(); } - bool get isLoading => _loader.isLoading; - bool get isError => _loader.isError; - - Object? get error => - isError ? (_loader.state.value as AsyncError).error : null; - - LemmaInfoResponse? get lemmaInfo => _loader.value; + AsyncState get state => _loader.value; + bool get isError => _loader.value is AsyncError; + bool get isLoaded => _loader.value is AsyncLoaded; + LemmaInfoResponse? get lemmaInfo => + isLoaded ? (_loader.value as AsyncLoaded).value : null; LemmaInfoRequest get _request => LemmaInfoRequest( lemma: widget.constructId.lemma, @@ -91,15 +76,27 @@ class LemmaMeaningBuilderState extends State { messageInfo: widget.messageInfo, ); - void _reload() { - _loader = _LemmaMeaningLoader(_request); - _loader.load(); + Future _load() async { + final int version = ++_loadVersion; + + _loader.value = const AsyncState.loading(); + final result = await LemmaInfoRepo.get( + MatrixState.pangeaController.userController.accessToken, + _request, + ); + + // Ignore if a newer load started after this one + if (!mounted || version != _loadVersion) return; + + _loader.value = result.isError + ? AsyncState.error(result.asError!.error) + : AsyncState.loaded(result.asValue!.value); } @override Widget build(BuildContext context) { return ValueListenableBuilder( - valueListenable: _loader.state, + valueListenable: _loader, builder: (context, _, __) => widget.builder( context, this, diff --git a/lib/pangea/lemmas/lemma_meaning_widget.dart b/lib/pangea/lemmas/lemma_meaning_widget.dart deleted file mode 100644 index e2daea447..000000000 --- a/lib/pangea/lemmas/lemma_meaning_widget.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart'; -import 'package:fluffychat/pangea/common/network/requests.dart'; -import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/lemmas/lemma_meaning_builder.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -class LemmaMeaningWidget extends StatelessWidget { - final ConstructIdentifier constructId; - final TextStyle? style; - final InlineSpan? leading; - final Map messageInfo; - - const LemmaMeaningWidget({ - super.key, - required this.constructId, - required this.messageInfo, - this.style, - this.leading, - }); - - @override - Widget build(BuildContext context) { - return LemmaMeaningBuilder( - langCode: MatrixState.pangeaController.userController.userL2!.langCode, - constructId: constructId, - messageInfo: messageInfo, - builder: (context, controller) { - if (controller.isLoading) { - return const TextLoadingShimmer(); - } - - if (controller.error != null) { - if (controller.error is UnsubscribedException) { - return ErrorIndicator( - message: L10n.of(context).subscribeToUnlockDefinitions, - style: style, - onTap: () { - MatrixState.pangeaController.subscriptionController - .showPaywall(context); - }, - ); - } - return ErrorIndicator( - message: L10n.of(context).errorFetchingDefinition, - style: style, - ); - } - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: RichText( - textAlign: leading == null ? TextAlign.center : TextAlign.start, - text: TextSpan( - style: style, - children: [ - if (leading != null) leading!, - if (leading != null) - const WidgetSpan(child: SizedBox(width: 6.0)), - TextSpan( - text: controller.lemmaInfo?.meaning, - ), - ], - ), - ), - ), - ], - ); - }, - ); - } -} diff --git a/lib/pangea/login/pages/add_course_page.dart b/lib/pangea/login/pages/add_course_page.dart index b6822332d..b641b5e58 100644 --- a/lib/pangea/login/pages/add_course_page.dart +++ b/lib/pangea/login/pages/add_course_page.dart @@ -1,163 +1,163 @@ -import 'package:flutter/material.dart'; +// import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; -import 'package:go_router/go_router.dart'; -import 'package:material_symbols_icons/symbols.dart'; +// import 'package:flutter_svg/svg.dart'; +// import 'package:go_router/go_router.dart'; +// import 'package:material_symbols_icons/symbols.dart'; -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/common/widgets/pangea_logo_svg.dart'; +// import 'package:fluffychat/config/app_config.dart'; +// import 'package:fluffychat/l10n/l10n.dart'; +// import 'package:fluffychat/pangea/common/widgets/pangea_logo_svg.dart'; -class AddCoursePage extends StatelessWidget { - final String route; - const AddCoursePage({ - required this.route, - super.key, - }); +// class AddCoursePage extends StatelessWidget { +// final String route; +// const AddCoursePage({ +// required this.route, +// super.key, +// }); - static String mapStartFileName = "start_trip.svg"; - static String mapUnlockFileName = "unlock_trip.svg"; +// static String mapStartFileName = "start_trip.svg"; +// static String mapUnlockFileName = "unlock_trip.svg"; - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Scaffold( - appBar: AppBar( - title: Row( - spacing: 10.0, - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.map_outlined), - Text(L10n.of(context).addCourse), - ], - ), - automaticallyImplyLeading: false, - centerTitle: true, - ), - body: SafeArea( - child: Center( - child: Container( - padding: const EdgeInsets.all(20.0), - constraints: const BoxConstraints( - maxWidth: 350, - maxHeight: 600, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - PangeaLogoSvg( - width: 100.0, - forceColor: theme.colorScheme.onSurface, - ), - Column( - spacing: 16.0, - children: [ - ElevatedButton( - onPressed: () => context.go( - '/$route/course/private', - ), - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primaryContainer, - foregroundColor: theme.colorScheme.onPrimaryContainer, - ), - child: Row( - spacing: 4.0, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SvgPicture.network( - "${AppConfig.assetsBaseURL}/$mapUnlockFileName", - width: 24.0, - height: 24.0, - colorFilter: ColorFilter.mode( - theme.colorScheme.onPrimaryContainer, - BlendMode.srcIn, - ), - ), - Flexible( - child: Text( - L10n.of(context).joinWithCode, - textAlign: TextAlign.center, - ), - ), - ], - ), - ), - ElevatedButton( - onPressed: () => context.go( - '/$route/course/public', - ), - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primaryContainer, - foregroundColor: theme.colorScheme.onPrimaryContainer, - ), - child: Row( - spacing: 4.0, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Symbols.map_search, - size: 24.0, - color: theme.colorScheme.onPrimaryContainer, - ), - Flexible( - child: Text( - L10n.of(context).joinPublicCourse, - textAlign: TextAlign.center, - ), - ), - ], - ), - ), - ElevatedButton( - onPressed: () => context.go( - '/$route/course/own', - ), - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primaryContainer, - foregroundColor: theme.colorScheme.onPrimaryContainer, - ), - child: Row( - spacing: 4.0, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SvgPicture.network( - "${AppConfig.assetsBaseURL}/$mapStartFileName", - width: 24.0, - height: 24.0, - colorFilter: ColorFilter.mode( - theme.colorScheme.onPrimaryContainer, - BlendMode.srcIn, - ), - ), - Flexible( - child: Text( - L10n.of(context).startOwn, - textAlign: TextAlign.center, - ), - ), - ], - ), - ), - ListTile( - contentPadding: const EdgeInsets.all(0.0), - leading: const Icon(Icons.school), - title: Text( - L10n.of(context).joinCourseDesc, - style: theme.textTheme.labelLarge, - ), - ), - if (route == "registration") - TextButton( - child: Text(L10n.of(context).skipForNow), - onPressed: () => context.go('/rooms'), - ), - ], - ), - ], - ), - ), - ), - ), - ); - } -} +// @override +// Widget build(BuildContext context) { +// final theme = Theme.of(context); +// return Scaffold( +// appBar: AppBar( +// title: Row( +// spacing: 10.0, +// mainAxisSize: MainAxisSize.min, +// children: [ +// const Icon(Icons.map_outlined), +// Text(L10n.of(context).addCourse), +// ], +// ), +// automaticallyImplyLeading: false, +// centerTitle: true, +// ), +// body: SafeArea( +// child: Center( +// child: Container( +// padding: const EdgeInsets.all(20.0), +// constraints: const BoxConstraints( +// maxWidth: 350, +// maxHeight: 600, +// ), +// child: Column( +// mainAxisAlignment: MainAxisAlignment.spaceBetween, +// children: [ +// PangeaLogoSvg( +// width: 100.0, +// forceColor: theme.colorScheme.onSurface, +// ), +// Column( +// spacing: 16.0, +// children: [ +// ElevatedButton( +// onPressed: () => context.go( +// '/$route/course/private', +// ), +// style: ElevatedButton.styleFrom( +// backgroundColor: theme.colorScheme.primaryContainer, +// foregroundColor: theme.colorScheme.onPrimaryContainer, +// ), +// child: Row( +// spacing: 4.0, +// mainAxisAlignment: MainAxisAlignment.center, +// children: [ +// SvgPicture.network( +// "${AppConfig.assetsBaseURL}/$mapUnlockFileName", +// width: 24.0, +// height: 24.0, +// colorFilter: ColorFilter.mode( +// theme.colorScheme.onPrimaryContainer, +// BlendMode.srcIn, +// ), +// ), +// Flexible( +// child: Text( +// L10n.of(context).joinWithCode, +// textAlign: TextAlign.center, +// ), +// ), +// ], +// ), +// ), +// ElevatedButton( +// onPressed: () => context.go( +// '/$route/course/public', +// ), +// style: ElevatedButton.styleFrom( +// backgroundColor: theme.colorScheme.primaryContainer, +// foregroundColor: theme.colorScheme.onPrimaryContainer, +// ), +// child: Row( +// spacing: 4.0, +// mainAxisAlignment: MainAxisAlignment.center, +// children: [ +// Icon( +// Symbols.map_search, +// size: 24.0, +// color: theme.colorScheme.onPrimaryContainer, +// ), +// Flexible( +// child: Text( +// L10n.of(context).joinPublicCourse, +// textAlign: TextAlign.center, +// ), +// ), +// ], +// ), +// ), +// ElevatedButton( +// onPressed: () => context.go( +// '/$route/course/own', +// ), +// style: ElevatedButton.styleFrom( +// backgroundColor: theme.colorScheme.primaryContainer, +// foregroundColor: theme.colorScheme.onPrimaryContainer, +// ), +// child: Row( +// spacing: 4.0, +// mainAxisAlignment: MainAxisAlignment.center, +// children: [ +// SvgPicture.network( +// "${AppConfig.assetsBaseURL}/$mapStartFileName", +// width: 24.0, +// height: 24.0, +// colorFilter: ColorFilter.mode( +// theme.colorScheme.onPrimaryContainer, +// BlendMode.srcIn, +// ), +// ), +// Flexible( +// child: Text( +// L10n.of(context).startOwn, +// textAlign: TextAlign.center, +// ), +// ), +// ], +// ), +// ), +// ListTile( +// contentPadding: const EdgeInsets.all(0.0), +// leading: const Icon(Icons.school), +// title: Text( +// L10n.of(context).joinCourseDesc, +// style: theme.textTheme.labelLarge, +// ), +// ), +// if (route == "registration") +// TextButton( +// child: Text(L10n.of(context).skipForNow), +// onPressed: () => context.go('/rooms'), +// ), +// ], +// ), +// ], +// ), +// ), +// ), +// ), +// ); +// } +// } diff --git a/lib/pangea/login/pages/course_code_page.dart b/lib/pangea/login/pages/course_code_page.dart index 6d3cd55ce..fd2e36cb3 100644 --- a/lib/pangea/login/pages/course_code_page.dart +++ b/lib/pangea/login/pages/course_code_page.dart @@ -6,7 +6,7 @@ import 'package:go_router/go_router.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/join_codes/space_code_controller.dart'; -import 'package:fluffychat/pangea/login/pages/add_course_page.dart'; +import 'package:fluffychat/pangea/spaces/space_constants.dart'; import 'package:fluffychat/widgets/matrix.dart'; class CourseCodePage extends StatefulWidget { @@ -72,7 +72,7 @@ class CourseCodePageState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ SvgPicture.network( - "${AppConfig.assetsBaseURL}/${AddCoursePage.mapUnlockFileName}", + "${AppConfig.assetsBaseURL}/${SpaceConstants.mapUnlockFileName}", width: 100.0, height: 100.0, colorFilter: ColorFilter.mode( diff --git a/lib/pangea/login/pages/create_pangea_account_page.dart b/lib/pangea/login/pages/create_pangea_account_page.dart index 6565912ef..69347f823 100644 --- a/lib/pangea/login/pages/create_pangea_account_page.dart +++ b/lib/pangea/login/pages/create_pangea_account_page.dart @@ -154,7 +154,7 @@ class CreatePangeaAccountPageState extends State { if (l2Set) { if (targetLangCode == null) { - context.go('/registration/course'); + context.go('/registration/notifications'); return; } @@ -216,7 +216,7 @@ class CreatePangeaAccountPageState extends State { context.go( _spaceId != null ? '/rooms/spaces/$_spaceId/details' - : '/registration/course', + : '/registration/notifications', ); } diff --git a/lib/pangea/login/pages/find_course_page.dart b/lib/pangea/login/pages/find_course_page.dart new file mode 100644 index 000000000..4f0eda0a4 --- /dev/null +++ b/lib/pangea/login/pages/find_course_page.dart @@ -0,0 +1,534 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; +import 'package:fluffychat/pangea/common/widgets/url_image_widget.dart'; +import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart'; +import 'package:fluffychat/pangea/course_creation/course_language_filter.dart'; +import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart'; +import 'package:fluffychat/pangea/course_plans/courses/course_plans_repo.dart'; +import 'package:fluffychat/pangea/course_plans/courses/get_localized_courses_request.dart'; +import 'package:fluffychat/pangea/languages/language_model.dart'; +import 'package:fluffychat/pangea/spaces/public_course_extension.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/hover_builder.dart'; +import 'package:fluffychat/widgets/layouts/max_width_body.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class FindCoursePage extends StatefulWidget { + const FindCoursePage({super.key}); + + @override + State createState() => FindCoursePageState(); +} + +class FindCoursePageState extends State { + final TextEditingController searchController = TextEditingController(); + + bool loading = true; + bool _fullyLoaded = false; + Object? error; + Timer? _coolDown; + + LanguageModel? targetLanguageFilter; + + List discoveredCourses = []; + Map coursePlans = {}; + String? nextBatch; + + @override + void initState() { + super.initState(); + + final target = MatrixState.pangeaController.userController.userL2; + if (target != null) { + setTargetLanguageFilter(target); + } + + _loadCourses(); + } + + @override + void dispose() { + searchController.dispose(); + _coolDown?.cancel(); + super.dispose(); + } + + void setTargetLanguageFilter(LanguageModel? language) { + if (targetLanguageFilter?.langCodeShort == language?.langCodeShort) return; + setState(() => targetLanguageFilter = language); + _loadCourses(); + } + + void onSearchEnter(String text, {bool globalSearch = true}) { + if (text.isEmpty) { + _loadCourses(); + return; + } + + _coolDown?.cancel(); + _coolDown = Timer(const Duration(milliseconds: 500), _loadCourses); + } + + List get filteredCourses { + List filtered = discoveredCourses + .where( + (c) => + !Matrix.of(context).client.rooms.any( + (r) => + r.id == c.room.roomId && + r.membership == Membership.join, + ) && + coursePlans.containsKey(c.courseId), + ) + .toList(); + + if (targetLanguageFilter != null) { + filtered = filtered.where( + (chunk) { + final course = coursePlans[chunk.courseId]; + if (course == null) return false; + return course.targetLanguage.split('-').first == + targetLanguageFilter!.langCodeShort; + }, + ).toList(); + } + + final searchText = searchController.text.trim().toLowerCase(); + if (searchText.isNotEmpty) { + filtered = filtered.where( + (chunk) { + final course = coursePlans[chunk.courseId]; + if (course == null) return false; + final name = chunk.room.name?.toLowerCase() ?? ''; + final description = course.description.toLowerCase(); + return name.contains(searchText) || description.contains(searchText); + }, + ).toList(); + } + + // sort by join rule, with knock rooms at the end + filtered.sort((a, b) { + final aKnock = a.room.joinRule == JoinRules.knock.name; + final bKnock = b.room.joinRule == JoinRules.knock.name; + if (aKnock && !bKnock) return 1; + if (!aKnock && bKnock) return -1; + return 0; + }); + + return filtered; + } + + Future _loadPublicSpaces() async { + try { + final resp = await Matrix.of(context).client.requestPublicCourses( + since: nextBatch, + ); + + for (final room in resp.courses) { + if (!discoveredCourses.any((e) => e.room.roomId == room.room.roomId)) { + discoveredCourses.add(room); + } + } + + nextBatch = resp.nextBatch; + } catch (e, s) { + error = e; + ErrorHandler.logError( + e: e, + s: s, + data: { + 'nextBatch': nextBatch, + }, + ); + } + } + + Future _loadCourses() async { + if (_fullyLoaded && nextBatch == null) { + return; + } + + setState(() { + loading = true; + error = null; + }); + + await _loadPublicSpaces(); + + int timesLoaded = 0; + while (error == null && timesLoaded < 5 && nextBatch != null) { + await _loadPublicSpaces(); + timesLoaded++; + } + + if (nextBatch == null) { + _fullyLoaded = true; + } + + try { + final resp = await CoursePlansRepo.search( + GetLocalizedCoursesRequest( + coursePlanIds: + discoveredCourses.map((c) => c.courseId).toSet().toList(), + l1: MatrixState.pangeaController.userController.userL1Code!, + ), + ); + final searchResult = resp.coursePlans; + + coursePlans.clear(); + for (final entry in searchResult.entries) { + coursePlans[entry.key] = entry.value; + } + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'discoveredCourses': + discoveredCourses.map((c) => c.courseId).toList(), + }, + ); + } finally { + if (mounted) { + setState(() => loading = false); + } + } + } + + void startNewCourse() { + String route = "/rooms/course/own"; + if (targetLanguageFilter != null) { + route += + "?lang=${Uri.encodeComponent(targetLanguageFilter!.langCodeShort)}"; + } + context.go(route); + } + + @override + Widget build(BuildContext context) { + return FindCoursePageView(controller: this); + } +} + +class FindCoursePageView extends StatelessWidget { + final FindCoursePageState controller; + + const FindCoursePageView({ + super.key, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isColumnMode = FluffyThemes.isColumnMode(context); + + return Scaffold( + appBar: AppBar(title: Text(L10n.of(context).findCourse)), + body: MaxWidthBody( + showBorder: false, + withScrolling: false, + maxWidth: 600.0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Column( + spacing: 16.0, + children: [ + TextField( + controller: controller.searchController, + textInputAction: TextInputAction.search, + onChanged: controller.onSearchEnter, + decoration: InputDecoration( + filled: !isColumnMode, + fillColor: isColumnMode + ? null + : theme.colorScheme.secondaryContainer, + border: OutlineInputBorder( + borderSide: + isColumnMode ? const BorderSide() : BorderSide.none, + borderRadius: BorderRadius.circular(100), + ), + contentPadding: const EdgeInsets.fromLTRB( + 0, + 0, + 20.0, + 0, + ), + hintText: L10n.of(context).findCourse, + hintStyle: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.normal, + fontSize: 16.0, + ), + floatingLabelBehavior: FloatingLabelBehavior.never, + prefixIcon: IconButton( + onPressed: () {}, + icon: Icon( + Icons.search_outlined, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ), + ), + LayoutBuilder( + builder: (context, constrained) { + return Row( + spacing: 12.0, + children: [ + Expanded( + child: CourseLanguageFilter( + value: controller.targetLanguageFilter, + onChanged: controller.setTargetLanguageFilter, + ), + ), + if (constrained.maxWidth >= 500) ...[ + TextButton( + onPressed: controller.startNewCourse, + child: Row( + spacing: 8.0, + children: [ + const Icon(Icons.add), + Text(L10n.of(context).newCourse), + ], + ), + ), + TextButton( + child: Row( + spacing: 8.0, + children: [ + const Icon(Icons.join_full), + Text(L10n.of(context).joinWithCode), + ], + ), + onPressed: () => context.go("/rooms/course/private"), + ), + ] else + PopupMenuButton( + icon: const Icon(Icons.more_vert), + itemBuilder: (context) => [ + PopupMenuItem( + onTap: controller.startNewCourse, + child: Row( + spacing: 8.0, + children: [ + const Icon(Icons.add), + Text(L10n.of(context).newCourse), + ], + ), + ), + PopupMenuItem( + onTap: () => context.go("/rooms/course/private"), + child: Row( + spacing: 8.0, + children: [ + const Icon(Icons.join_full), + Text(L10n.of(context).joinWithCode), + ], + ), + ), + ], + ), + ], + ); + }, + ), + ValueListenableBuilder( + valueListenable: controller.searchController, + builder: (context, _, __) { + if (controller.error != null) { + return ErrorIndicator( + message: L10n.of(context).oopsSomethingWentWrong, + ); + } + + if (controller.loading) { + return const CircularProgressIndicator.adaptive(); + } + + if (controller.filteredCourses.isEmpty) { + return Text( + L10n.of(context).nothingFound, + ); + } + + return Expanded( + child: ListView.builder( + itemCount: controller.filteredCourses.length, + itemBuilder: (context, index) { + final space = controller.filteredCourses[index]; + return _PublicCourseTile( + chunk: space, + course: controller.coursePlans[space.courseId], + ); + }, + ), + ); + }, + ), + ], + ), + ), + ), + ); + } +} + +class _PublicCourseTile extends StatelessWidget { + final PublicCoursesChunk chunk; + final CoursePlanModel? course; + + const _PublicCourseTile({ + required this.chunk, + this.course, + }); + + void _navigateToCoursePage( + BuildContext context, + ) { + context.go( + '/rooms/course/${Uri.encodeComponent(chunk.room.roomId)}', + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isColumnMode = FluffyThemes.isColumnMode(context); + final space = chunk.room; + final courseId = chunk.courseId; + final displayname = + space.name ?? space.canonicalAlias ?? L10n.of(context).emptyChat; + + return Padding( + padding: isColumnMode + ? const EdgeInsets.only( + bottom: 32.0, + ) + : const EdgeInsets.only( + bottom: 16.0, + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => _navigateToCoursePage(context), + borderRadius: BorderRadius.circular(12.0), + child: Container( + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0), + border: Border.all( + color: theme.colorScheme.primary, + ), + ), + child: Column( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 8.0, + children: [ + ImageByUrl( + imageUrl: space.avatarUrl, + width: 58.0, + borderRadius: BorderRadius.circular(10.0), + replacement: Avatar( + name: displayname, + borderRadius: BorderRadius.circular( + 10.0, + ), + size: 58.0, + ), + ), + Flexible( + child: Column( + spacing: 0.0, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayname, + style: theme.textTheme.bodyLarge, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + Row( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.group, + size: 16.0, + ), + Text( + L10n.of(context).countParticipants( + space.numJoinedMembers, + ), + style: theme.textTheme.bodyMedium, + ), + ], + ), + ], + ), + ), + ], + ), + if (course != null) ...[ + CourseInfoChips( + courseId, + iconSize: 12.0, + fontSize: 12.0, + ), + Text( + course!.description, + style: theme.textTheme.bodyMedium, + ), + ], + const SizedBox(height: 12.0), + HoverBuilder( + builder: (context, hovered) => ElevatedButton( + onPressed: () => _navigateToCoursePage(context), + style: ElevatedButton.styleFrom( + backgroundColor: + theme.colorScheme.primaryContainer.withAlpha( + hovered ? 255 : 200, + ), + foregroundColor: theme.colorScheme.onPrimaryContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 12.0, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + space.joinRule == JoinRules.knock.name + ? L10n.of( + context, + ).knock + : L10n.of( + context, + ).join, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pangea/login/pages/language_selection_page.dart b/lib/pangea/login/pages/language_selection_page.dart index fad33f3fd..f1f3f22e7 100644 --- a/lib/pangea/login/pages/language_selection_page.dart +++ b/lib/pangea/login/pages/language_selection_page.dart @@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/common/widgets/shimmer_background.dart'; +import 'package:fluffychat/pangea/common/widgets/shrinkable_text.dart'; import 'package:fluffychat/pangea/languages/language_model.dart'; import 'package:fluffychat/pangea/languages/language_service.dart'; import 'package:fluffychat/pangea/languages/p_language_store.dart'; @@ -102,14 +104,43 @@ class LanguageSelectionPageState extends State { return Scaffold( appBar: AppBar( - title: Text(L10n.of(context).languages), + title: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 500, + ), + child: Row( + spacing: 12.0, + children: [ + Navigator.of(context).canPop() + ? BackButton( + onPressed: Navigator.of(context).maybePop, + ) + : const SizedBox(width: 40.0), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return ShrinkableText( + text: L10n.of(context).onboardingLanguagesTitle, + maxWidth: constraints.maxWidth, + alignment: Alignment.center, + ); + }, + ), + ), + const SizedBox( + width: 40.0, + ), + ], + ), + ), + automaticallyImplyLeading: false, ), body: SafeArea( child: Center( child: Container( padding: const EdgeInsets.all(20.0), constraints: const BoxConstraints( - maxWidth: 450, + maxWidth: 500, ), child: Column( spacing: 24.0, @@ -121,6 +152,7 @@ class LanguageSelectionPageState extends State { border: OutlineInputBorder( borderRadius: BorderRadius.circular(50), ), + hintText: L10n.of(context).searchLanguagesHint, ), ), Expanded( @@ -140,7 +172,7 @@ class LanguageSelectionPageState extends State { ), child: Wrap( spacing: isColumnMode ? 16.0 : 8.0, - runSpacing: isColumnMode ? 16.0 : 8.0, + runSpacing: isColumnMode ? 24.0 : 16.0, alignment: WrapAlignment.center, children: languages .where( @@ -153,27 +185,41 @@ class LanguageSelectionPageState extends State { ), ) .map( - (l) => FilterChip( - selected: _selectedLanguage == l, - backgroundColor: - _selectedLanguage == l - ? theme.colorScheme.primary - : theme.colorScheme.surface, - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 4.0, + (l) => ShimmerBackground( + enabled: _selectedLanguage == null, + borderRadius: const BorderRadius.all( + Radius.circular(16.0), ), - label: Text( - l.getDisplayName(context), - style: isColumnMode - ? theme.textTheme.bodyLarge - : theme.textTheme.bodyMedium, + child: FilterChip( + materialTapTargetSize: + MaterialTapTargetSize + .shrinkWrap, + selected: _selectedLanguage == l, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(16.0), + ), + ), + backgroundColor: + _selectedLanguage == l + ? theme.colorScheme.primary + : theme.colorScheme.surface, + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 4.0, + ), + label: Text( + l.getDisplayName(context), + style: isColumnMode + ? theme.textTheme.bodyLarge + : theme.textTheme.bodyMedium, + ), + onSelected: (selected) { + _setSelectedLanguage( + selected ? l : null, + ); + }, ), - onSelected: (selected) { - _setSelectedLanguage( - selected ? l : null, - ); - }, ), ) .toList(), @@ -220,23 +266,24 @@ class LanguageSelectionPageState extends State { ) : const SizedBox(), ), - Text( - L10n.of(context).chooseLanguage, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ElevatedButton( - onPressed: _selectedLanguage != null ? _submit : null, - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primaryContainer, - foregroundColor: theme.colorScheme.onPrimaryContainer, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(L10n.of(context).letsGo), - ], + ShimmerBackground( + enabled: _selectedLanguage != null, + borderRadius: BorderRadius.circular(24.0), + child: ElevatedButton( + onPressed: _selectedLanguage != null ? _submit : null, + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primaryContainer, + foregroundColor: theme.colorScheme.onPrimaryContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24.0), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(L10n.of(context).letsGo), + ], + ), ), ), ], diff --git a/lib/pangea/login/pages/login_options_view.dart b/lib/pangea/login/pages/login_options_view.dart index 5ef1bc512..c6a73b9e4 100644 --- a/lib/pangea/login/pages/login_options_view.dart +++ b/lib/pangea/login/pages/login_options_view.dart @@ -21,9 +21,24 @@ class LoginOptionsView extends StatelessWidget { final theme = Theme.of(context); return Scaffold( appBar: AppBar( - title: Text( - L10n.of(context).loginToAccount, + title: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 450, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + BackButton( + onPressed: Navigator.of(context).pop, + ), + Text(L10n.of(context).login), + const SizedBox( + width: 40.0, + ), + ], + ), ), + automaticallyImplyLeading: false, ), body: SafeArea( child: Center( @@ -36,6 +51,13 @@ class LoginOptionsView extends StatelessWidget { spacing: 16.0, mainAxisAlignment: MainAxisAlignment.end, children: [ + Text( + L10n.of(context).loginToAccount, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), const PangeaSsoButton( provider: SSOProvider.apple, title: "Apple", diff --git a/lib/pangea/login/pages/new_course_page.dart b/lib/pangea/login/pages/new_course_page.dart index 65903c224..fd15070a6 100644 --- a/lib/pangea/login/pages/new_course_page.dart +++ b/lib/pangea/login/pages/new_course_page.dart @@ -17,6 +17,7 @@ import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart'; import 'package:fluffychat/pangea/course_plans/courses/course_plans_repo.dart'; import 'package:fluffychat/pangea/course_plans/courses/get_localized_courses_response.dart'; import 'package:fluffychat/pangea/languages/language_model.dart'; +import 'package:fluffychat/pangea/languages/p_language_store.dart'; import 'package:fluffychat/pangea/learning_settings/language_level_type_enum.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/adaptive_dialog_action.dart'; import 'package:fluffychat/widgets/avatar.dart'; @@ -27,12 +28,14 @@ class NewCoursePage extends StatefulWidget { final String route; final String? spaceId; final bool showFilters; + final String? initialLanguageCode; const NewCoursePage({ super.key, required this.route, this.spaceId, this.showFilters = true, + this.initialLanguageCode, }); @override @@ -50,8 +53,15 @@ class NewCoursePageState extends State { void initState() { super.initState(); - _targetLanguageFilter.value = - MatrixState.pangeaController.userController.userL2; + if (widget.initialLanguageCode != null) { + _targetLanguageFilter.value = + PLanguageStore.byLangCode(widget.initialLanguageCode!); + } + + if (_targetLanguageFilter.value == null) { + _targetLanguageFilter.value = + MatrixState.pangeaController.userController.userL2; + } _loadCourses(); } diff --git a/lib/pangea/login/pages/pangea_login_view.dart b/lib/pangea/login/pages/pangea_login_view.dart index 45c3bdfc3..8690136aa 100644 --- a/lib/pangea/login/pages/pangea_login_view.dart +++ b/lib/pangea/login/pages/pangea_login_view.dart @@ -15,9 +15,24 @@ class PasswordLoginView extends StatelessWidget { key: controller.formKey, child: Scaffold( appBar: AppBar( - title: Text( - L10n.of(context).loginWithEmail, + title: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 450, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + BackButton( + onPressed: Navigator.of(context).pop, + ), + Text(L10n.of(context).loginWithEmail), + const SizedBox( + width: 40.0, + ), + ], + ), ), + automaticallyImplyLeading: false, ), body: SafeArea( child: Center( diff --git a/lib/pangea/login/pages/public_courses_page.dart b/lib/pangea/login/pages/public_courses_page.dart index 18c0f3dde..54aa25940 100644 --- a/lib/pangea/login/pages/public_courses_page.dart +++ b/lib/pangea/login/pages/public_courses_page.dart @@ -1,350 +1,402 @@ -import 'package:flutter/material.dart'; +// import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:matrix/matrix.dart'; +// import 'package:go_router/go_router.dart'; +// import 'package:matrix/matrix.dart'; -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/common/widgets/url_image_widget.dart'; -import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart'; -import 'package:fluffychat/pangea/course_creation/course_language_filter.dart'; -import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart'; -import 'package:fluffychat/pangea/course_plans/courses/course_plans_repo.dart'; -import 'package:fluffychat/pangea/course_plans/courses/get_localized_courses_request.dart'; -import 'package:fluffychat/pangea/languages/language_model.dart'; -import 'package:fluffychat/pangea/spaces/public_course_extension.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import 'package:fluffychat/widgets/matrix.dart'; +// import 'package:fluffychat/l10n/l10n.dart'; +// import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart'; +// import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +// import 'package:fluffychat/pangea/common/widgets/url_image_widget.dart'; +// import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart'; +// import 'package:fluffychat/pangea/course_creation/course_language_filter.dart'; +// import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart'; +// import 'package:fluffychat/pangea/course_plans/courses/course_plans_repo.dart'; +// import 'package:fluffychat/pangea/course_plans/courses/get_localized_courses_request.dart'; +// import 'package:fluffychat/pangea/languages/language_model.dart'; +// import 'package:fluffychat/pangea/spaces/public_course_extension.dart'; +// import 'package:fluffychat/widgets/avatar.dart'; +// import 'package:fluffychat/widgets/hover_builder.dart'; +// import 'package:fluffychat/widgets/matrix.dart'; -class PublicCoursesPage extends StatefulWidget { - final String route; - final bool showFilters; - const PublicCoursesPage({ - super.key, - required this.route, - this.showFilters = true, - }); +// class PublicCoursesPage extends StatefulWidget { +// final String route; +// final bool showFilters; +// const PublicCoursesPage({ +// super.key, +// required this.route, +// this.showFilters = true, +// }); - @override - State createState() => PublicCoursesPageState(); -} +// @override +// State createState() => PublicCoursesPageState(); +// } -class PublicCoursesPageState extends State { - bool loading = true; - Object? error; +// class PublicCoursesPageState extends State { +// bool loading = true; +// Object? error; - LanguageModel? targetLanguageFilter; +// LanguageModel? targetLanguageFilter; - List discoveredCourses = []; - Map coursePlans = {}; - String? nextBatch; +// List discoveredCourses = []; +// Map coursePlans = {}; +// String? nextBatch; - @override - void initState() { - super.initState(); +// @override +// void initState() { +// super.initState(); - final target = MatrixState.pangeaController.userController.userL2; - if (target != null) { - setTargetLanguageFilter(target); - } +// final target = MatrixState.pangeaController.userController.userL2; +// if (target != null) { +// setTargetLanguageFilter(target); +// } - _loadCourses(); - } +// _loadCourses(); +// } - void setTargetLanguageFilter(LanguageModel? language, {bool reload = true}) { - if (targetLanguageFilter?.langCodeShort == language?.langCodeShort) return; - setState(() => targetLanguageFilter = language); - if (reload) _loadCourses(); - } +// void setTargetLanguageFilter(LanguageModel? language) { +// if (targetLanguageFilter?.langCodeShort == language?.langCodeShort) return; +// setState(() => targetLanguageFilter = language); +// _loadCourses(); +// } - List get filteredCourses { - List filtered = discoveredCourses - .where( - (c) => - !Matrix.of(context).client.rooms.any( - (r) => - r.id == c.room.roomId && - r.membership == Membership.join, - ) && - coursePlans.containsKey(c.courseId) && - c.room.joinRule == 'public', - ) - .toList(); +// List get filteredCourses { +// List filtered = discoveredCourses +// .where( +// (c) => +// !Matrix.of(context).client.rooms.any( +// (r) => +// r.id == c.room.roomId && +// r.membership == Membership.join, +// ) && +// coursePlans.containsKey(c.courseId), +// ) +// .toList(); - if (targetLanguageFilter != null) { - filtered = filtered.where( - (chunk) { - final course = coursePlans[chunk.courseId]; - if (course == null) return false; - return course.targetLanguage.split('-').first == - targetLanguageFilter!.langCodeShort; - }, - ).toList(); - } +// if (targetLanguageFilter != null) { +// filtered = filtered.where( +// (chunk) { +// final course = coursePlans[chunk.courseId]; +// if (course == null) return false; +// return course.targetLanguage.split('-').first == +// targetLanguageFilter!.langCodeShort; +// }, +// ).toList(); +// } - return filtered; - } +// // sort by join rule, with knock rooms at the end +// filtered.sort((a, b) { +// final aKnock = a.room.joinRule == JoinRules.knock.name; +// final bKnock = b.room.joinRule == JoinRules.knock.name; +// if (aKnock && !bKnock) return 1; +// if (!aKnock && bKnock) return -1; +// return 0; +// }); - Future _loadCourses() async { - try { - setState(() { - loading = true; - error = null; - }); +// return filtered; +// } - final resp = await Matrix.of(context).client.requestPublicCourses( - since: nextBatch, - ); +// Future _loadPublicSpaces() async { +// try { +// final resp = await Matrix.of(context).client.requestPublicCourses( +// since: nextBatch, +// ); - for (final room in resp.courses) { - if (!discoveredCourses.any((e) => e.room.roomId == room.room.roomId)) { - discoveredCourses.add(room); - } - } +// for (final room in resp.courses) { +// if (!discoveredCourses.any((e) => e.room.roomId == room.room.roomId)) { +// discoveredCourses.add(room); +// } +// } - nextBatch = resp.nextBatch; - } catch (e, s) { - error = e; - ErrorHandler.logError( - e: e, - s: s, - data: { - 'nextBatch': nextBatch, - }, - ); - } +// nextBatch = resp.nextBatch; +// } catch (e, s) { +// error = e; +// ErrorHandler.logError( +// e: e, +// s: s, +// data: { +// 'nextBatch': nextBatch, +// }, +// ); +// } +// } - try { - final resp = await CoursePlansRepo.search( - GetLocalizedCoursesRequest( - coursePlanIds: - discoveredCourses.map((c) => c.courseId).toSet().toList(), - l1: MatrixState.pangeaController.userController.userL1Code!, - ), - ); - final searchResult = resp.coursePlans; +// Future _loadCourses() async { +// setState(() { +// loading = true; +// error = null; +// }); - coursePlans.clear(); - for (final entry in searchResult.entries) { - coursePlans[entry.key] = entry.value; - } - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - 'discoveredCourses': - discoveredCourses.map((c) => c.courseId).toList(), - }, - ); - } finally { - if (mounted) { - setState(() => loading = false); - } - } - } +// await _loadPublicSpaces(); - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Scaffold( - appBar: AppBar( - title: Text( - L10n.of(context).joinPublicCourse, - ), - ), - body: SafeArea( - child: Center( - child: Container( - padding: const EdgeInsets.all(20.0), - constraints: const BoxConstraints( - maxWidth: 450, - ), - child: Column( - children: [ - if (widget.showFilters) ...[ - Row( - children: [ - Expanded( - child: Wrap( - spacing: 8.0, - runSpacing: 8.0, - alignment: WrapAlignment.start, - children: [ - CourseLanguageFilter( - value: targetLanguageFilter, - onChanged: setTargetLanguageFilter, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 20.0), - ], - if (error != null || - (!loading && filteredCourses.isEmpty && nextBatch == null)) - Center( - child: Padding( - padding: const EdgeInsets.all(32.0), - child: Column( - spacing: 12.0, - children: [ - const BotFace( - expression: BotExpression.addled, - width: Avatar.defaultSize * 1.5, - ), - Text( - L10n.of(context).noPublicCoursesFound, - textAlign: TextAlign.center, - style: theme.textTheme.bodyLarge, - ), - ElevatedButton( - onPressed: () => context.go( - '/${widget.route}/course/own', - ), - style: ElevatedButton.styleFrom( - backgroundColor: - theme.colorScheme.primaryContainer, - foregroundColor: - theme.colorScheme.onPrimaryContainer, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(L10n.of(context).startOwn), - ], - ), - ), - ], - ), - ), - ) - else - Expanded( - child: ListView.separated( - itemCount: filteredCourses.length + 1, - separatorBuilder: (context, index) => - const SizedBox(height: 10.0), - itemBuilder: (context, index) { - if (index == filteredCourses.length) { - return Center( - child: loading - ? const CircularProgressIndicator.adaptive() - : nextBatch != null - ? TextButton( - onPressed: _loadCourses, - child: Text(L10n.of(context).loadMore), - ) - : const SizedBox(), - ); - } +// int timesLoaded = 0; +// while (error == null && timesLoaded < 5 && nextBatch != null) { +// await _loadPublicSpaces(); +// timesLoaded++; +// } - final roomChunk = filteredCourses[index].room; - final courseId = filteredCourses[index].courseId; - final course = coursePlans[courseId]; +// try { +// final resp = await CoursePlansRepo.search( +// GetLocalizedCoursesRequest( +// coursePlanIds: +// discoveredCourses.map((c) => c.courseId).toSet().toList(), +// l1: MatrixState.pangeaController.userController.userL1Code!, +// ), +// ); +// final searchResult = resp.coursePlans; - final displayname = roomChunk.name ?? - roomChunk.canonicalAlias ?? - L10n.of(context).emptyChat; +// coursePlans.clear(); +// for (final entry in searchResult.entries) { +// coursePlans[entry.key] = entry.value; +// } +// } catch (e, s) { +// ErrorHandler.logError( +// e: e, +// s: s, +// data: { +// 'discoveredCourses': +// discoveredCourses.map((c) => c.courseId).toList(), +// }, +// ); +// } finally { +// if (mounted) { +// setState(() => loading = false); +// } +// } +// } - return Material( - type: MaterialType.transparency, - child: InkWell( - onTap: () => context.go( - '/${widget.route}/course/public/$courseId', - extra: roomChunk, - ), - borderRadius: BorderRadius.circular(12.0), - child: Container( - padding: const EdgeInsets.all(12.0), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12.0), - border: Border.all( - color: theme.colorScheme.primary, - ), - ), - child: Column( - spacing: 4.0, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - spacing: 8.0, - children: [ - ImageByUrl( - imageUrl: roomChunk.avatarUrl, - width: 58.0, - borderRadius: - BorderRadius.circular(10.0), - replacement: Avatar( - name: displayname, - borderRadius: BorderRadius.circular( - 10.0, - ), - size: 58.0, - ), - ), - Flexible( - child: Column( - spacing: 0.0, - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - displayname, - style: theme.textTheme.bodyLarge, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - Row( - spacing: 4.0, - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.group, - size: 16.0, - ), - Text( - L10n.of(context) - .countParticipants( - roomChunk.numJoinedMembers, - ), - style: theme - .textTheme.bodyMedium, - ), - ], - ), - ], - ), - ), - ], - ), - if (course != null) ...[ - CourseInfoChips( - courseId, - iconSize: 12.0, - fontSize: 12.0, - ), - Text( - course.description, - style: theme.textTheme.bodyMedium, - ), - ], - ], - ), - ), - ), - ); - }, - ), - ), - ], - ), - ), - ), - ), - ); - } -} +// @override +// Widget build(BuildContext context) { +// final theme = Theme.of(context); +// return Scaffold( +// appBar: AppBar( +// title: Text( +// L10n.of(context).joinPublicCourse, +// ), +// ), +// body: SafeArea( +// child: Center( +// child: Container( +// padding: const EdgeInsets.all(20.0), +// constraints: const BoxConstraints( +// maxWidth: 450, +// ), +// child: Column( +// children: [ +// if (widget.showFilters) ...[ +// Row( +// children: [ +// Expanded( +// child: Wrap( +// spacing: 8.0, +// runSpacing: 8.0, +// alignment: WrapAlignment.start, +// children: [ +// CourseLanguageFilter( +// value: targetLanguageFilter, +// onChanged: setTargetLanguageFilter, +// ), +// ], +// ), +// ), +// ], +// ), +// const SizedBox(height: 20.0), +// ], +// if (error != null || +// (!loading && filteredCourses.isEmpty && nextBatch == null)) +// Center( +// child: Padding( +// padding: const EdgeInsets.all(32.0), +// child: Column( +// spacing: 12.0, +// children: [ +// const BotFace( +// expression: BotExpression.addled, +// width: Avatar.defaultSize * 1.5, +// ), +// Text( +// L10n.of(context).noPublicCoursesFound, +// textAlign: TextAlign.center, +// style: theme.textTheme.bodyLarge, +// ), +// ElevatedButton( +// onPressed: () => context.go( +// '/${widget.route}/course/own', +// ), +// style: ElevatedButton.styleFrom( +// backgroundColor: +// theme.colorScheme.primaryContainer, +// foregroundColor: +// theme.colorScheme.onPrimaryContainer, +// ), +// child: Row( +// mainAxisAlignment: MainAxisAlignment.center, +// children: [ +// Text(L10n.of(context).startOwn), +// ], +// ), +// ), +// ], +// ), +// ), +// ) +// else +// Expanded( +// child: ListView.separated( +// itemCount: filteredCourses.length + 1, +// separatorBuilder: (context, index) => +// const SizedBox(height: 10.0), +// itemBuilder: (context, index) { +// if (index == filteredCourses.length) { +// return Center( +// child: loading +// ? const CircularProgressIndicator.adaptive() +// : nextBatch != null +// ? TextButton( +// onPressed: _loadCourses, +// child: Text(L10n.of(context).loadMore), +// ) +// : const SizedBox(), +// ); +// } + +// final roomChunk = filteredCourses[index].room; +// final courseId = filteredCourses[index].courseId; +// final course = coursePlans[courseId]; + +// final displayname = roomChunk.name ?? +// roomChunk.canonicalAlias ?? +// L10n.of(context).emptyChat; + +// return Material( +// type: MaterialType.transparency, +// child: InkWell( +// onTap: () => context.go( +// '/${widget.route}/course/public/$courseId', +// extra: roomChunk, +// ), +// borderRadius: BorderRadius.circular(12.0), +// child: Container( +// padding: const EdgeInsets.all(12.0), +// decoration: BoxDecoration( +// borderRadius: BorderRadius.circular(12.0), +// border: Border.all( +// color: theme.colorScheme.primary, +// ), +// ), +// child: Column( +// spacing: 4.0, +// mainAxisSize: MainAxisSize.min, +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// Row( +// spacing: 8.0, +// children: [ +// ImageByUrl( +// imageUrl: roomChunk.avatarUrl, +// width: 58.0, +// borderRadius: +// BorderRadius.circular(10.0), +// replacement: Avatar( +// name: displayname, +// borderRadius: BorderRadius.circular( +// 10.0, +// ), +// size: 58.0, +// ), +// ), +// Flexible( +// child: Column( +// spacing: 0.0, +// crossAxisAlignment: +// CrossAxisAlignment.start, +// children: [ +// Text( +// displayname, +// style: theme.textTheme.bodyLarge, +// maxLines: 2, +// overflow: TextOverflow.ellipsis, +// ), +// Row( +// spacing: 4.0, +// mainAxisSize: MainAxisSize.min, +// children: [ +// const Icon( +// Icons.group, +// size: 16.0, +// ), +// Text( +// L10n.of(context) +// .countParticipants( +// roomChunk.numJoinedMembers, +// ), +// style: theme +// .textTheme.bodyMedium, +// ), +// ], +// ), +// ], +// ), +// ), +// ], +// ), +// if (course != null) ...[ +// CourseInfoChips( +// courseId, +// iconSize: 12.0, +// fontSize: 12.0, +// ), +// Text( +// course.description, +// style: theme.textTheme.bodyMedium, +// ), +// ], +// const SizedBox(height: 12.0), +// HoverBuilder( +// builder: (context, hovered) => +// ElevatedButton( +// onPressed: () => context.go( +// '/${widget.route}/course/public/$courseId', +// extra: roomChunk, +// ), +// style: ElevatedButton.styleFrom( +// backgroundColor: theme +// .colorScheme.primaryContainer +// .withAlpha(hovered ? 255 : 200), +// foregroundColor: theme +// .colorScheme.onPrimaryContainer, +// shape: RoundedRectangleBorder( +// borderRadius: +// BorderRadius.circular(12.0), +// ), +// ), +// child: Row( +// mainAxisAlignment: +// MainAxisAlignment.center, +// children: [ +// Text( +// roomChunk.joinRule == +// JoinRules.knock.name +// ? L10n.of(context).knock +// : L10n.of(context).join, +// ), +// ], +// ), +// ), +// ), +// ], +// ), +// ), +// ), +// ); +// }, +// ), +// ), +// ], +// ), +// ), +// ), +// ), +// ); +// } +// } diff --git a/lib/pangea/login/pages/signup_view.dart b/lib/pangea/login/pages/signup_view.dart index 8492775ca..6b79ee241 100644 --- a/lib/pangea/login/pages/signup_view.dart +++ b/lib/pangea/login/pages/signup_view.dart @@ -27,7 +27,24 @@ class SignupPageView extends StatelessWidget { return Form( key: controller.formKey, child: Scaffold( - appBar: AppBar(), + appBar: AppBar( + title: SizedBox( + width: 450, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + BackButton( + onPressed: Navigator.of(context).pop, + ), + Text(L10n.of(context).signUp), + const SizedBox( + width: 40.0, + ), + ], + ), + ), + automaticallyImplyLeading: false, + ), body: SafeArea( child: Center( child: ConstrainedBox( @@ -46,8 +63,8 @@ class SignupPageView extends StatelessWidget { fontWeight: FontWeight.bold, ), ), - const PangeaSsoButton(provider: SSOProvider.google), const PangeaSsoButton(provider: SSOProvider.apple), + const PangeaSsoButton(provider: SSOProvider.google), ElevatedButton( onPressed: () => context.go( '/home/language/signup/email', diff --git a/lib/pangea/login/pages/signup_with_email_view.dart b/lib/pangea/login/pages/signup_with_email_view.dart index 59da3b129..840e43812 100644 --- a/lib/pangea/login/pages/signup_with_email_view.dart +++ b/lib/pangea/login/pages/signup_with_email_view.dart @@ -15,7 +15,25 @@ class SignupWithEmailView extends StatelessWidget { return Form( key: controller.formKey, child: Scaffold( - appBar: AppBar(), + appBar: AppBar( + title: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 450, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + BackButton( + onPressed: Navigator.of(context).pop, + ), + const SizedBox( + width: 40.0, + ), + ], + ), + ), + automaticallyImplyLeading: false, + ), body: SafeArea( child: Center( child: ConstrainedBox( diff --git a/lib/pangea/login/widgets/p_sso_button.dart b/lib/pangea/login/widgets/p_sso_button.dart index d89c0e0e1..a02219333 100644 --- a/lib/pangea/login/widgets/p_sso_button.dart +++ b/lib/pangea/login/widgets/p_sso_button.dart @@ -9,12 +9,12 @@ import 'package:matrix/matrix.dart'; import 'package:universal_html/html.dart' as html; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pages/homeserver_picker/homeserver_picker.dart'; import 'package:fluffychat/pangea/common/utils/firebase_analytics.dart'; import 'package:fluffychat/pangea/login/sso_provider_enum.dart'; import 'package:fluffychat/pangea/login/widgets/p_sso_dialog.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/fluffy_chat_app.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; class PangeaSsoButton extends StatelessWidget { @@ -27,23 +27,25 @@ class PangeaSsoButton extends StatelessWidget { super.key, }); - Future _runSSOLogin(BuildContext context) => showAdaptiveDialog( - context: context, - builder: (context) => SSODialog( - future: () => _ssoAction( - IdentityProvider( - id: provider.id, - name: provider.name, - ), - context, - ), - ), - ); + Future _runSSOLogin(BuildContext context) async { + final token = await showAdaptiveDialog( + context: context, + builder: (context) => SSODialog( + future: () => _getSSOToken(context), + ), + ); - Future _ssoAction( - IdentityProvider provider, - BuildContext context, - ) async { + if (token == null || token.isEmpty) { + return; + } + + await showFutureLoadingDialog( + context: context, + future: () => _ssoAction(token, context), + ); + } + + Future _getSSOToken(BuildContext context) async { final bool isDefaultPlatform = (PlatformInfos.isMobile || PlatformInfos.isWeb || PlatformInfos.isMacOS); @@ -58,7 +60,7 @@ class PangeaSsoButton extends StatelessWidget { : 'http://localhost:3001//login'; final client = await Matrix.of(context).getLoginClient(); final url = client.homeserver!.replace( - path: '/_matrix/client/v3/login/sso/redirect/${provider.id ?? ''}', + path: '/_matrix/client/v3/login/sso/redirect/${provider.id}', queryParameters: {'redirectUrl': redirectUrl}, ); @@ -74,13 +76,20 @@ class PangeaSsoButton extends StatelessWidget { } catch (err) { if (err is PlatformException && err.code == 'CANCELED') { debugPrint("user cancelled SSO login"); - return; + return null; } rethrow; } final token = Uri.parse(result).queryParameters['loginToken']; - if (token?.isEmpty ?? false) return; + if (token?.isEmpty ?? false) return null; + return token; + } + Future _ssoAction( + String token, + BuildContext context, + ) async { + final client = Matrix.of(context).client; final redirect = client.onLoginStateChanged.stream .where((state) => state == LoginState.loggedIn) .first @@ -110,7 +119,7 @@ class PangeaSsoButton extends StatelessWidget { await redirect; } - GoogleAnalytics.login(provider.name!, loginRes.userId); + GoogleAnalytics.login(provider.name, loginRes.userId); } @override diff --git a/lib/pangea/login/widgets/p_sso_dialog.dart b/lib/pangea/login/widgets/p_sso_dialog.dart index fb7184672..2620c3a11 100644 --- a/lib/pangea/login/widgets/p_sso_dialog.dart +++ b/lib/pangea/login/widgets/p_sso_dialog.dart @@ -5,11 +5,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/url_launcher.dart'; class SSODialog extends StatefulWidget { - final Future Function() future; + final Future Function() future; const SSODialog({ super.key, required this.future, @@ -22,7 +21,6 @@ class SSODialog extends StatefulWidget { class SSODialogState extends State { Timer? _hintTimer; bool _showHint = false; - Object? _error; @override void initState() { @@ -43,9 +41,10 @@ class SSODialogState extends State { Future _runFuture() async { try { - await widget.future(); + final token = await widget.future(); + Navigator.of(context).pop(token); } catch (e) { - setState(() => _error = e); + Navigator.of(context).pop(); } } @@ -69,17 +68,11 @@ class SSODialogState extends State { icon: const Icon(Icons.close), ), ), - _error == null - ? Text( - L10n.of(context).ssoDialogTitle, - style: Theme.of(context).textTheme.headlineMedium, - textAlign: TextAlign.center, - ) - : Icon( - Icons.error_outline_outlined, - color: Theme.of(context).colorScheme.error, - size: 48, - ), + Text( + L10n.of(context).ssoDialogTitle, + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), Container( alignment: Alignment.center, constraints: const BoxConstraints(minHeight: 150), @@ -88,39 +81,29 @@ class SSODialogState extends State { spacing: 16.0, mainAxisSize: MainAxisSize.min, children: [ - if (_error != null) - Text( - _error!.toLocalizedString(context), - style: Theme.of(context).textTheme.titleMedium, - textAlign: TextAlign.center, - ) - else ...[ - SelectableLinkify( - text: L10n.of(context).ssoDialogDesc, - textScaleFactor: - MediaQuery.textScalerOf(context).scale(1), - linkStyle: TextStyle( - color: Theme.of(context).colorScheme.primary, - decorationColor: Theme.of(context).colorScheme.primary, - ), - options: const LinkifyOptions(humanize: false), - onOpen: (url) => - UrlLauncher(context, url.url).launchUrl(), - style: Theme.of(context).textTheme.bodyLarge, - textAlign: TextAlign.center, + SelectableLinkify( + text: L10n.of(context).ssoDialogDesc, + textScaleFactor: MediaQuery.textScalerOf(context).scale(1), + linkStyle: TextStyle( + color: Theme.of(context).colorScheme.primary, + decorationColor: Theme.of(context).colorScheme.primary, ), - _showHint - ? Text( - L10n.of(context).ssoDialogHelpText, - style: Theme.of(context).textTheme.bodyLarge, - textAlign: TextAlign.center, - ) - : const SizedBox( - height: 16.0, - width: 16.0, - child: CircularProgressIndicator.adaptive(), - ), - ], + options: const LinkifyOptions(humanize: false), + onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + _showHint + ? Text( + L10n.of(context).ssoDialogHelpText, + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ) + : const SizedBox( + height: 16.0, + width: 16.0, + child: CircularProgressIndicator.adaptive(), + ), ], ), ), diff --git a/lib/pangea/morphs/get_grammar_copy.dart b/lib/pangea/morphs/get_grammar_copy.dart index 14085ab4a..ce811ba63 100644 --- a/lib/pangea/morphs/get_grammar_copy.dart +++ b/lib/pangea/morphs/get_grammar_copy.dart @@ -61,6 +61,12 @@ String? getGrammarCopy({ return L10n.of(context).grammarCopyPOSintj; case 'grammarCopyPOSx': return L10n.of(context).grammarCopyPOSx; + case 'grammarCopyPOSidiom': + return L10n.of(context).grammarCopyPOSidiom; + case 'grammarCopyPOSphrasalv': + return L10n.of(context).grammarCopyPOSphrasalv; + case 'grammarCopyPOScompn': + return L10n.of(context).grammarCopyPOScompn; case 'grammarCopyGENDERfem': return L10n.of(context).grammarCopyGENDERfem; case 'grammarCopyPERSON2': diff --git a/lib/pangea/morphs/morph_meaning/morph_info_repo.dart b/lib/pangea/morphs/morph_meaning/morph_info_repo.dart index 684c3134d..9a418b203 100644 --- a/lib/pangea/morphs/morph_meaning/morph_info_repo.dart +++ b/lib/pangea/morphs/morph_meaning/morph_info_repo.dart @@ -52,7 +52,7 @@ class MorphInfoRepo { final future = _safeFetch(accessToken, request); // 4. Save to in-memory cache - _cache[request.hashCode.toString()] = _MorphInfoCacheItem( + _cache[request.storageKey] = _MorphInfoCacheItem( resultFuture: future, timestamp: DateTime.now(), ); @@ -67,7 +67,7 @@ class MorphInfoRepo { MorphInfoRequest request, MorphInfoResponse resultFuture, ) async { - final key = request.hashCode.toString(); + final key = request.storageKey; try { await _storage.write(key, resultFuture.toJson()); _cache.remove(key); // Invalidate in-memory cache @@ -149,7 +149,7 @@ class MorphInfoRepo { MorphInfoRequest request, ) { final now = DateTime.now(); - final key = request.hashCode.toString(); + final key = request.storageKey; // Remove stale entries first _cache.removeWhere( @@ -173,7 +173,7 @@ class MorphInfoRepo { static MorphInfoResponse? _getStored( MorphInfoRequest request, ) { - final key = request.hashCode.toString(); + final key = request.storageKey; try { final entry = _storage.read(key); if (entry == null) return null; diff --git a/lib/pangea/morphs/parts_of_speech_enum.dart b/lib/pangea/morphs/parts_of_speech_enum.dart index c9103781d..b8f919be9 100644 --- a/lib/pangea/morphs/parts_of_speech_enum.dart +++ b/lib/pangea/morphs/parts_of_speech_enum.dart @@ -1,13 +1,6 @@ -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import 'package:collection/collection.dart'; -import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; /// list ordered by priority enum PartOfSpeechEnum { @@ -16,6 +9,9 @@ enum PartOfSpeechEnum { verb, adj, adv, + idiom, + phrasalv, + compn, //Function tokens sconj, @@ -48,107 +44,13 @@ enum PartOfSpeechEnum { return pos; } - String getDisplayCopy(BuildContext context) { - switch (this) { - case PartOfSpeechEnum.sconj: - return L10n.of(context).grammarCopyPOSsconj; - case PartOfSpeechEnum.num: - return L10n.of(context).grammarCopyPOSnum; - case PartOfSpeechEnum.verb: - return L10n.of(context).grammarCopyPOSverb; - case PartOfSpeechEnum.affix: - return L10n.of(context).grammarCopyPOSaffix; - case PartOfSpeechEnum.part: - return L10n.of(context).grammarCopyPOSpart; - case PartOfSpeechEnum.adj: - return L10n.of(context).grammarCopyPOSadj; - case PartOfSpeechEnum.cconj: - return L10n.of(context).grammarCopyPOScconj; - case PartOfSpeechEnum.punct: - return L10n.of(context).grammarCopyPOSpunct; - case PartOfSpeechEnum.adv: - return L10n.of(context).grammarCopyPOSadv; - case PartOfSpeechEnum.aux: - return L10n.of(context).grammarCopyPOSaux; - case PartOfSpeechEnum.space: - return L10n.of(context).grammarCopyPOSspace; - case PartOfSpeechEnum.sym: - return L10n.of(context).grammarCopyPOSsym; - case PartOfSpeechEnum.det: - return L10n.of(context).grammarCopyPOSdet; - case PartOfSpeechEnum.pron: - return L10n.of(context).grammarCopyPOSpron; - case PartOfSpeechEnum.adp: - return L10n.of(context).grammarCopyPOSadp; - case PartOfSpeechEnum.propn: - return L10n.of(context).grammarCopyPOSpropn; - case PartOfSpeechEnum.noun: - return L10n.of(context).grammarCopyPOSnoun; - case PartOfSpeechEnum.intj: - return L10n.of(context).grammarCopyPOSintj; - case PartOfSpeechEnum.x: - return L10n.of(context).grammarCopyPOSx; - } - } - bool get isContentWord => [ PartOfSpeechEnum.noun, PartOfSpeechEnum.verb, PartOfSpeechEnum.adj, PartOfSpeechEnum.adv, + PartOfSpeechEnum.idiom, + PartOfSpeechEnum.phrasalv, + PartOfSpeechEnum.compn, ].contains(this); - - bool get canBeDefined => [ - PartOfSpeechEnum.noun, - PartOfSpeechEnum.verb, - PartOfSpeechEnum.adj, - PartOfSpeechEnum.adv, - PartOfSpeechEnum.propn, - PartOfSpeechEnum.intj, - PartOfSpeechEnum.det, - PartOfSpeechEnum.pron, - PartOfSpeechEnum.sconj, - PartOfSpeechEnum.cconj, - PartOfSpeechEnum.adp, - PartOfSpeechEnum.aux, - PartOfSpeechEnum.num, - ].contains(this); - - bool get canBeHeard => [ - PartOfSpeechEnum.noun, - PartOfSpeechEnum.verb, - PartOfSpeechEnum.adj, - PartOfSpeechEnum.adv, - PartOfSpeechEnum.propn, - PartOfSpeechEnum.intj, - PartOfSpeechEnum.det, - PartOfSpeechEnum.pron, - PartOfSpeechEnum.sconj, - PartOfSpeechEnum.cconj, - PartOfSpeechEnum.adp, - PartOfSpeechEnum.aux, - PartOfSpeechEnum.num, - ].contains(this); - - bool eligibleForPractice(ActivityTypeEnum activityType) { - switch (activityType) { - case ActivityTypeEnum.emoji: - case ActivityTypeEnum.wordMeaning: - case ActivityTypeEnum.morphId: - return canBeDefined; - case ActivityTypeEnum.wordFocusListening: - return canBeHeard; - default: - debugger(when: kDebugMode); - return false; - } - } -} - -String? getVocabCategoryName(String category, BuildContext context) { - return PartOfSpeechEnum.values - .firstWhereOrNull( - (pos) => pos.name.toLowerCase() == category.toLowerCase(), - ) - ?.getDisplayCopy(context); } diff --git a/lib/pangea/phonetic_transcription/phonetic_transcription_builder.dart b/lib/pangea/phonetic_transcription/phonetic_transcription_builder.dart index 366f9b1fe..3627f78a3 100644 --- a/lib/pangea/phonetic_transcription/phonetic_transcription_builder.dart +++ b/lib/pangea/phonetic_transcription/phonetic_transcription_builder.dart @@ -8,29 +8,10 @@ import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_ import 'package:fluffychat/widgets/matrix.dart'; import 'phonetic_transcription_repo.dart'; -class _TranscriptLoader extends AsyncLoader { - final PhoneticTranscriptionRequest request; - _TranscriptLoader(this.request) : super(); - - @override - Future fetch() async { - final resp = await PhoneticTranscriptionRepo.get( - MatrixState.pangeaController.userController.accessToken, - request, - ); - - if (resp.isError) { - throw resp.asError!.error; - } - - return resp.asValue!.value.phoneticTranscriptionResult.phoneticTranscription - .first.phoneticL1Transcription.content; - } -} - class PhoneticTranscriptionBuilder extends StatefulWidget { final LanguageModel textLanguage; final String text; + final ValueNotifier? reloadNotifier; final Widget Function( BuildContext context, @@ -42,6 +23,7 @@ class PhoneticTranscriptionBuilder extends StatefulWidget { required this.textLanguage, required this.text, required this.builder, + this.reloadNotifier, }); @override @@ -51,12 +33,14 @@ class PhoneticTranscriptionBuilder extends StatefulWidget { class PhoneticTranscriptionBuilderState extends State { - late _TranscriptLoader _loader; + final ValueNotifier> _loader = + ValueNotifier(const AsyncState.idle()); @override void initState() { super.initState(); - _reload(); + _load(); + widget.reloadNotifier?.addListener(_load); } @override @@ -64,27 +48,24 @@ class PhoneticTranscriptionBuilderState super.didUpdateWidget(oldWidget); if (oldWidget.text != widget.text || oldWidget.textLanguage != widget.textLanguage) { - _loader.dispose(); - _reload(); + _load(); } } @override void dispose() { + widget.reloadNotifier?.removeListener(_load); _loader.dispose(); super.dispose(); } - bool get isLoading => _loader.isLoading; - bool get isError => _loader.isError; + AsyncState get state => _loader.value; + bool get isError => _loader.value is AsyncError; + bool get isLoaded => _loader.value is AsyncLoaded; + String? get transcription => + isLoaded ? (_loader.value as AsyncLoaded).value : null; - Object? get error => - isError ? (_loader.state.value as AsyncError).error : null; - - String? get transcription => _loader.value; - - PhoneticTranscriptionRequest get _transcriptRequest => - PhoneticTranscriptionRequest( + PhoneticTranscriptionRequest get _request => PhoneticTranscriptionRequest( arc: LanguageArc( l1: MatrixState.pangeaController.userController.userL1!, l2: widget.textLanguage, @@ -92,15 +73,26 @@ class PhoneticTranscriptionBuilderState content: PangeaTokenText.fromString(widget.text), ); - void _reload() { - _loader = _TranscriptLoader(_transcriptRequest); - _loader.load(); + Future _load() async { + _loader.value = const AsyncState.loading(); + final resp = await PhoneticTranscriptionRepo.get( + MatrixState.pangeaController.userController.accessToken, + _request, + ); + + if (!mounted) return; + resp.isError + ? _loader.value = AsyncState.error(resp.asError!.error) + : _loader.value = AsyncState.loaded( + resp.asValue!.value.phoneticTranscriptionResult + .phoneticTranscription.first.phoneticL1Transcription.content, + ); } @override Widget build(BuildContext context) { return ValueListenableBuilder( - valueListenable: _loader.state, + valueListenable: _loader, builder: (context, _, __) => widget.builder( context, this, diff --git a/lib/pangea/phonetic_transcription/phonetic_transcription_repo.dart b/lib/pangea/phonetic_transcription/phonetic_transcription_repo.dart index d42728437..d7df0b778 100644 --- a/lib/pangea/phonetic_transcription/phonetic_transcription_repo.dart +++ b/lib/pangea/phonetic_transcription/phonetic_transcription_repo.dart @@ -12,20 +12,47 @@ import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_request.dart'; import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_response.dart'; -class _PhoneticTranscriptionCacheItem { +class _PhoneticTranscriptionMemoryCacheItem { final Future> resultFuture; final DateTime timestamp; - const _PhoneticTranscriptionCacheItem({ + const _PhoneticTranscriptionMemoryCacheItem({ required this.resultFuture, required this.timestamp, }); } +class _PhoneticTranscriptionStorageCacheItem { + final PhoneticTranscriptionResponse response; + final DateTime timestamp; + + const _PhoneticTranscriptionStorageCacheItem({ + required this.response, + required this.timestamp, + }); + + Map toJson() { + return { + 'response': response.toJson(), + 'timestamp': timestamp.toIso8601String(), + }; + } + + static _PhoneticTranscriptionStorageCacheItem fromJson( + Map json, + ) { + return _PhoneticTranscriptionStorageCacheItem( + response: PhoneticTranscriptionResponse.fromJson(json['response']), + timestamp: DateTime.parse(json['timestamp']), + ); + } +} + class PhoneticTranscriptionRepo { // In-memory cache - static final Map _cache = {}; + static final Map _cache = {}; static const Duration _cacheDuration = Duration(minutes: 10); + static const Duration _storageDuration = Duration(days: 7); // Persistent storage static final GetStorage _storage = @@ -53,7 +80,7 @@ class PhoneticTranscriptionRepo { final future = _safeFetch(accessToken, request); // 4. Save to in-memory cache - _cache[request.hashCode.toString()] = _PhoneticTranscriptionCacheItem( + _cache[request.hashCode.toString()] = _PhoneticTranscriptionMemoryCacheItem( resultFuture: future, timestamp: DateTime.now(), ); @@ -71,7 +98,11 @@ class PhoneticTranscriptionRepo { await GetStorage.init('phonetic_transcription_storage'); final key = request.hashCode.toString(); try { - await _storage.write(key, resultFuture.toJson()); + final item = _PhoneticTranscriptionStorageCacheItem( + response: resultFuture, + timestamp: DateTime.now(), + ); + await _storage.write(key, item.toJson()); _cache.remove(key); // Invalidate in-memory cache } catch (e, s) { ErrorHandler.logError( @@ -154,7 +185,12 @@ class PhoneticTranscriptionRepo { final entry = _storage.read(key); if (entry == null) return null; - return PhoneticTranscriptionResponse.fromJson(entry); + final item = _PhoneticTranscriptionStorageCacheItem.fromJson(entry); + if (DateTime.now().difference(item.timestamp) >= _storageDuration) { + _storage.remove(key); + return null; + } + return item.response; } catch (e, s) { ErrorHandler.logError( e: e, diff --git a/lib/pangea/phonetic_transcription/phonetic_transcription_widget.dart b/lib/pangea/phonetic_transcription/phonetic_transcription_widget.dart index 7736c13b9..8f5737125 100644 --- a/lib/pangea/phonetic_transcription/phonetic_transcription_widget.dart +++ b/lib/pangea/phonetic_transcription/phonetic_transcription_widget.dart @@ -1,10 +1,9 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart'; import 'package:fluffychat/pangea/common/network/requests.dart'; +import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; import 'package:fluffychat/pangea/languages/language_model.dart'; import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_builder.dart'; @@ -22,6 +21,7 @@ class PhoneticTranscriptionWidget extends StatefulWidget { final int? maxLines; final VoidCallback? onTranscriptionFetched; + final ValueNotifier? reloadNotifier; const PhoneticTranscriptionWidget({ super.key, @@ -32,6 +32,7 @@ class PhoneticTranscriptionWidget extends StatefulWidget { this.iconColor, this.maxLines, this.onTranscriptionFetched, + this.reloadNotifier, }); @override @@ -68,77 +69,75 @@ class _PhoneticTranscriptionWidgetState final targetId = 'phonetic-transcription-${widget.text}-$hashCode'; return HoverBuilder( builder: (context, hovering) { - return GestureDetector( - onTap: () => _handleAudioTap(targetId), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - decoration: BoxDecoration( - color: hovering - ? Colors.grey.withAlpha((0.2 * 255).round()) - : Colors.transparent, - borderRadius: BorderRadius.circular(6), - ), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: CompositedTransformTarget( - link: MatrixState.pAnyState.layerLinkAndKey(targetId).link, - child: PhoneticTranscriptionBuilder( - key: MatrixState.pAnyState.layerLinkAndKey(targetId).key, - textLanguage: widget.textLanguage, - text: widget.text, - builder: (context, controller) { - if (controller.isError) { - return controller.error is UnsubscribedException - ? ErrorIndicator( - message: L10n.of(context) - .subscribeToUnlockTranscriptions, - onTap: () { - MatrixState - .pangeaController.subscriptionController - .showPaywall(context); - }, - ) - : ErrorIndicator( - message: - L10n.of(context).failedToFetchTranscription, - ); - } - - if (controller.isLoading || - controller.transcription == null) { - return const TextLoadingShimmer( - width: 125.0, - height: 20.0, - ); - } - - return Row( - spacing: 8.0, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - controller.transcription!, - textScaler: TextScaler.noScaling, - style: widget.style ?? - Theme.of(context).textTheme.bodyMedium, - maxLines: widget.maxLines, - overflow: TextOverflow.ellipsis, + return Tooltip( + message: + _isPlaying ? L10n.of(context).stop : L10n.of(context).playAudio, + child: GestureDetector( + onTap: () => _handleAudioTap(targetId), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: hovering + ? Colors.grey.withAlpha((0.2 * 255).round()) + : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: CompositedTransformTarget( + link: MatrixState.pAnyState.layerLinkAndKey(targetId).link, + child: PhoneticTranscriptionBuilder( + key: MatrixState.pAnyState.layerLinkAndKey(targetId).key, + textLanguage: widget.textLanguage, + text: widget.text, + reloadNotifier: widget.reloadNotifier, + builder: (context, controller) { + return switch (controller.state) { + AsyncError(error: final error) => + error is UnsubscribedException + ? ErrorIndicator( + message: L10n.of(context) + .subscribeToUnlockTranscriptions, + onTap: () { + MatrixState + .pangeaController.subscriptionController + .showPaywall(context); + }, + ) + : ErrorIndicator( + message: + L10n.of(context).failedToFetchTranscription, + ), + AsyncLoaded(value: final transcription) => Row( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + transcription, + textScaler: TextScaler.noScaling, + style: widget.style ?? + Theme.of(context).textTheme.bodyMedium, + maxLines: widget.maxLines, + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + _isPlaying + ? Icons.pause_outlined + : Icons.volume_up, + size: widget.iconSize ?? 24, + color: widget.iconColor ?? + Theme.of(context).iconTheme.color, + ), + ], ), - ), - Tooltip( - message: _isPlaying - ? L10n.of(context).stop - : L10n.of(context).playAudio, - child: Icon( - _isPlaying ? Icons.pause_outlined : Icons.volume_up, - size: widget.iconSize ?? 24, - color: widget.iconColor ?? - Theme.of(context).iconTheme.color, + _ => const TextLoadingShimmer( + width: 125.0, + height: 20.0, ), - ), - ], - ); - }, + }; + }, + ), ), ), ), diff --git a/lib/pangea/practice_activities/activity_type_enum.dart b/lib/pangea/practice_activities/activity_type_enum.dart index 6a3134f5b..b8e493d9d 100644 --- a/lib/pangea/practice_activities/activity_type_enum.dart +++ b/lib/pangea/practice_activities/activity_type_enum.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; enum ActivityTypeEnum { @@ -13,7 +14,9 @@ enum ActivityTypeEnum { morphId, messageMeaning, lemmaMeaning, - lemmaAudio; + lemmaAudio, + grammarCategory, + grammarError; bool get includeTTSOnClick { switch (this) { @@ -27,6 +30,8 @@ enum ActivityTypeEnum { case ActivityTypeEnum.hiddenWordListening: case ActivityTypeEnum.lemmaAudio: case ActivityTypeEnum.lemmaMeaning: + case ActivityTypeEnum.grammarCategory: + case ActivityTypeEnum.grammarError: return true; } } @@ -62,6 +67,12 @@ enum ActivityTypeEnum { case 'lemma_audio': case 'lemmaAudio': return ActivityTypeEnum.lemmaAudio; + case 'grammar_category': + case 'grammarCategory': + return ActivityTypeEnum.grammarCategory; + case 'grammar_error': + case 'grammarError': + return ActivityTypeEnum.grammarError; default: throw Exception('Unknown activity type: $split'); } @@ -117,6 +128,16 @@ enum ActivityTypeEnum { ConstructUseTypeEnum.corLM, ConstructUseTypeEnum.incLM, ]; + case ActivityTypeEnum.grammarCategory: + return [ + ConstructUseTypeEnum.corGC, + ConstructUseTypeEnum.incGC, + ]; + case ActivityTypeEnum.grammarError: + return [ + ConstructUseTypeEnum.corGE, + ConstructUseTypeEnum.incGE, + ]; } } @@ -140,6 +161,10 @@ enum ActivityTypeEnum { return ConstructUseTypeEnum.corLA; case ActivityTypeEnum.lemmaMeaning: return ConstructUseTypeEnum.corLM; + case ActivityTypeEnum.grammarCategory: + return ConstructUseTypeEnum.corGC; + case ActivityTypeEnum.grammarError: + return ConstructUseTypeEnum.corGE; } } @@ -163,6 +188,10 @@ enum ActivityTypeEnum { return ConstructUseTypeEnum.incLA; case ActivityTypeEnum.lemmaMeaning: return ConstructUseTypeEnum.incLM; + case ActivityTypeEnum.grammarCategory: + return ConstructUseTypeEnum.incGC; + case ActivityTypeEnum.grammarError: + return ConstructUseTypeEnum.incGE; } } @@ -182,6 +211,8 @@ enum ActivityTypeEnum { case ActivityTypeEnum.morphId: return Icons.format_shapes; case ActivityTypeEnum.messageMeaning: + case ActivityTypeEnum.grammarCategory: + case ActivityTypeEnum.grammarError: return Icons.star; // TODO: Add to L10n } } @@ -200,6 +231,8 @@ enum ActivityTypeEnum { case ActivityTypeEnum.messageMeaning: case ActivityTypeEnum.lemmaMeaning: case ActivityTypeEnum.lemmaAudio: + case ActivityTypeEnum.grammarCategory: + case ActivityTypeEnum.grammarError: return 1; } } @@ -210,4 +243,25 @@ enum ActivityTypeEnum { ActivityTypeEnum.wordFocusListening, ActivityTypeEnum.morphId, ]; + + static List get _vocabPracticeTypes => [ + ActivityTypeEnum.lemmaMeaning, + // ActivityTypeEnum.lemmaAudio, + ]; + + static List get _grammarPracticeTypes => [ + ActivityTypeEnum.grammarCategory, + ActivityTypeEnum.grammarError, + ]; + + static List analyticsPracticeTypes( + ConstructTypeEnum constructType, + ) { + switch (constructType) { + case ConstructTypeEnum.vocab: + return _vocabPracticeTypes; + case ConstructTypeEnum.morph: + return _grammarPracticeTypes; + } + } } diff --git a/lib/pangea/practice_activities/emoji_activity_generator.dart b/lib/pangea/practice_activities/emoji_activity_generator.dart index 2bb790379..5b8881a5a 100644 --- a/lib/pangea/practice_activities/emoji_activity_generator.dart +++ b/lib/pangea/practice_activities/emoji_activity_generator.dart @@ -3,7 +3,6 @@ import 'package:async/async.dart'; import 'package:fluffychat/pangea/constructs/construct_form.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.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_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_match.dart'; @@ -13,7 +12,7 @@ class EmojiActivityGenerator { MessageActivityRequest req, { required Map messageInfo, }) async { - if (req.targetTokens.length <= 1) { + if (req.target.tokens.length <= 1) { throw Exception("Emoji activity requires at least 2 tokens"); } @@ -28,7 +27,7 @@ class EmojiActivityGenerator { final List missingEmojis = []; final List usedEmojis = []; - for (final token in req.targetTokens) { + for (final token in req.target.tokens) { final userSavedEmoji = token.vocabConstructID.userSetEmoji; if (userSavedEmoji != null && !usedEmojis.contains(userSavedEmoji)) { matchInfo[token.vocabForm] = [userSavedEmoji]; @@ -65,9 +64,8 @@ class EmojiActivityGenerator { } return MessageActivityResponse( - activity: PracticeActivityModel( - activityType: ActivityTypeEnum.emoji, - targetTokens: req.targetTokens, + activity: EmojiPracticeActivityModel( + tokens: req.target.tokens, langCode: req.userL2, matchContent: PracticeMatchActivity( matchInfo: matchInfo, diff --git a/lib/pangea/practice_activities/lemma_activity_generator.dart b/lib/pangea/practice_activities/lemma_activity_generator.dart index dafef270c..83ff095de 100644 --- a/lib/pangea/practice_activities/lemma_activity_generator.dart +++ b/lib/pangea/practice_activities/lemma_activity_generator.dart @@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.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/multiple_choice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; @@ -15,16 +14,15 @@ class LemmaActivityGenerator { static Future get( MessageActivityRequest req, ) async { - debugger(when: kDebugMode && req.targetTokens.length != 1); + debugger(when: kDebugMode && req.target.tokens.length != 1); - final token = req.targetTokens.first; + final token = req.target.tokens.first; final choices = await lemmaActivityDistractors(token); // TODO - modify MultipleChoiceActivity flow to allow no correct answer return MessageActivityResponse( - activity: PracticeActivityModel( - activityType: ActivityTypeEnum.lemmaId, - targetTokens: [token], + activity: LemmaPracticeActivityModel( + tokens: req.target.tokens, langCode: req.userL2, multipleChoiceContent: MultipleChoiceActivity( choices: choices.map((c) => c.lemma).toSet(), diff --git a/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart b/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart index 274334fb3..3e32611b0 100644 --- a/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart +++ b/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart @@ -4,7 +4,6 @@ import 'package:async/async.dart'; import 'package:fluffychat/pangea/constructs/construct_form.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.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_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_match.dart'; @@ -16,7 +15,7 @@ class LemmaMeaningActivityGenerator { required Map messageInfo, }) async { final List>> lemmaInfoFutures = req - .targetTokens + .target.tokens .map((token) => token.vocabConstructID.getLemmaInfo(messageInfo)) .toList(); @@ -28,14 +27,13 @@ class LemmaMeaningActivityGenerator { } final Map> matchInfo = Map.fromIterables( - req.targetTokens.map((token) => token.vocabForm), + req.target.tokens.map((token) => token.vocabForm), lemmaInfos.map((lemmaInfo) => [lemmaInfo.asValue!.value.meaning]), ); return MessageActivityResponse( - activity: PracticeActivityModel( - activityType: ActivityTypeEnum.wordMeaning, - targetTokens: req.targetTokens, + activity: LemmaMeaningPracticeActivityModel( + tokens: req.target.tokens, langCode: req.userL2, matchContent: PracticeMatchActivity( matchInfo: matchInfo, diff --git a/lib/pangea/practice_activities/message_activity_request.dart b/lib/pangea/practice_activities/message_activity_request.dart index 1200440b1..ec6a9e491 100644 --- a/lib/pangea/practice_activities/message_activity_request.dart +++ b/lib/pangea/practice_activities/message_activity_request.dart @@ -1,10 +1,14 @@ -import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_model.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'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; // includes feedback text and the bad activity model class ActivityQualityFeedback { @@ -16,15 +20,6 @@ class ActivityQualityFeedback { required this.badActivity, }); - factory ActivityQualityFeedback.fromJson(Map json) { - return ActivityQualityFeedback( - feedbackText: json['feedback_text'] as String, - badActivity: PracticeActivityModel.fromJson( - json['bad_activity'] as Map, - ), - ); - } - Map toJson() { return { 'feedback_text': feedbackText, @@ -47,37 +42,82 @@ class ActivityQualityFeedback { } } +class GrammarErrorRequestInfo { + final ChoreoRecordModel choreo; + final int stepIndex; + final String eventID; + final String translation; + + const GrammarErrorRequestInfo({ + required this.choreo, + required this.stepIndex, + required this.eventID, + required this.translation, + }); + + Map toJson() { + return { + 'choreo': choreo.toJson(), + 'step_index': stepIndex, + 'event_id': eventID, + 'translation': translation, + }; + } + + factory GrammarErrorRequestInfo.fromJson(Map json) { + return GrammarErrorRequestInfo( + choreo: ChoreoRecordModel.fromJson(json['choreo']), + stepIndex: json['step_index'] as int, + eventID: json['event_id'] as String, + translation: json['translation'] as String, + ); + } +} + class MessageActivityRequest { final String userL1; final String userL2; - - final List targetTokens; - final ActivityTypeEnum targetType; - final MorphFeaturesEnum? targetMorphFeature; - + final PracticeTarget target; final ActivityQualityFeedback? activityQualityFeedback; + final GrammarErrorRequestInfo? grammarErrorInfo; + final MorphExampleInfo? morphExampleInfo; MessageActivityRequest({ required this.userL1, required this.userL2, required this.activityQualityFeedback, - required this.targetTokens, - required this.targetType, - required this.targetMorphFeature, + required this.target, + this.grammarErrorInfo, + this.morphExampleInfo, }) { - if (targetTokens.isEmpty) { + if (target.tokens.isEmpty) { throw Exception('Target tokens must not be empty'); } } + String promptText(BuildContext context) { + switch (target.activityType) { + case ActivityTypeEnum.grammarCategory: + return L10n.of(context).whatIsTheMorphTag( + target.morphFeature!.getDisplayCopy(context), + target.tokens.first.text.content, + ); + case ActivityTypeEnum.grammarError: + return L10n.of(context).fillInBlank; + default: + return target.tokens.first.vocabConstructID.lemma; + } + } + Map toJson() { return { 'user_l1': userL1, 'user_l2': userL2, 'activity_quality_feedback': activityQualityFeedback?.toJson(), - 'target_tokens': targetTokens.map((e) => e.toJson()).toList(), - 'target_type': targetType.name, - 'target_morph_feature': targetMorphFeature, + 'target_tokens': target.tokens.map((e) => e.toJson()).toList(), + 'target_type': target.activityType.name, + 'target_morph_feature': target.morphFeature, + 'grammar_error_info': grammarErrorInfo?.toJson(), }; } @@ -86,19 +126,21 @@ class MessageActivityRequest { if (identical(this, other)) return true; return other is MessageActivityRequest && - other.targetType == targetType && + other.userL1 == userL1 && + other.userL2 == userL2 && + other.target == target && other.activityQualityFeedback?.feedbackText == activityQualityFeedback?.feedbackText && - const ListEquality().equals(other.targetTokens, targetTokens) && - other.targetMorphFeature == targetMorphFeature; + other.grammarErrorInfo == grammarErrorInfo; } @override int get hashCode { - return targetType.hashCode ^ - activityQualityFeedback.hashCode ^ - targetTokens.hashCode ^ - targetMorphFeature.hashCode; + return activityQualityFeedback.hashCode ^ + target.hashCode ^ + userL1.hashCode ^ + userL2.hashCode ^ + grammarErrorInfo.hashCode; } } diff --git a/lib/pangea/practice_activities/morph_activity_generator.dart b/lib/pangea/practice_activities/morph_activity_generator.dart index aff0eec88..0c2e2bf5b 100644 --- a/lib/pangea/practice_activities/morph_activity_generator.dart +++ b/lib/pangea/practice_activities/morph_activity_generator.dart @@ -4,7 +4,6 @@ import 'package:flutter/foundation.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'; import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; @@ -18,13 +17,13 @@ class MorphActivityGenerator { static MessageActivityResponse get( MessageActivityRequest req, ) { - debugger(when: kDebugMode && req.targetTokens.length != 1); + debugger(when: kDebugMode && req.target.tokens.length != 1); - debugger(when: kDebugMode && req.targetMorphFeature == null); + debugger(when: kDebugMode && req.target.morphFeature == null); - final PangeaToken token = req.targetTokens.first; + final PangeaToken token = req.target.tokens.first; - final MorphFeaturesEnum morphFeature = req.targetMorphFeature!; + final MorphFeaturesEnum morphFeature = req.target.morphFeature!; final String? morphTag = token.getMorphTag(morphFeature); if (morphTag == null) { @@ -38,11 +37,10 @@ class MorphActivityGenerator { debugger(when: kDebugMode && distractors.length < 3); return MessageActivityResponse( - activity: PracticeActivityModel( - targetTokens: req.targetTokens, + activity: MorphMatchPracticeActivityModel( + tokens: req.target.tokens, langCode: req.userL2, - activityType: ActivityTypeEnum.morphId, - morphFeature: req.targetMorphFeature, + morphFeature: morphFeature, multipleChoiceContent: MultipleChoiceActivity( choices: distractors, answers: {morphTag}, diff --git a/lib/pangea/practice_activities/practice_activity_model.dart b/lib/pangea/practice_activities/practice_activity_model.dart index d884e1315..eddbdacf1 100644 --- a/lib/pangea/practice_activities/practice_activity_model.dart +++ b/lib/pangea/practice_activities/practice_activity_model.dart @@ -1,152 +1,60 @@ -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; - -import 'package:collection/collection.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.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'; import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; import 'package:fluffychat/pangea/practice_activities/practice_match.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; -class PracticeActivityModel { - final List targetTokens; - final ActivityTypeEnum activityType; - final MorphFeaturesEnum? morphFeature; - +sealed class PracticeActivityModel { + final List tokens; final String langCode; - final MultipleChoiceActivity? multipleChoiceContent; - final PracticeMatchActivity? matchContent; - - PracticeActivityModel({ - required this.targetTokens, + const PracticeActivityModel({ + required this.tokens, required this.langCode, - required this.activityType, - this.morphFeature, - this.multipleChoiceContent, - this.matchContent, - }) { - if (matchContent == null && multipleChoiceContent == null) { - debugger(when: kDebugMode); - throw ("both matchContent and multipleChoiceContent are null in PracticeActivityModel"); - } - if (matchContent != null && multipleChoiceContent != null) { - debugger(when: kDebugMode); - throw ("both matchContent and multipleChoiceContent are not null in PracticeActivityModel"); - } - if (activityType == ActivityTypeEnum.morphId && morphFeature == null) { - debugger(when: kDebugMode); - throw ("morphFeature is null in PracticeActivityModel"); - } - } + }); + + String get storageKey => + '${activityType.name}-${tokens.map((e) => e.text.content).join("-")}'; PracticeTarget get practiceTarget => PracticeTarget( - tokens: targetTokens, activityType: activityType, - morphFeature: morphFeature, + tokens: tokens, + morphFeature: this is MorphPracticeActivityModel + ? (this as MorphPracticeActivityModel).morphFeature + : null, ); - bool onMultipleChoiceSelect( - ConstructIdentifier choiceConstruct, - String choice, - ) { - if (multipleChoiceContent == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "in onMultipleChoiceSelect with null multipleChoiceContent", - s: StackTrace.current, - data: toJson(), - ); - return false; + ActivityTypeEnum get activityType { + switch (this) { + case MorphCategoryPracticeActivityModel(): + return ActivityTypeEnum.grammarCategory; + case VocabAudioPracticeActivityModel(): + return ActivityTypeEnum.lemmaAudio; + case VocabMeaningPracticeActivityModel(): + return ActivityTypeEnum.lemmaMeaning; + case EmojiPracticeActivityModel(): + return ActivityTypeEnum.emoji; + case LemmaPracticeActivityModel(): + return ActivityTypeEnum.lemmaId; + case LemmaMeaningPracticeActivityModel(): + return ActivityTypeEnum.wordMeaning; + case MorphMatchPracticeActivityModel(): + return ActivityTypeEnum.morphId; + case WordListeningPracticeActivityModel(): + return ActivityTypeEnum.wordFocusListening; + case GrammarErrorPracticeActivityModel(): + return ActivityTypeEnum.grammarError; } - - if (practiceTarget.isComplete || - practiceTarget.record.alreadyHasMatchResponse( - choiceConstruct, - choice, - )) { - // the user has already selected this choice - // so we don't want to record it again - return false; - } - - final bool isCorrect = multipleChoiceContent!.isCorrect(choice); - - // NOTE: the response is associated with the contructId of the choice, not the selected token - // example: the user selects the word "cat" to match with the emoji 🐶 - // the response is associated with correct word "dog", not the word "cat" - practiceTarget.record.addResponse( - cId: choiceConstruct, - target: practiceTarget, - text: choice, - score: isCorrect ? 1 : 0, - ); - - return isCorrect; - } - - bool onMatch( - PangeaToken token, - PracticeChoice choice, - ) { - // the user has already selected this choice - // so we don't want to record it again - if (practiceTarget.isComplete || - practiceTarget.record.alreadyHasMatchResponse( - token.vocabConstructID, - choice.choiceContent, - )) { - return false; - } - - bool isCorrect = false; - if (multipleChoiceContent != null) { - isCorrect = multipleChoiceContent!.answers.any( - (answer) => answer.toLowerCase() == choice.choiceContent.toLowerCase(), - ); - } else { - // we check to see if it's in the list of acceptable answers - // rather than if the vocabForm is the same because an emoji - // could be in multiple constructs so there could be multiple answers - final answers = matchContent!.matchInfo[token.vocabForm]; - debugger(when: answers == null && kDebugMode); - isCorrect = answers!.contains(choice.choiceContent); - } - - // NOTE: the response is associated with the contructId of the selected token, not the choice - // example: the user selects the word "cat" to match with the emoji 🐶 - // the response is associated with incorrect word "cat", not the word "dog" - practiceTarget.record.addResponse( - cId: token.vocabConstructID, - target: practiceTarget, - text: choice.choiceContent, - score: isCorrect ? 1 : 0, - ); - - return isCorrect; } factory PracticeActivityModel.fromJson(Map json) { - // moving from multiple_choice to content as the key - // this is to make the model more generic - // here for backward compatibility - final Map? contentMap = - (json['content'] ?? json["multiple_choice"]) as Map?; - - if (contentMap == null) { - Sentry.addBreadcrumb( - Breadcrumb(data: {"json": json}), - ); - throw ("content is null in PracticeActivityModel.fromJson"); - } - if (json['lang_code'] is! String) { Sentry.addBreadcrumb( Breadcrumb(data: {"json": json}), @@ -163,58 +71,366 @@ class PracticeActivityModel { throw ("tgt_constructs is not a list in PracticeActivityModel.fromJson"); } - return PracticeActivityModel( - langCode: json['lang_code'] as String, - activityType: ActivityTypeEnum.fromString(json['activity_type']), - multipleChoiceContent: json['content'] != null - ? MultipleChoiceActivity.fromJson(contentMap) - : null, - targetTokens: (json['target_tokens'] as List) - .map((e) => PangeaToken.fromJson(e as Map)) - .toList(), - matchContent: json['match_content'] != null - ? PracticeMatchActivity.fromJson(contentMap) - : null, - morphFeature: json['morph_feature'] != null - ? MorphFeaturesEnumExtension.fromString( - json['morph_feature'] as String, - ) - : null, - ); + final type = ActivityTypeEnum.fromString(json['activity_type']); + + final morph = json['morph_feature'] != null + ? MorphFeaturesEnumExtension.fromString( + json['morph_feature'] as String, + ) + : null; + + final tokens = (json['target_tokens'] as List) + .map((e) => PangeaToken.fromJson(e as Map)) + .toList(); + + final langCode = json['lang_code'] as String; + + final multipleChoiceContent = json['content'] != null + ? MultipleChoiceActivity.fromJson( + json['content'] as Map, + ) + : null; + + final matchContent = json['match_content'] != null + ? PracticeMatchActivity.fromJson( + json['match_content'] as Map, + ) + : null; + + switch (type) { + case ActivityTypeEnum.grammarCategory: + assert( + morph != null, + "morphFeature is null in PracticeActivityModel.fromJson for grammarCategory", + ); + assert( + multipleChoiceContent != null, + "multipleChoiceContent is null in PracticeActivityModel.fromJson for grammarCategory", + ); + return MorphCategoryPracticeActivityModel( + langCode: langCode, + 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( + multipleChoiceContent != null, + "multipleChoiceContent is null in PracticeActivityModel.fromJson for lemmaAudio", + ); + return VocabAudioPracticeActivityModel( + langCode: langCode, + tokens: tokens, + multipleChoiceContent: multipleChoiceContent!, + ); + case ActivityTypeEnum.lemmaMeaning: + assert( + multipleChoiceContent != null, + "multipleChoiceContent is null in PracticeActivityModel.fromJson for lemmaMeaning", + ); + return VocabMeaningPracticeActivityModel( + langCode: langCode, + tokens: tokens, + multipleChoiceContent: multipleChoiceContent!, + ); + case ActivityTypeEnum.emoji: + assert( + matchContent != null, + "matchContent is null in PracticeActivityModel.fromJson for emoji", + ); + return EmojiPracticeActivityModel( + langCode: langCode, + tokens: tokens, + matchContent: matchContent!, + ); + case ActivityTypeEnum.lemmaId: + assert( + multipleChoiceContent != null, + "multipleChoiceContent is null in PracticeActivityModel.fromJson for lemmaId", + ); + return LemmaPracticeActivityModel( + langCode: langCode, + tokens: tokens, + multipleChoiceContent: multipleChoiceContent!, + ); + case ActivityTypeEnum.wordMeaning: + assert( + matchContent != null, + "matchContent is null in PracticeActivityModel.fromJson for wordMeaning", + ); + return LemmaMeaningPracticeActivityModel( + langCode: langCode, + tokens: tokens, + matchContent: matchContent!, + ); + case ActivityTypeEnum.morphId: + assert( + morph != null, + "morphFeature is null in PracticeActivityModel.fromJson for morphId", + ); + assert( + multipleChoiceContent != null, + "multipleChoiceContent is null in PracticeActivityModel.fromJson for morphId", + ); + return MorphMatchPracticeActivityModel( + langCode: langCode, + tokens: tokens, + morphFeature: morph!, + multipleChoiceContent: multipleChoiceContent!, + ); + case ActivityTypeEnum.wordFocusListening: + assert( + matchContent != null, + "matchContent is null in PracticeActivityModel.fromJson for wordFocusListening", + ); + return WordListeningPracticeActivityModel( + langCode: langCode, + tokens: tokens, + matchContent: matchContent!, + ); + case ActivityTypeEnum.grammarError: + assert( + multipleChoiceContent != null, + "multipleChoiceContent is null in PracticeActivityModel.fromJson for grammarError", + ); + return GrammarErrorPracticeActivityModel( + langCode: langCode, + tokens: tokens, + multipleChoiceContent: multipleChoiceContent!, + text: json['text'] as String, + errorOffset: json['error_offset'] as int, + errorLength: json['error_length'] as int, + eventID: json['event_id'] as String, + translation: json['translation'] as String, + ); + default: + throw ("Unsupported activity type in PracticeActivityModel.fromJson: $type"); + } } Map toJson() { return { 'lang_code': langCode, 'activity_type': activityType.name, - 'content': multipleChoiceContent?.toJson(), - 'target_tokens': targetTokens.map((e) => e.toJson()).toList(), - 'match_content': matchContent?.toJson(), - 'morph_feature': morphFeature?.name, + 'target_tokens': tokens.map((e) => e.toJson()).toList(), }; } +} - // override operator == and hashCode - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; +sealed class MultipleChoicePracticeActivityModel extends PracticeActivityModel { + final MultipleChoiceActivity multipleChoiceContent; - return other is PracticeActivityModel && - const ListEquality().equals(other.targetTokens, targetTokens) && - other.langCode == langCode && - other.activityType == activityType && - other.multipleChoiceContent == multipleChoiceContent && - other.matchContent == matchContent && - other.morphFeature == morphFeature; + MultipleChoicePracticeActivityModel({ + required super.tokens, + required super.langCode, + required this.multipleChoiceContent, + }); + + bool isCorrect(String choice) => multipleChoiceContent.isCorrect(choice); + + OneConstructUse constructUse(String choiceContent) { + final correct = multipleChoiceContent.isCorrect(choiceContent); + final useType = + correct ? activityType.correctUse : activityType.incorrectUse; + final token = tokens.first; + + return OneConstructUse( + useType: useType, + constructType: ConstructTypeEnum.vocab, + metadata: ConstructUseMetaData( + roomId: null, + timeStamp: DateTime.now(), + ), + category: token.pos, + lemma: token.lemma.text, + form: token.lemma.text, + xp: useType.pointValue, + ); } @override - int get hashCode { - return const ListEquality().hash(targetTokens) ^ - langCode.hashCode ^ - activityType.hashCode ^ - multipleChoiceContent.hashCode ^ - matchContent.hashCode ^ - morphFeature.hashCode; + Map toJson() { + final json = super.toJson(); + json['content'] = multipleChoiceContent.toJson(); + return json; } } + +sealed class MatchPracticeActivityModel extends PracticeActivityModel { + final PracticeMatchActivity matchContent; + + MatchPracticeActivityModel({ + required super.tokens, + required super.langCode, + required this.matchContent, + }); + + bool isCorrect( + PangeaToken token, + String choice, + ) => + matchContent.matchInfo[token.vocabForm]!.contains(choice); + + @override + Map toJson() { + final json = super.toJson(); + json['match_content'] = matchContent.toJson(); + return json; + } +} + +sealed class MorphPracticeActivityModel + extends MultipleChoicePracticeActivityModel { + final MorphFeaturesEnum morphFeature; + + MorphPracticeActivityModel({ + required super.tokens, + required super.langCode, + required super.multipleChoiceContent, + required this.morphFeature, + }); + + @override + String get storageKey => + '${activityType.name}-${tokens.map((e) => e.text.content).join("-")}-${morphFeature.name}'; + + @override + Map toJson() { + final json = super.toJson(); + json['morph_feature'] = morphFeature.name; + return json; + } +} + +class MorphCategoryPracticeActivityModel extends MorphPracticeActivityModel { + final MorphExampleInfo morphExampleInfo; + MorphCategoryPracticeActivityModel({ + required super.tokens, + required super.langCode, + required super.morphFeature, + required super.multipleChoiceContent, + required this.morphExampleInfo, + }); + + @override + OneConstructUse constructUse(String choiceContent) { + final correct = multipleChoiceContent.isCorrect(choiceContent); + final token = tokens.first; + final useType = + correct ? activityType.correctUse : activityType.incorrectUse; + final tag = token.getMorphTag(morphFeature)!; + + return OneConstructUse( + useType: useType, + constructType: ConstructTypeEnum.morph, + metadata: ConstructUseMetaData( + roomId: null, + timeStamp: DateTime.now(), + ), + category: morphFeature.name, + lemma: tag, + form: token.lemma.form, + xp: useType.pointValue, + ); + } + + @override + Map toJson() { + final json = super.toJson(); + json['morph_example_info'] = morphExampleInfo.toJson(); + return json; + } +} + +class MorphMatchPracticeActivityModel extends MorphPracticeActivityModel { + MorphMatchPracticeActivityModel({ + required super.tokens, + required super.langCode, + required super.morphFeature, + required super.multipleChoiceContent, + }); +} + +class VocabAudioPracticeActivityModel + extends MultipleChoicePracticeActivityModel { + VocabAudioPracticeActivityModel({ + required super.tokens, + required super.langCode, + required super.multipleChoiceContent, + }); +} + +class VocabMeaningPracticeActivityModel + extends MultipleChoicePracticeActivityModel { + VocabMeaningPracticeActivityModel({ + required super.tokens, + required super.langCode, + required super.multipleChoiceContent, + }); +} + +class LemmaPracticeActivityModel extends MultipleChoicePracticeActivityModel { + LemmaPracticeActivityModel({ + required super.tokens, + required super.langCode, + required super.multipleChoiceContent, + }); +} + +class GrammarErrorPracticeActivityModel + extends MultipleChoicePracticeActivityModel { + final String text; + final int errorOffset; + final int errorLength; + final String eventID; + final String translation; + + GrammarErrorPracticeActivityModel({ + required super.tokens, + required super.langCode, + required super.multipleChoiceContent, + required this.text, + required this.errorOffset, + required this.errorLength, + required this.eventID, + required this.translation, + }); + + @override + Map toJson() { + final json = super.toJson(); + json['text'] = text; + json['error_offset'] = errorOffset; + json['error_length'] = errorLength; + json['event_id'] = eventID; + json['translation'] = translation; + return json; + } +} + +class EmojiPracticeActivityModel extends MatchPracticeActivityModel { + EmojiPracticeActivityModel({ + required super.tokens, + required super.langCode, + required super.matchContent, + }); +} + +class LemmaMeaningPracticeActivityModel extends MatchPracticeActivityModel { + LemmaMeaningPracticeActivityModel({ + required super.tokens, + required super.langCode, + required super.matchContent, + }); +} + +class WordListeningPracticeActivityModel extends MatchPracticeActivityModel { + WordListeningPracticeActivityModel({ + required super.tokens, + required super.langCode, + required super.matchContent, + }); +} diff --git a/lib/pangea/practice_activities/practice_generation_repo.dart b/lib/pangea/practice_activities/practice_generation_repo.dart index 118f01a9e..35a961940 100644 --- a/lib/pangea/practice_activities/practice_generation_repo.dart +++ b/lib/pangea/practice_activities/practice_generation_repo.dart @@ -9,6 +9,10 @@ import 'package:async/async.dart'; import 'package:get_storage/get_storage.dart'; import 'package:http/http.dart'; +import 'package:fluffychat/pangea/analytics_practice/grammar_error_practice_generator.dart'; +import 'package:fluffychat/pangea/analytics_practice/morph_category_activity_generator.dart'; +import 'package:fluffychat/pangea/analytics_practice/vocab_audio_activity_generator.dart'; +import 'package:fluffychat/pangea/analytics_practice/vocab_meaning_activity_generator.dart'; import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/network/requests.dart'; import 'package:fluffychat/pangea/common/network/urls.dart'; @@ -21,8 +25,6 @@ import 'package:fluffychat/pangea/practice_activities/message_activity_request.d import 'package:fluffychat/pangea/practice_activities/morph_activity_generator.dart'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/word_focus_listening_generator.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_audio_activity_generator.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_meaning_activity_generator.dart'; import 'package:fluffychat/widgets/matrix.dart'; /// Represents an item in the completion cache. @@ -116,7 +118,7 @@ class PracticeRepo { required Map messageInfo, }) async { // some activities we'll get from the server and others we'll generate locally - switch (req.targetType) { + switch (req.target.activityType) { case ActivityTypeEnum.emoji: return EmojiActivityGenerator.get(req, messageInfo: messageInfo); case ActivityTypeEnum.lemmaId: @@ -125,6 +127,14 @@ class PracticeRepo { return VocabMeaningActivityGenerator.get(req); case ActivityTypeEnum.lemmaAudio: return VocabAudioActivityGenerator.get(req); + case ActivityTypeEnum.grammarCategory: + return MorphCategoryActivityGenerator.get(req); + case ActivityTypeEnum.grammarError: + assert( + req.grammarErrorInfo != null, + 'Grammar error info must be provided for grammar error activities', + ); + return GrammarErrorPracticeGenerator.get(req); case ActivityTypeEnum.morphId: return MorphActivityGenerator.get(req); case ActivityTypeEnum.wordMeaning: diff --git a/lib/pangea/practice_activities/practice_target.dart b/lib/pangea/practice_activities/practice_target.dart index 00bfcd671..87c1ce7e7 100644 --- a/lib/pangea/practice_activities/practice_target.dart +++ b/lib/pangea/practice_activities/practice_target.dart @@ -1,17 +1,11 @@ -import 'dart:developer'; - import 'package:flutter/foundation.dart'; import 'package:collection/collection.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.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'; -import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_record.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_record_repo.dart'; /// Picks which tokens to do activities on and what types of activities to do /// Caches result so that we don't have to recompute it @@ -89,79 +83,11 @@ class PracticeTarget { (morphFeature?.name ?? ""); } - PracticeRecord get record => PracticeRecordRepo.get(this); - - bool get isComplete { - if (activityType == ActivityTypeEnum.morphId) { - return record.completeResponses > 0; - } - - return tokens.every( - (t) => record.responses - .any((res) => res.cId == t.vocabConstructID && res.isCorrect), - ); - } - - bool isCompleteByToken(PangeaToken token, [MorphFeaturesEnum? morph]) { - final ConstructIdentifier? cId = - morph == null ? token.vocabConstructID : token.morphIdByFeature(morph); - if (cId == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "isCompleteByToken: cId is null for token ${token.text.content}", - data: { - "t": token.toJson(), - "morph": morph?.name, - }, - ); - return false; - } - - if (activityType == ActivityTypeEnum.morphId) { - return record.responses.any( - (res) => res.cId == token.morphIdByFeature(morph!) && res.isCorrect, - ); - } - - return record.responses.any( - (res) => res.cId == token.vocabConstructID && res.isCorrect, - ); - } - - bool? wasCorrectChoice(String choice) { - for (final response in record.responses) { - if (response.text == choice) { - return response.isCorrect; - } - } - return null; - } - - /// if any of the choices were correct, return true - /// if all of the choices were incorrect, return false - /// if null, it means the user has not yet responded with that choice - bool? wasCorrectMatch(PracticeChoice choice) { - for (final response in record.responses) { - if (response.text == choice.choiceContent && response.isCorrect) { - return true; - } - } - for (final response in record.responses) { - if (response.text == choice.choiceContent) { - return false; - } - } - return null; - } - - bool get hasAnyResponses => record.responses.isNotEmpty; - - bool get hasAnyCorrectChoices { - for (final response in record.responses) { - if (response.isCorrect) { - return true; - } - } - return false; + ConstructIdentifier targetTokenConstructID(PangeaToken token) { + final defaultID = token.vocabConstructID; + final ConstructIdentifier? cId = morphFeature == null + ? defaultID + : token.morphIdByFeature(morphFeature!); + return cId ?? defaultID; } } diff --git a/lib/pangea/practice_activities/word_focus_listening_generator.dart b/lib/pangea/practice_activities/word_focus_listening_generator.dart index 3d03a37ba..0511b6fdb 100644 --- a/lib/pangea/practice_activities/word_focus_listening_generator.dart +++ b/lib/pangea/practice_activities/word_focus_listening_generator.dart @@ -1,5 +1,4 @@ import 'package:fluffychat/pangea/constructs/construct_form.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_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_match.dart'; @@ -8,20 +7,19 @@ class WordFocusListeningGenerator { static MessageActivityResponse get( MessageActivityRequest req, ) { - if (req.targetTokens.length <= 1) { + if (req.target.tokens.length <= 1) { throw Exception( "Word focus listening activity requires at least 2 tokens", ); } return MessageActivityResponse( - activity: PracticeActivityModel( - activityType: ActivityTypeEnum.wordFocusListening, - targetTokens: req.targetTokens, + activity: WordListeningPracticeActivityModel( + tokens: req.target.tokens, langCode: req.userL2, matchContent: PracticeMatchActivity( matchInfo: Map.fromEntries( - req.targetTokens.map( + req.target.tokens.map( (token) => MapEntry( ConstructForm( cId: token.vocabConstructID, diff --git a/lib/pangea/space_analytics/analytics_request_indicator.dart b/lib/pangea/space_analytics/analytics_request_indicator.dart index 060403cdd..6b9d854a8 100644 --- a/lib/pangea/space_analytics/analytics_request_indicator.dart +++ b/lib/pangea/space_analytics/analytics_request_indicator.dart @@ -8,6 +8,7 @@ import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/space_analytics/space_analytics_requested_dialog.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; @@ -25,48 +26,88 @@ class AnalyticsRequestIndicator extends StatefulWidget { class AnalyticsRequestIndicatorState extends State { AnalyticsRequestIndicatorState(); - - final Map> _knockingAdmins = {}; + StreamSubscription? _analyticsRoomSub; @override void initState() { super.initState(); - _fetchKnockingAdmins(); + _init(); } @override void didUpdateWidget(covariant AnalyticsRequestIndicator oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.room.id != widget.room.id) { - _fetchKnockingAdmins(); + _init(); } } - Future _fetchKnockingAdmins() async { - setState(() => _knockingAdmins.clear()); + @override + void dispose() { + _analyticsRoomSub?.cancel(); + super.dispose(); + } - final admins = (await widget.room.requestParticipants( - [Membership.join, Membership.invite, Membership.knock], - false, - true, - )) - .where((u) => u.powerLevel >= 100); + Future _init() async { + final analyticsRooms = widget.room.client.allMyAnalyticsRooms; + final futures = analyticsRooms.map( + (r) => r.requestParticipants( + [Membership.join, Membership.invite, Membership.knock], + false, + true, + ), + ); + await Future.wait(futures); - for (final analyticsRoom in widget.room.client.allMyAnalyticsRooms) { - final knocking = - await analyticsRoom.requestParticipants([Membership.knock]); - if (knocking.isEmpty) continue; + final analyticsRoomIds = analyticsRooms.map((r) => r.id).toSet(); + _analyticsRoomSub?.cancel(); + _analyticsRoomSub = widget.room.client.onSync.stream.listen((update) async { + final joined = update.rooms?.join?.entries + .where((e) => analyticsRoomIds.contains(e.key)); - for (final admin in admins) { - if (knocking.any((u) => u.id == admin.id)) { - _knockingAdmins.putIfAbsent(admin, () => []).add(analyticsRoom); + if (joined == null || joined.isEmpty) return; + final Set updatedRoomIds = {}; + for (final entry in joined) { + final memberEvents = entry.value.timeline?.events?.where( + (e) => e.type == EventTypes.RoomMember, + ); + if (memberEvents != null && memberEvents.isNotEmpty) { + updatedRoomIds.add(entry.key); } } + + if (updatedRoomIds.isEmpty) return; + for (final roomId in updatedRoomIds) { + final room = widget.room.client.getRoomById(roomId); + if (room == null) continue; + await room.requestParticipants( + [Membership.join, Membership.invite, Membership.knock], + false, + true, + ); + } + + if (mounted) { + setState(() {}); + } + }); + } + + Map> get _knockingAdmins { + final Map> knockingAdmins = {}; + for (final analyticsRoom in widget.room.client.allMyAnalyticsRooms) { + final knocking = analyticsRoom + .getParticipants([Membership.knock]) + .where((u) => u.content['reason'] == widget.room.id) + .toList(); + + if (knocking.isEmpty) continue; + for (final admin in knocking) { + knockingAdmins.putIfAbsent(admin, () => []).add(analyticsRoom); + } } - if (mounted) { - setState(() {}); - } + return knockingAdmins; } Future _onTap(BuildContext context) async { @@ -91,15 +132,20 @@ class AnalyticsRequestIndicatorState extends State { final rooms = entry.value; final List futures = rooms - .map((room) => resp ? room.invite(user.id) : room.kick(user.id)) + .map( + (room) => resp + ? room.invite( + user.id, + reason: PangeaEventTypes.analyticsInviteContent, + ) + : room.kick(user.id), + ) .toList(); await Future.wait(futures); } }, ); - - if (mounted) _fetchKnockingAdmins(); } @override diff --git a/lib/pangea/space_analytics/analytics_requests_repo.dart b/lib/pangea/space_analytics/analytics_requests_repo.dart index ef6bfe1a5..2c3b39ffd 100644 --- a/lib/pangea/space_analytics/analytics_requests_repo.dart +++ b/lib/pangea/space_analytics/analytics_requests_repo.dart @@ -94,4 +94,8 @@ class AnalyticsRequestsRepo { final key = _storageKey(userId, language); await _requestStorage.remove(key); } + + static Future clear() async { + await _requestStorage.erase(); + } } diff --git a/lib/pangea/space_analytics/download_space_analytics_dialog.dart b/lib/pangea/space_analytics/download_space_analytics_dialog.dart index cb89a6241..5a783a918 100644 --- a/lib/pangea/space_analytics/download_space_analytics_dialog.dart +++ b/lib/pangea/space_analytics/download_space_analytics_dialog.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:csv/csv.dart'; @@ -83,7 +84,7 @@ class DownloadAnalyticsDialogState extends State { String? get _statusText { if (_downloading) return L10n.of(context).downloading; - if (_downloaded) return L10n.of(context).downloadComplete; + if (_downloaded) return L10n.of(context).downloadInitiated; return null; } @@ -405,6 +406,21 @@ class DownloadAnalyticsDialogState extends State { ) : const SizedBox(), ), + AnimatedSize( + duration: FluffyThemes.animationDuration, + child: kIsWeb && _downloaded + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + L10n.of(context).webDownloadPermissionMessage, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).disabledColor, + ), + ), + ) + : const SizedBox(), + ), AnimatedSize( duration: FluffyThemes.animationDuration, child: _error != null diff --git a/lib/pangea/space_analytics/space_analytics.dart b/lib/pangea/space_analytics/space_analytics.dart index 0bf8832ae..5db3b2248 100644 --- a/lib/pangea/space_analytics/space_analytics.dart +++ b/lib/pangea/space_analytics/space_analytics.dart @@ -189,6 +189,7 @@ class SpaceAnalyticsState extends State { Future refresh() async { if (room == null || !room!.isSpace || selectedLanguage == null) return; + await AnalyticsRequestsRepo.clear(); setState(() { downloads = Map.fromEntries( @@ -296,6 +297,7 @@ class SpaceAnalyticsState extends State { (child) => child.roomId == roomId, ) ?.via, + reason: widget.roomId, ); status = RequestStatus.requested; } catch (e) { diff --git a/lib/pangea/space_analytics/space_analytics_view.dart b/lib/pangea/space_analytics/space_analytics_view.dart index 23e9fe7e5..686ecdfe2 100644 --- a/lib/pangea/space_analytics/space_analytics_view.dart +++ b/lib/pangea/space_analytics/space_analytics_view.dart @@ -510,33 +510,36 @@ class _RequestButton extends StatelessWidget { cursor: status.enabled ? SystemMouseCursors.click : MouseCursor.defer, child: GestureDetector( onTap: status.enabled ? onPressed : null, - child: Opacity( - opacity: status.enabled ? 0.9 : 0.3, - child: Container( - padding: EdgeInsets.symmetric( - horizontal: mini ? 4.0 : 8.0, - vertical: 4.0, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(40), - color: status.backgroundColor(context), - ), - child: FittedBox( - fit: BoxFit.fitWidth, - child: Row( - spacing: mini ? 2.0 : 8.0, - mainAxisSize: MainAxisSize.min, - children: [ - if (status.icon != null) - Icon( - status.icon, - size: !mini ? 12.0 : 8.0, + child: Tooltip( + message: status.label(context), + child: Opacity( + opacity: status.enabled ? 0.9 : 0.3, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: mini ? 4.0 : 8.0, + vertical: 4.0, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(40), + color: status.backgroundColor(context), + ), + child: FittedBox( + fit: BoxFit.fitWidth, + child: Row( + spacing: mini ? 2.0 : 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + if (status.icon != null) + Icon( + status.icon, + size: !mini ? 12.0 : 8.0, + ), + Text( + status.label(context), + style: TextStyle(fontSize: !mini ? 12.0 : 8.0), ), - Text( - status.label(context), - style: TextStyle(fontSize: !mini ? 12.0 : 8.0), - ), - ], + ], + ), ), ), ), diff --git a/lib/pangea/spaces/space_constants.dart b/lib/pangea/spaces/space_constants.dart index f6315356f..a387fbcbf 100644 --- a/lib/pangea/spaces/space_constants.dart +++ b/lib/pangea/spaces/space_constants.dart @@ -5,6 +5,7 @@ class SpaceConstants { static const String classCode = 'classcode'; static const String introductionChatAlias = 'introductionChat'; static const String announcementsChatAlias = 'announcementsChat'; + static String mapUnlockFileName = "unlock_trip.svg"; static List introChatIcons = [ '${AppConfig.assetsBaseURL}/Introduction_1.jpg', diff --git a/lib/pangea/spaces/space_navigation_column.dart b/lib/pangea/spaces/space_navigation_column.dart index cc6891cdf..b6d630870 100644 --- a/lib/pangea/spaces/space_navigation_column.dart +++ b/lib/pangea/spaces/space_navigation_column.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; @@ -16,6 +17,7 @@ import 'package:fluffychat/pangea/analytics_summary/level_analytics_details_cont import 'package:fluffychat/pangea/spaces/space_constants.dart'; import 'package:fluffychat/widgets/hover_builder.dart'; import 'package:fluffychat/widgets/navigation_rail.dart'; +import '../../widgets/matrix.dart'; class SpaceNavigationColumn extends StatefulWidget { final GoRouterState state; @@ -31,38 +33,57 @@ class SpaceNavigationColumn extends StatefulWidget { } class SpaceNavigationColumnState extends State { + bool _hovered = false; bool _expanded = false; - Timer? _debounceTimer; + Timer? _timer; + Profile? _profile; @override - void dispose() { - _debounceTimer?.cancel(); - _debounceTimer = null; - super.dispose(); + void initState() { + super.initState(); + Matrix.of(context).client.fetchOwnProfile().then((profile) { + if (mounted) { + setState(() { + _profile = profile; + }); + } + }); } - void _expand() { - if (_debounceTimer?.isActive == true) return; - if (!_expanded) { - setState(() => _expanded = true); - } - } - - void _collapse() { - if (_expanded) { + void _updateProfile(Profile profile) { + if (mounted) { setState(() { - _expanded = false; - _debounce(); + _profile = profile; }); } } - void _debounce() { - _debounceTimer?.cancel(); - _debounceTimer = Timer(const Duration(milliseconds: 300), () { - _debounceTimer?.cancel(); - _debounceTimer = null; - }); + void _onHoverUpdate(bool hovered) { + if (hovered == _hovered) return; + _hovered = hovered; + _cancelTimer(); + + if (hovered) { + _timer = Timer(const Duration(milliseconds: 200), () { + if (_hovered && mounted) { + setState(() => _expanded = true); + } + _cancelTimer(); + }); + } else { + setState(() => _expanded = false); + } + } + + void _cancelTimer() { + _timer?.cancel(); + _timer = null; + } + + @override + void dispose() { + _cancelTimer(); + super.dispose(); } @override @@ -115,7 +136,7 @@ class SpaceNavigationColumnState extends State { HoverBuilder( builder: (context, hovered) { WidgetsBinding.instance.addPostFrameCallback((_) { - hovered ? _expand() : _collapse(); + _onHoverUpdate(hovered); }); return Row( @@ -128,7 +149,12 @@ class SpaceNavigationColumnState extends State { ? navRailWidth + navRailExtraWidth : navRailWidth, expanded: _expanded, - collapse: _collapse, + collapse: () { + _cancelTimer(); + setState(() => _expanded = false); + }, + profile: _profile, + onProfileUpdate: _updateProfile, ), Container( width: 1, diff --git a/lib/pangea/subscription/pages/settings_subscription.dart b/lib/pangea/subscription/pages/settings_subscription.dart index a1f54d8ca..37145333a 100644 --- a/lib/pangea/subscription/pages/settings_subscription.dart +++ b/lib/pangea/subscription/pages/settings_subscription.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -22,7 +23,8 @@ class SubscriptionManagement extends StatefulWidget { SubscriptionManagementController(); } -class SubscriptionManagementController extends State { +class SubscriptionManagementController extends State + with WidgetsBindingObserver { final SubscriptionController subscriptionController = MatrixState.pangeaController.subscriptionController; @@ -31,6 +33,9 @@ class SubscriptionManagementController extends State { @override void initState() { + WidgetsBinding.instance.addObserver(this); + _refreshSubscription(); + if (!subscriptionController.initCompleter.isCompleted) { subscriptionController.initialize().then((_) => setState(() {})); } @@ -43,11 +48,20 @@ class SubscriptionManagementController extends State { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); subscriptionController.subscriptionNotifier.removeListener(_onSubscribe); subscriptionController.removeListener(_onSubscriptionUpdate); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _refreshSubscription(); + } + super.didChangeAppLifecycleState(state); + } + bool get subscriptionsAvailable => subscriptionController .availableSubscriptionInfo?.availableSubscriptions.isNotEmpty ?? @@ -103,6 +117,33 @@ class SubscriptionManagementController extends State { void _onSubscriptionUpdate() => setState(() {}); void _onSubscribe() => showSubscribedSnackbar(context); + Future _refreshSubscription() async { + if (!kIsWeb) return; + + // if the user previously clicked cancel, check if the subscription end date has changed + final prevEndDate = SubscriptionManagementRepo.getSubscriptionEndDate(); + final clickedCancel = + SubscriptionManagementRepo.getClickedCancelSubscription(); + if (clickedCancel == null) return; + + await subscriptionController.reinitialize(); + final newEndDate = + subscriptionController.currentSubscriptionInfo?.subscriptionEndDate; + + if (prevEndDate != newEndDate) { + SubscriptionManagementRepo.removeClickedCancelSubscription(); + SubscriptionManagementRepo.setSubscriptionEndDate(newEndDate); + if (mounted) setState(() {}); + return; + } + + // if more than 10 minutes have passed since the user clicked cancel, remove the click flag + if (DateTime.now().difference(clickedCancel).inMinutes >= 10) { + SubscriptionManagementRepo.removeClickedCancelSubscription(); + if (mounted) setState(() {}); + } + } + Future submitChange( SubscriptionDetails subscription, { bool isPromo = false, @@ -130,6 +171,9 @@ class SubscriptionManagementController extends State { Future onClickCancelSubscription() async { await SubscriptionManagementRepo.setClickedCancelSubscription(); + await SubscriptionManagementRepo.setSubscriptionEndDate( + subscriptionEndDate, + ); await launchMangementUrl(ManagementOption.cancel); if (mounted) setState(() {}); } diff --git a/lib/pangea/subscription/pages/settings_subscription_view.dart b/lib/pangea/subscription/pages/settings_subscription_view.dart index c5b3bb506..b48ab2fcf 100644 --- a/lib/pangea/subscription/pages/settings_subscription_view.dart +++ b/lib/pangea/subscription/pages/settings_subscription_view.dart @@ -16,6 +16,8 @@ class SettingsSubscriptionView extends StatelessWidget { @override Widget build(BuildContext context) { + final clickedCancelDate = + SubscriptionManagementRepo.getClickedCancelSubscription(); final List managementButtons = [ if (controller.currentSubscriptionAvailable) ListTile( @@ -70,7 +72,8 @@ class SettingsSubscriptionView extends StatelessWidget { ), ), ), - if (SubscriptionManagementRepo.getClickedCancelSubscription()) + if (clickedCancelDate != null && + DateTime.now().difference(clickedCancelDate).inMinutes < 10) Padding( padding: const EdgeInsets.all(16.0), child: Row( diff --git a/lib/pangea/subscription/repo/subscription_management_repo.dart b/lib/pangea/subscription/repo/subscription_management_repo.dart index f0b96a678..673a1103b 100644 --- a/lib/pangea/subscription/repo/subscription_management_repo.dart +++ b/lib/pangea/subscription/repo/subscription_management_repo.dart @@ -77,14 +77,26 @@ class SubscriptionManagementRepo { ); } - static bool getClickedCancelSubscription() { + static DateTime? getClickedCancelSubscription() { final entry = _cache.read(PLocalKey.clickedCancelSubscription); - if (entry == null) return false; - final val = DateTime.tryParse(entry); - return val != null && DateTime.now().difference(val).inSeconds < 60; + if (entry == null) return null; + return DateTime.tryParse(entry); } static Future removeClickedCancelSubscription() async { await _cache.remove(PLocalKey.clickedCancelSubscription); } + + static Future setSubscriptionEndDate(DateTime? date) async { + await _cache.write( + PLocalKey.subscriptionEndDate, + date?.toIso8601String(), + ); + } + + static DateTime? getSubscriptionEndDate() { + final entry = _cache.read(PLocalKey.subscriptionEndDate); + if (entry == null) return null; + return DateTime.tryParse(entry); + } } diff --git a/lib/pangea/text_to_speech/text_to_speech_response_model.dart b/lib/pangea/text_to_speech/text_to_speech_response_model.dart index 24cc71e1a..645005a3d 100644 --- a/lib/pangea/text_to_speech/text_to_speech_response_model.dart +++ b/lib/pangea/text_to_speech/text_to_speech_response_model.dart @@ -41,11 +41,16 @@ class TextToSpeechResponseModel { "tts_tokens": List.from(ttsTokens.map((x) => x.toJson())), }; - PangeaAudioEventData toPangeaAudioEventData(String text, String langCode) { + PangeaAudioEventData toPangeaAudioEventData( + String text, + String langCode, + String? voice, + ) { return PangeaAudioEventData( text: text, langCode: langCode, tokens: ttsTokens, + voice: voice, ); } } @@ -91,11 +96,13 @@ class PangeaAudioEventData { final String text; final String langCode; final List tokens; + final String? voice; PangeaAudioEventData({ required this.text, required this.langCode, required this.tokens, + this.voice, }); factory PangeaAudioEventData.fromJson(dynamic json) => PangeaAudioEventData( @@ -106,6 +113,7 @@ class PangeaAudioEventData { .map((x) => TTSToken.fromJson(x)) .toList(), ), + voice: json[ModelKey.voice] as String?, ); Map toJson() => { @@ -113,5 +121,6 @@ class PangeaAudioEventData { ModelKey.langCode: langCode, ModelKey.tokens: List>.from(tokens.map((x) => x.toJson())), + if (voice != null) ModelKey.voice: voice, }; } diff --git a/lib/pangea/token_info_feedback/show_token_feedback_dialog.dart b/lib/pangea/token_info_feedback/show_token_feedback_dialog.dart index 7d60c9181..9d34b7141 100644 --- a/lib/pangea/token_info_feedback/show_token_feedback_dialog.dart +++ b/lib/pangea/token_info_feedback/show_token_feedback_dialog.dart @@ -12,6 +12,7 @@ class TokenFeedbackUtil { required TokenInfoFeedbackRequestData requestData, required String langCode, PangeaMessageEvent? event, + VoidCallback? onUpdated, }) async { final resp = await showDialog( context: context, @@ -23,6 +24,8 @@ class TokenFeedbackUtil { ); if (resp == null) return; + + onUpdated?.call(); await showDialog( context: context, builder: (context) { diff --git a/lib/pangea/toolbar/layout/message_selection_positioner.dart b/lib/pangea/toolbar/layout/message_selection_positioner.dart index a905a1fa1..c4bf54b8c 100644 --- a/lib/pangea/toolbar/layout/message_selection_positioner.dart +++ b/lib/pangea/toolbar/layout/message_selection_positioner.dart @@ -125,9 +125,6 @@ class MessageSelectionPositionerState extends State final Duration transitionAnimationDuration = const Duration(milliseconds: 300); - final Offset _defaultMessageOffset = - const Offset(Avatar.defaultSize + 16 + 8, 300); - double get _horizontalPadding => FluffyThemes.isColumnMode(context) ? 8.0 : 0.0; @@ -232,14 +229,14 @@ class MessageSelectionPositionerState extends State null, ); - Offset get _originalMessageOffset { + Offset? get _originalMessageOffset { if (_messageRenderBox == null || !_messageRenderBox!.hasSize) { - return _defaultMessageOffset; + return null; } return _runWithLogging( () => _messageRenderBox?.localToGlobal(Offset.zero), "Error getting message offset", - _defaultMessageOffset, + null, ); } @@ -267,27 +264,36 @@ class MessageSelectionPositionerState extends State double? get messageLeftOffset { if (ownMessage) return null; + final offset = _originalMessageOffset; + if (offset == null) { + return Avatar.defaultSize + 16; + } + if (isRtl) { - return _originalMessageOffset.dx - - (showDetails ? FluffyThemes.columnWidth : 0); + return offset.dx - (showDetails ? FluffyThemes.columnWidth : 0); } if (ownMessage) return null; - return max(_originalMessageOffset.dx - columnWidth, 0); + return max(offset.dx - columnWidth, 0); } double? get messageRightOffset { if (mediaQuery == null || !ownMessage) return null; + final offset = _originalMessageOffset; + if (offset == null) { + return 8.0; + } + if (isRtl) { return mediaQuery!.size.width - columnWidth - - _originalMessageOffset.dx - + offset.dx - originalMessageSize.width; } return mediaQuery!.size.width - - _originalMessageOffset.dx - + offset.dx - originalMessageSize.width - (showDetails ? FluffyThemes.columnWidth : 0); } @@ -344,7 +350,10 @@ class MessageSelectionPositionerState extends State bool get _hasFooterOverflow { if (_screenHeight == null) return false; - final bottomOffset = _originalMessageOffset.dy + + final offset = _originalMessageOffset; + if (offset == null) return false; + + final bottomOffset = offset.dy + originalMessageSize.height + _reactionsHeight + AppConfig.toolbarMenuHeight + @@ -357,6 +366,8 @@ class MessageSelectionPositionerState extends State double get spaceBelowContent { if (shouldScroll) return 0; if (_hasFooterOverflow) return 0; + final offset = _originalMessageOffset; + if (offset == null) return 300; final messageHeight = originalMessageSize.height; final originalContentHeight = @@ -364,8 +375,7 @@ class MessageSelectionPositionerState extends State final screenHeight = mediaQuery!.size.height - mediaQuery!.padding.bottom; - double boxHeight = - screenHeight - _originalMessageOffset.dy - originalContentHeight; + double boxHeight = screenHeight - offset.dy - originalContentHeight; final neededSpace = boxHeight + _fullContentHeight + mediaQuery!.padding.top + 4.0; @@ -482,7 +492,7 @@ class MessageSelectionPositionerState extends State final type = practice.practiceMode.associatedActivityType; final complete = type != null && - practice.isPracticeActivityDone(type); + practice.isPracticeSessionDone(type); if (instruction != null && !complete) { return InstructionsInlineTooltip( diff --git a/lib/pangea/toolbar/layout/overlay_message.dart b/lib/pangea/toolbar/layout/overlay_message.dart index ad575ca0e..13008833f 100644 --- a/lib/pangea/toolbar/layout/overlay_message.dart +++ b/lib/pangea/toolbar/layout/overlay_message.dart @@ -10,14 +10,10 @@ import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/message_content.dart'; import 'package:fluffychat/pages/chat/events/reply_content.dart'; -import 'package:fluffychat/pangea/chat/widgets/request_regeneration_button.dart'; import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/languages/language_model.dart'; -import 'package:fluffychat/pangea/languages/p_language_store.dart'; -import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_widget.dart'; import 'package:fluffychat/pangea/toolbar/layout/reading_assistance_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/message_selection_overlay.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance/select_mode_buttons.dart'; @@ -258,11 +254,6 @@ class OverlayMessage extends StatelessWidget { ), ], ), - ) - else if (canRefresh) - RequestRegenerationButton( - textColor: textColor, - onPressed: () => controller.requestRegeneration(event.eventId), ), ], ), @@ -506,19 +497,19 @@ class _MessageBubbleTranscription extends StatelessWidget { onClick: onTokenSelected, isSelected: isTokenSelected, ), - if (MatrixState - .pangeaController.userController.showTranscription) - PhoneticTranscriptionWidget( - text: transcription.transcript.text, - textLanguage: PLanguageStore.byLangCode( - transcription.langCode, - ) ?? - LanguageModel.unknown, - style: style, - iconColor: style.color, - onTranscriptionFetched: () => - controller.contentChangedStream.add(true), - ), + // if (MatrixState + // .pangeaController.userController.showTranscription) + // PhoneticTranscriptionWidget( + // text: transcription.transcript.text, + // textLanguage: PLanguageStore.byLangCode( + // transcription.langCode, + // ) ?? + // LanguageModel.unknown, + // style: style, + // iconColor: style.color, + // onTranscriptionFetched: () => + // controller.contentChangedStream.add(true), + // ), ], ), ); diff --git a/lib/pangea/toolbar/message_practice/message_audio_card.dart b/lib/pangea/toolbar/message_practice/message_audio_card.dart index bf57035aa..6a3661b61 100644 --- a/lib/pangea/toolbar/message_practice/message_audio_card.dart +++ b/lib/pangea/toolbar/message_practice/message_audio_card.dart @@ -8,10 +8,10 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat/events/audio_player.dart'; import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart'; -import 'package:fluffychat/pangea/bot/utils/bot_room_extension.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/text_to_speech/text_to_speech_response_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; class MessageAudioCard extends StatefulWidget { final PangeaMessageEvent messageEvent; @@ -44,7 +44,7 @@ class MessageAudioCardState extends State { try { audioFile = await widget.messageEvent.requestTextToSpeech( widget.messageEvent.messageDisplayLangCode, - widget.messageEvent.room.botOptions?.targetVoice, + MatrixState.pangeaController.userController.voice, ); debugPrint("audio file is now: $audioFile. setting starts and ends..."); if (mounted) setState(() => _isLoading = false); diff --git a/lib/pangea/toolbar/message_practice/message_morph_choice.dart b/lib/pangea/toolbar/message_practice/message_morph_choice.dart index 99d025a4e..458932773 100644 --- a/lib/pangea/toolbar/message_practice/message_morph_choice.dart +++ b/lib/pangea/toolbar/message_practice/message_morph_choice.dart @@ -30,7 +30,7 @@ const int numberOfMorphDistractors = 3; class MessageMorphInputBarContent extends StatefulWidget { final PracticeController controller; - final PracticeActivityModel activity; + final MorphPracticeActivityModel activity; final PangeaToken? selectedToken; final double maxWidth; @@ -51,8 +51,8 @@ class MessageMorphInputBarContentState extends State { String? selectedTag; - PangeaToken get token => widget.activity.targetTokens.first; - MorphFeaturesEnum get morph => widget.activity.morphFeature!; + PangeaToken get token => widget.activity.tokens.first; + MorphFeaturesEnum get morph => widget.activity.morphFeature; @override void didUpdateWidget(covariant MessageMorphInputBarContent oldWidget) { @@ -114,10 +114,9 @@ class MessageMorphInputBarContentState runAlignment: WrapAlignment.center, spacing: spacing, runSpacing: spacing, - children: widget.activity.multipleChoiceContent!.choices.mapIndexed( + children: widget.activity.multipleChoiceContent.choices.mapIndexed( (index, choice) { - final wasCorrect = - widget.activity.practiceTarget.wasCorrectChoice(choice); + final wasCorrect = widget.controller.wasCorrectChoice(choice); return ChoiceAnimationWidget( isSelected: selectedTag == choice, @@ -135,9 +134,8 @@ class MessageMorphInputBarContentState PracticeChoice( choiceContent: choice, form: ConstructForm( - cId: widget.activity.targetTokens.first - .morphIdByFeature( - widget.activity.morphFeature!, + cId: widget.activity.tokens.first.morphIdByFeature( + widget.activity.morphFeature, )!, form: token.text.content, ), diff --git a/lib/pangea/toolbar/message_practice/practice_activity_card.dart b/lib/pangea/toolbar/message_practice/practice_activity_card.dart index 4a6eb7ff0..8561ea27a 100644 --- a/lib/pangea/toolbar/message_practice/practice_activity_card.dart +++ b/lib/pangea/toolbar/message_practice/practice_activity_card.dart @@ -98,17 +98,20 @@ class PracticeActivityCardState extends State { AsyncError() => CardErrorWidget( L10n.of(context).errorFetchingActivity, ), - AsyncLoaded() => state.value.multipleChoiceContent != null - ? MessageMorphInputBarContent( + AsyncLoaded() => switch (state.value) { + MultipleChoicePracticeActivityModel() => + MessageMorphInputBarContent( controller: widget.controller, - activity: state.value, + activity: state.value as MorphPracticeActivityModel, selectedToken: widget.selectedToken, maxWidth: widget.maxWidth, - ) - : MatchActivityCard( - currentActivity: state.value, + ), + MatchPracticeActivityModel() => MatchActivityCard( + currentActivity: + state.value as MatchPracticeActivityModel, controller: widget.controller, ), + }, _ => const SizedBox.shrink(), }, ], diff --git a/lib/pangea/toolbar/message_practice/practice_controller.dart b/lib/pangea/toolbar/message_practice/practice_controller.dart index d672f415b..5230499c3 100644 --- a/lib/pangea/toolbar/message_practice/practice_controller.dart +++ b/lib/pangea/toolbar/message_practice/practice_controller.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:async/async.dart'; import 'package:collection/collection.dart'; @@ -18,6 +18,7 @@ import 'package:fluffychat/pangea/practice_activities/practice_selection_repo.da import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/message_practice_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/morph_selection.dart'; +import 'package:fluffychat/pangea/toolbar/message_practice/practice_record_controller.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -35,18 +36,34 @@ class PracticeController with ChangeNotifier { MorphSelection? selectedMorph; PracticeChoice? selectedChoice; - PracticeActivityModel? get activity => _activity; - PracticeSelection? practiceSelection; - bool get isTotallyDone => - isPracticeActivityDone(ActivityTypeEnum.emoji) && - isPracticeActivityDone(ActivityTypeEnum.wordMeaning) && - isPracticeActivityDone(ActivityTypeEnum.wordFocusListening) && - isPracticeActivityDone(ActivityTypeEnum.morphId); + bool? wasCorrectMatch(PracticeChoice choice) { + if (_activity == null) return false; + return PracticeRecordController.wasCorrectMatch( + _activity!.practiceTarget, + choice, + ); + } - bool isPracticeActivityDone(ActivityTypeEnum activityType) => - practiceSelection?.activities(activityType).every((a) => a.isComplete) == + bool? wasCorrectChoice(String choice) { + if (_activity == null) return false; + return PracticeRecordController.wasCorrectChoice( + _activity!.practiceTarget, + choice, + ); + } + + bool get isTotallyDone => + isPracticeSessionDone(ActivityTypeEnum.emoji) && + isPracticeSessionDone(ActivityTypeEnum.wordMeaning) && + isPracticeSessionDone(ActivityTypeEnum.wordFocusListening) && + isPracticeSessionDone(ActivityTypeEnum.morphId); + + bool isPracticeSessionDone(ActivityTypeEnum activityType) => + practiceSelection + ?.activities(activityType) + .every((a) => PracticeRecordController.isCompleteByTarget(a)) == true; bool isPracticeButtonEmpty(PangeaToken token) { @@ -66,23 +83,25 @@ class PracticeController with ChangeNotifier { } return target == null || - target.isCompleteByToken( - token, - _activity?.morphFeature, - ) == - true; + PracticeRecordController.isCompleteByToken( + target, + token, + ); } bool get showChoiceShimmer { if (_activity == null) return false; - - if (_activity!.activityType == ActivityTypeEnum.morphId) { + if (_activity is MorphMatchPracticeActivityModel) { return selectedMorph != null && - !_activity!.practiceTarget.hasAnyResponses; + !PracticeRecordController.hasResponse( + _activity!.practiceTarget, + ); } return selectedChoice == null && - !_activity!.practiceTarget.hasAnyCorrectChoices; + !PracticeRecordController.hasAnyCorrectChoices( + _activity!.practiceTarget, + ); } Future _fetchPracticeSelection() async { @@ -101,9 +120,7 @@ class PracticeController with ChangeNotifier { userL1: MatrixState.pangeaController.userController.userL1!.langCode, userL2: MatrixState.pangeaController.userController.userL2!.langCode, activityQualityFeedback: null, - targetTokens: target.tokens, - targetType: target.activityType, - targetMorphFeature: target.morphFeature, + target: target, ); final result = await PracticeRepo.getPracticeActivity( @@ -151,11 +168,11 @@ class PracticeController with ChangeNotifier { void onMatch(PangeaToken token, PracticeChoice choice) { if (_activity == null) return; - - final isCorrect = _activity!.activityType == ActivityTypeEnum.morphId - ? _activity! - .onMultipleChoiceSelect(choice.form.cId, choice.choiceContent) - : _activity!.onMatch(token, choice); + final isCorrect = PracticeRecordController.onSelectChoice( + choice.choiceContent, + token, + _activity!, + ); final targetId = "message-token-${token.text.uniqueKey}-${pangeaMessageEvent.eventId}"; @@ -164,9 +181,10 @@ class PracticeController with ChangeNotifier { .pangeaController.matrixState.analyticsDataService.updateService; // we don't take off points for incorrect emoji matches - if (_activity!.activityType != ActivityTypeEnum.emoji || isCorrect) { - final constructUseType = _activity!.practiceTarget.record.responses.last - .useType(_activity!.activityType); + if (_activity is! EmojiPracticeActivityModel || isCorrect) { + final constructUseType = + PracticeRecordController.lastResponse(_activity!.practiceTarget)! + .useType(_activity!.activityType); final constructs = [ OneConstructUse( @@ -192,14 +210,14 @@ class PracticeController with ChangeNotifier { } if (isCorrect) { - if (_activity!.activityType == ActivityTypeEnum.emoji) { + if (_activity is EmojiPracticeActivityModel) { updateService.setLemmaInfo( choice.form.cId, emoji: choice.choiceContent, ); } - if (_activity!.activityType == ActivityTypeEnum.wordMeaning) { + if (_activity is LemmaMeaningPracticeActivityModel) { updateService.setLemmaInfo( choice.form.cId, meaning: choice.choiceContent, diff --git a/lib/pangea/toolbar/message_practice/practice_match_card.dart b/lib/pangea/toolbar/message_practice/practice_match_card.dart index 317be572a..60506e821 100644 --- a/lib/pangea/toolbar/message_practice/practice_match_card.dart +++ b/lib/pangea/toolbar/message_practice/practice_match_card.dart @@ -1,13 +1,9 @@ -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/common/widgets/choice_animation.dart'; -import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/message_audio_card.dart'; @@ -16,7 +12,7 @@ import 'package:fluffychat/pangea/toolbar/message_practice/practice_controller.d import 'package:fluffychat/pangea/toolbar/message_practice/practice_match_item.dart'; class MatchActivityCard extends StatelessWidget { - final PracticeActivityModel currentActivity; + final MatchPracticeActivityModel currentActivity; final PracticeController controller; const MatchActivityCard({ @@ -25,18 +21,14 @@ class MatchActivityCard extends StatelessWidget { required this.controller, }); - PracticeActivityModel get activity => currentActivity; - - ActivityTypeEnum get activityType => currentActivity.activityType; - Widget choiceDisplayContent( BuildContext context, String choice, double? fontSize, ) { - switch (activityType) { - case ActivityTypeEnum.emoji: - case ActivityTypeEnum.wordMeaning: + switch (currentActivity) { + case EmojiPracticeActivityModel(): + case LemmaMeaningPracticeActivityModel(): return Padding( padding: const EdgeInsets.all(8), child: Text( @@ -45,7 +37,7 @@ class MatchActivityCard extends StatelessWidget { textAlign: TextAlign.center, ), ); - case ActivityTypeEnum.wordFocusListening: + case WordListeningPracticeActivityModel(): return Padding( padding: const EdgeInsets.all(8), child: Icon( @@ -53,9 +45,6 @@ class MatchActivityCard extends StatelessWidget { size: fontSize, ), ); - default: - debugger(when: kDebugMode); - return const SizedBox(); } } @@ -83,15 +72,14 @@ class MatchActivityCard extends StatelessWidget { alignment: WrapAlignment.center, spacing: 4.0, runSpacing: 4.0, - children: activity.matchContent!.choices.map( + children: currentActivity.matchContent.choices.map( (PracticeChoice cf) { - final bool? wasCorrect = - currentActivity.practiceTarget.wasCorrectMatch(cf); + final bool? wasCorrect = controller.wasCorrectMatch(cf); return ChoiceAnimationWidget( isSelected: controller.selectedChoice == cf, isCorrect: wasCorrect, child: PracticeMatchItem( - token: currentActivity.practiceTarget.tokens.firstWhereOrNull( + token: currentActivity.tokens.firstWhereOrNull( (t) => t.vocabConstructID == cf.form.cId, ), isSelected: controller.selectedChoice == cf, @@ -100,7 +88,7 @@ class MatchActivityCard extends StatelessWidget { content: choiceDisplayContent(context, cf.choiceContent, fontSize), audioContent: - activityType == ActivityTypeEnum.wordFocusListening + currentActivity is WordListeningPracticeActivityModel ? cf.choiceContent : null, controller: controller, diff --git a/lib/pangea/toolbar/message_practice/practice_record_controller.dart b/lib/pangea/toolbar/message_practice/practice_record_controller.dart new file mode 100644 index 000000000..9bb3f396e --- /dev/null +++ b/lib/pangea/toolbar/message_practice/practice_record_controller.dart @@ -0,0 +1,119 @@ +import 'package:collection/collection.dart'; + +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_record.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_record_repo.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; + +class PracticeRecordController { + static PracticeRecord _recordByTarget(PracticeTarget target) => + PracticeRecordRepo.get(target); + + static bool hasResponse(PracticeTarget target) => + _recordByTarget(target).responses.isNotEmpty; + + static ActivityRecordResponse? lastResponse(PracticeTarget target) { + final record = _recordByTarget(target); + return record.responses.lastOrNull; + } + + static ActivityRecordResponse? correctResponse( + PracticeTarget target, + PangeaToken token, + ) { + final record = _recordByTarget(target); + return record.responses.firstWhereOrNull( + (res) => res.cId == target.targetTokenConstructID(token) && res.isCorrect, + ); + } + + static bool? wasCorrectMatch( + PracticeTarget target, + PracticeChoice choice, + ) { + final record = _recordByTarget(target); + for (final response in record.responses) { + if (response.text == choice.choiceContent && response.isCorrect) { + return true; + } + } + for (final response in record.responses) { + if (response.text == choice.choiceContent) { + return false; + } + } + return null; + } + + static bool? wasCorrectChoice( + PracticeTarget target, + String choice, + ) { + final record = _recordByTarget(target); + for (final response in record.responses) { + if (response.text == choice) { + return response.isCorrect; + } + } + return null; + } + + static bool isCompleteByTarget(PracticeTarget target) { + final record = _recordByTarget(target); + if (target.activityType == ActivityTypeEnum.morphId) { + return record.completeResponses > 0; + } + + return target.tokens.every( + (t) => record.responses.any( + (res) => res.cId == target.targetTokenConstructID(t) && res.isCorrect, + ), + ); + } + + static bool isCompleteByToken( + PracticeTarget target, + PangeaToken token, + ) { + final cId = target.targetTokenConstructID(token); + return _recordByTarget(target).responses.any( + (res) => res.cId == cId && res.isCorrect, + ); + } + + static bool hasAnyCorrectChoices(PracticeTarget target) { + final record = _recordByTarget(target); + return record.responses.any((response) => response.isCorrect); + } + + static bool onSelectChoice( + String choice, + PangeaToken token, + PracticeActivityModel activity, + ) { + final target = activity.practiceTarget; + final record = _recordByTarget(target); + final cId = target.targetTokenConstructID(token); + if (isCompleteByTarget(target) || + record.alreadyHasMatchResponse(cId, choice)) { + return false; + } + + final isCorrect = switch (activity) { + MatchPracticeActivityModel() => activity.isCorrect(token, choice), + MultipleChoicePracticeActivityModel() => activity.isCorrect(choice), + }; + + record.addResponse( + cId: cId, + target: target, + text: choice, + score: isCorrect ? 1 : 0, + ); + + return isCorrect; + } +} diff --git a/lib/pangea/toolbar/message_practice/reading_assistance_input_bar.dart b/lib/pangea/toolbar/message_practice/reading_assistance_input_bar.dart index 6cbcb249c..5eaf37f51 100644 --- a/lib/pangea/toolbar/message_practice/reading_assistance_input_bar.dart +++ b/lib/pangea/toolbar/message_practice/reading_assistance_input_bar.dart @@ -54,7 +54,7 @@ class ReadingAssistanceInputBarState extends State { children: [ ...MessagePracticeMode.practiceModes.map( (m) { - final complete = widget.controller.isPracticeActivityDone( + final complete = widget.controller.isPracticeSessionDone( m.associatedActivityType!, ); return ToolbarButton( @@ -125,7 +125,7 @@ class _ReadingAssistanceBarContent extends StatelessWidget { } final activityType = mode.associatedActivityType; final activityCompleted = - activityType != null && controller.isPracticeActivityDone(activityType); + activityType != null && controller.isPracticeSessionDone(activityType); switch (mode) { case MessagePracticeMode.noneSelected: diff --git a/lib/pangea/toolbar/message_practice/token_practice_button.dart b/lib/pangea/toolbar/message_practice/token_practice_button.dart index 5ec21afa3..a585e1290 100644 --- a/lib/pangea/toolbar/message_practice/token_practice_button.dart +++ b/lib/pangea/toolbar/message_practice/token_practice_button.dart @@ -2,7 +2,6 @@ import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:collection/collection.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:shimmer/shimmer.dart'; @@ -19,6 +18,7 @@ import 'package:fluffychat/pangea/toolbar/message_practice/dotted_border_painter import 'package:fluffychat/pangea/toolbar/message_practice/message_practice_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/morph_selection.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/practice_controller.dart'; +import 'package:fluffychat/pangea/toolbar/message_practice/practice_record_controller.dart'; import 'package:fluffychat/widgets/hover_builder.dart'; const double tokenButtonHeight = 40.0; @@ -48,11 +48,8 @@ class TokenPracticeButton extends StatelessWidget { PracticeTarget? get _activity => controller.practiceTargetForToken(token); bool get isActivityCompleteOrNullForToken { - return _activity?.isCompleteByToken( - token, - _activity!.morphFeature, - ) == - true; + if (_activity == null) return true; + return PracticeRecordController.isCompleteByToken(_activity!, token); } bool get _isEmpty => controller.isPracticeButtonEmpty(token); @@ -94,7 +91,8 @@ class TokenPracticeButton extends StatelessWidget { ), ), shimmer: controller.selectedMorph == null && - _activity?.hasAnyCorrectChoices == false, + _activity != null && + !PracticeRecordController.hasAnyCorrectChoices(_activity!), ); } else { child = _StandardMatchButton( @@ -257,14 +255,11 @@ class _NoActivityContentButton extends StatelessWidget { @override Widget build(BuildContext context) { - if (practiceMode == MessagePracticeMode.wordEmoji) { - final displayEmoji = target?.record.responses - .firstWhereOrNull( - (res) => res.cId == token.vocabConstructID && res.isCorrect, - ) - ?.text ?? - token.vocabConstructID.userSetEmoji ?? - ''; + if (practiceMode == MessagePracticeMode.wordEmoji && target != null) { + final displayEmoji = + PracticeRecordController.correctResponse(target!, token)?.text ?? + token.vocabConstructID.userSetEmoji ?? + ''; return Text( displayEmoji, style: emojiStyle, diff --git a/lib/pangea/toolbar/reading_assistance/new_word_overlay.dart b/lib/pangea/toolbar/reading_assistance/new_word_overlay.dart index 1bbef81fe..3f66827bb 100644 --- a/lib/pangea/toolbar/reading_assistance/new_word_overlay.dart +++ b/lib/pangea/toolbar/reading_assistance/new_word_overlay.dart @@ -83,10 +83,12 @@ class _NewWordOverlayState extends State @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 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!, @@ -162,7 +165,7 @@ class NewVocabBubble extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon( - Symbols.toys_and_games, + Symbols.dictionary, color: theme.colorScheme.primary, size: 24, ), diff --git a/lib/pangea/toolbar/reading_assistance/select_mode_buttons.dart b/lib/pangea/toolbar/reading_assistance/select_mode_buttons.dart index 116fd451d..227a8781e 100644 --- a/lib/pangea/toolbar/reading_assistance/select_mode_buttons.dart +++ b/lib/pangea/toolbar/reading_assistance/select_mode_buttons.dart @@ -15,9 +15,11 @@ import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/audio_player.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; +import 'package:fluffychat/pangea/common/widgets/shimmer_background.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; import 'package:fluffychat/pangea/events/utils/report_message.dart'; +import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; import 'package:fluffychat/pangea/text_to_speech/tts_controller.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/message_audio_card.dart'; import 'package:fluffychat/pangea/toolbar/message_selection_overlay.dart'; @@ -29,7 +31,8 @@ enum SelectMode { translate(Icons.translate), practice(Symbols.fitness_center), emoji(Icons.add_reaction_outlined), - speechTranslation(Icons.translate); + speechTranslation(Icons.translate), + requestRegenerate(Icons.replay); final IconData icon; const SelectMode(this.icon); @@ -46,6 +49,8 @@ enum SelectMode { return l10n.practice; case SelectMode.emoji: return l10n.emojiView; + case SelectMode.requestRegenerate: + return l10n.requestRegeneration; } } } @@ -180,6 +185,9 @@ class SelectModeButtonsState extends State { SelectModeController get controller => widget.overlayController.selectModeController; + bool get _canRefresh => + messageEvent.eventId == widget.controller.refreshEventID; + Future updateMode(SelectMode? mode) async { if (mode == null) { matrix?.audioPlayer?.stop(); @@ -208,19 +216,54 @@ class SelectModeButtonsState extends State { } if (updatedMode == SelectMode.translate) { + if (!InstructionsEnum.shimmerTranslation.isToggledOff) { + InstructionsEnum.shimmerTranslation.setToggledOff(true); + } await controller.fetchTranslation(); } if (updatedMode == SelectMode.speechTranslation) { await controller.fetchSpeechTranslation(); } + + if (updatedMode == SelectMode.requestRegenerate) { + await widget.controller.requestRegeneration( + messageEvent.eventId, + ); + + if (mounted) { + controller.setSelectMode(null); + } + } } Future modeDisabled() async { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( + final target = controller.messageEvent.originalSent?.langCode; + final messenger = ScaffoldMessenger.of(context); + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( SnackBar( - content: Text(L10n.of(context).modeDisabled), + content: Row( + spacing: 12.0, + children: [ + Flexible( + child: Text( + L10n.of(context).modeDisabled, + textAlign: TextAlign.center, + ), + ), + if (target != null) + TextButton( + style: TextButton.styleFrom( + foregroundColor: + Theme.of(context).colorScheme.primaryContainer, + ), + onPressed: () => + widget.controller.updateLanguageOnMismatch(target), + child: Text(L10n.of(context).learn), + ), + ], + ), ), ); } @@ -348,7 +391,7 @@ class SelectModeButtonsState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final modes = controller.readingAssistanceModes; - final allModes = controller.allModes; + final allModes = controller.allModes(enableRefresh: _canRefresh); return Material( type: MaterialType.transparency, child: SizedBox( @@ -358,7 +401,7 @@ class SelectModeButtonsState extends State { children: List.generate(allModes.length + 1, (index) { if (index < allModes.length) { final mode = allModes[index]; - final enabled = modes.contains(mode); + final enabled = modes(enableRefresh: _canRefresh).contains(mode); return Container( width: 45.0, alignment: Alignment.center, @@ -374,37 +417,43 @@ class SelectModeButtonsState extends State { builder: (context, _) { final selectedMode = controller.selectedMode.value; return Opacity( - opacity: enabled ? 1.0 : 0.5, + opacity: enabled ? 1.0 : 0.75, child: PressableButton( borderRadius: BorderRadius.circular(20), depressed: mode == selectedMode || !enabled, - color: enabled - ? theme.colorScheme.primaryContainer - : theme.disabledColor, + color: theme.colorScheme.primaryContainer, onPressed: enabled ? () => updateMode(mode) : modeDisabled, playSound: enabled && mode != SelectMode.audio, colorFactor: theme.brightness == Brightness.light ? 0.55 : 0.3, builder: (context, depressed, shadowColor) => - AnimatedContainer( - duration: FluffyThemes.animationDuration, - height: buttonSize, - width: buttonSize, - decoration: BoxDecoration( - color: depressed - ? shadowColor - : theme.colorScheme.primaryContainer, - shape: BoxShape.circle, - ), - child: ValueListenableBuilder( - valueListenable: _isPlayingNotifier, - builder: (context, playing, __) => - _SelectModeButtonIcon( - mode: mode, - loading: controller.isLoading && - mode == selectedMode, - playing: mode == SelectMode.audio && playing, + ShimmerBackground( + enabled: !InstructionsEnum + .shimmerTranslation.isToggledOff && + mode == SelectMode.translate && + enabled, + borderRadius: BorderRadius.circular(100), + child: AnimatedContainer( + duration: FluffyThemes.animationDuration, + height: buttonSize, + width: buttonSize, + decoration: BoxDecoration( + color: depressed + ? shadowColor + : theme.colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: ValueListenableBuilder( + valueListenable: _isPlayingNotifier, + builder: (context, playing, __) => + _SelectModeButtonIcon( + mode: mode, + loading: controller.isLoading && + mode == selectedMode, + playing: mode == SelectMode.audio && playing, + color: theme.colorScheme.onPrimaryContainer, + ), ), ), ), @@ -435,11 +484,13 @@ class _SelectModeButtonIcon extends StatelessWidget { final SelectMode mode; final bool loading; final bool playing; + final Color color; const _SelectModeButtonIcon({ required this.mode, this.loading = false, this.playing = false, + required this.color, }); @override @@ -458,10 +509,11 @@ class _SelectModeButtonIcon extends StatelessWidget { return Icon( playing ? Icons.pause_outlined : Icons.volume_up, size: 20, + color: color, ); } - return Icon(mode.icon, size: 20); + return Icon(mode.icon, size: 20, color: color); } } diff --git a/lib/pangea/toolbar/reading_assistance/select_mode_controller.dart b/lib/pangea/toolbar/reading_assistance/select_mode_controller.dart index eee6bdcbc..1f1a7890b 100644 --- a/lib/pangea/toolbar/reading_assistance/select_mode_controller.dart +++ b/lib/pangea/toolbar/reading_assistance/select_mode_controller.dart @@ -7,7 +7,6 @@ import 'package:matrix/matrix.dart'; import 'package:path_provider/path_provider.dart'; import 'package:fluffychat/pangea/analytics_misc/lemma_emoji_setter_mixin.dart'; -import 'package:fluffychat/pangea/bot/utils/bot_room_extension.dart'; import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; @@ -58,7 +57,7 @@ class _AudioLoader extends AsyncLoader<(PangeaAudioFile, File?)> { Future<(PangeaAudioFile, File?)> fetch() async { final audioBytes = await messageEvent.requestTextToSpeech( messageEvent.messageDisplayLangCode, - messageEvent.room.botOptions?.targetVoice, + MatrixState.pangeaController.userController.voice, ); File? audioFile; @@ -91,8 +90,6 @@ class SelectModeController with LemmaEmojiSetter { ValueNotifier selectedMode = ValueNotifier(null); - final StreamController contentChangedStream = StreamController.broadcast(); - // Sometimes the same token is clicked twice. Setting it to the same value // won't trigger the notifier, so use the bool for force it to trigger. ValueNotifier<(PangeaTokenText?, bool)> playTokenNotifier = @@ -105,7 +102,6 @@ class SelectModeController with LemmaEmojiSetter { _translationLoader.dispose(); _sttTranslationLoader.dispose(); _audioLoader.dispose(); - contentChangedStream.close(); } static List get _textModes => [ @@ -130,7 +126,7 @@ class SelectModeController with LemmaEmojiSetter { (PangeaAudioFile, File?)? get audioFile => _audioLoader.value; - List get allModes { + List allModes({bool enableRefresh = false}) { final validTypes = {MessageTypes.Text, MessageTypes.Audio}; if (!messageEvent.event.status.isSent || messageEvent.event.type != EventTypes.Message || @@ -138,12 +134,18 @@ class SelectModeController with LemmaEmojiSetter { return []; } - return messageEvent.event.messageType == MessageTypes.Text + final types = messageEvent.event.messageType == MessageTypes.Text ? _textModes : _audioModes; + + if (enableRefresh) { + return [...types, SelectMode.requestRegenerate]; + } + + return types; } - List get readingAssistanceModes { + List readingAssistanceModes({bool enableRefresh = false}) { final validTypes = {MessageTypes.Text, MessageTypes.Audio}; if (!messageEvent.event.status.isSent || messageEvent.event.type != EventTypes.Message || @@ -151,23 +153,30 @@ class SelectModeController with LemmaEmojiSetter { return []; } - if (messageEvent.event.messageType == MessageTypes.Text) { - final lang = messageEvent.messageDisplayLangCode.split("-").first; + List modes = []; + final lang = messageEvent.messageDisplayLangCode.split("-").first; + final matchesL1 = lang == + MatrixState.pangeaController.userController.userL1!.langCodeShort; + + if (messageEvent.event.messageType == MessageTypes.Text) { final matchesL2 = lang == MatrixState.pangeaController.userController.userL2!.langCodeShort; - final matchesL1 = lang == - MatrixState.pangeaController.userController.userL1!.langCodeShort; - - return matchesL2 + modes = matchesL2 ? _textModes : matchesL1 ? [] : [SelectMode.translate]; + } else { + modes = matchesL1 ? [] : _audioModes; } - return _audioModes; + if (enableRefresh) { + modes = [...modes, SelectMode.requestRegenerate]; + } + + return modes; } bool get isLoading => currentModeStateNotifier?.value is AsyncLoading; diff --git a/lib/pangea/toolbar/reading_assistance/stt_transcript_tokens.dart b/lib/pangea/toolbar/reading_assistance/stt_transcript_tokens.dart index 36be582af..2a25706b9 100644 --- a/lib/pangea/toolbar/reading_assistance/stt_transcript_tokens.dart +++ b/lib/pangea/toolbar/reading_assistance/stt_transcript_tokens.dart @@ -4,6 +4,7 @@ import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/speech_to_text/speech_to_text_response_model.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance/token_rendering_util.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance/tokens_util.dart'; +import 'package:fluffychat/pangea/toolbar/reading_assistance/underline_text_widget.dart'; import 'package:fluffychat/widgets/hover_builder.dart'; class SttTranscriptTokens extends StatelessWidget { @@ -38,10 +39,6 @@ class SttTranscriptTokens extends StatelessWidget { } final messageCharacters = model.transcript.text.characters; - final renderer = TokenRenderingUtil( - existingStyle: (style ?? DefaultTextStyle.of(context).style), - ); - final newTokens = TokensUtil.getNewTokens( eventId, tokens, @@ -76,18 +73,14 @@ class SttTranscriptTokens extends StatelessWidget { child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: onClick != null ? () => onClick?.call(token) : null, - child: RichText( - text: TextSpan( - text: text, - style: renderer.style( - underlineColor: Theme.of(context) - .colorScheme - .primary - .withAlpha(200), - hovered: hovered, - selected: selected, - isNew: newTokens.any((t) => t == token.text), - ), + child: UnderlineText( + text: text, + style: style ?? DefaultTextStyle.of(context).style, + underlineColor: TokenRenderingUtil.underlineColor( + Theme.of(context).colorScheme.primary.withAlpha(200), + selected: selected, + hovered: hovered, + isNew: newTokens.any((t) => t == token.text), ), ), ), diff --git a/lib/pangea/toolbar/reading_assistance/token_rendering_util.dart b/lib/pangea/toolbar/reading_assistance/token_rendering_util.dart index 2b51b0ff6..6891fe345 100644 --- a/lib/pangea/toolbar/reading_assistance/token_rendering_util.dart +++ b/lib/pangea/toolbar/reading_assistance/token_rendering_util.dart @@ -3,42 +3,16 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/app_config.dart'; class TokenRenderingUtil { - final TextStyle existingStyle; - - TokenRenderingUtil({ - required this.existingStyle, - }); + TokenRenderingUtil(); static final Map _tokensWidthCache = {}; - TextStyle style({ - required Color underlineColor, - double? fontSize, - bool selected = false, - bool highlighted = false, - bool isNew = false, - bool practiceMode = false, - bool hovered = false, - }) => - existingStyle.copyWith( - fontSize: fontSize, - decoration: TextDecoration.underline, - decorationThickness: 4, - decorationColor: _underlineColor( - underlineColor, - selected: selected, - highlighted: highlighted, - isNew: isNew, - practiceMode: practiceMode, - hovered: hovered, - ), - ); - double tokenTextWidthForContainer( String text, - Color underlineColor, { - double? fontSize, - }) { + Color underlineColor, + TextStyle style, + double fontSize, + ) { final tokenSizeKey = "$text-$fontSize"; if (_tokensWidthCache.containsKey(tokenSizeKey)) { return _tokensWidthCache[tokenSizeKey]!; @@ -47,10 +21,7 @@ class TokenRenderingUtil { final textPainter = TextPainter( text: TextSpan( text: text, - style: style( - underlineColor: underlineColor, - fontSize: fontSize, - ), + style: style, ), maxLines: 1, textDirection: TextDirection.ltr, @@ -62,7 +33,7 @@ class TokenRenderingUtil { return width; } - Color _underlineColor( + static Color underlineColor( Color underlineColor, { bool selected = false, bool highlighted = false, diff --git a/lib/pangea/toolbar/reading_assistance/underline_text_widget.dart b/lib/pangea/toolbar/reading_assistance/underline_text_widget.dart new file mode 100644 index 000000000..be680a5c2 --- /dev/null +++ b/lib/pangea/toolbar/reading_assistance/underline_text_widget.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_linkify/flutter_linkify.dart'; + +import 'package:fluffychat/utils/url_launcher.dart'; + +class UnderlineText extends StatelessWidget { + final String text; + final TextStyle style; + final TextStyle? linkStyle; + final TextDirection? textDirection; + final Color? underlineColor; + + const UnderlineText({ + super.key, + required this.text, + required this.style, + this.linkStyle, + this.textDirection, + this.underlineColor, + }); + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.bottomLeft, + children: [ + RichText( + textDirection: textDirection, + text: TextSpan( + children: [ + LinkifySpan( + text: text, + style: style, + linkStyle: linkStyle, + onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), + ), + ], + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + height: 3, + color: underlineColor ?? Colors.transparent, + ), + ), + ], + ); + } +} diff --git a/lib/pangea/toolbar/token_rendering_mixin.dart b/lib/pangea/toolbar/token_rendering_mixin.dart index 082a4e8f8..81da0912e 100644 --- a/lib/pangea/toolbar/token_rendering_mixin.dart +++ b/lib/pangea/toolbar/token_rendering_mixin.dart @@ -3,6 +3,7 @@ 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/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance/tokens_util.dart'; mixin TokenRenderingMixin { @@ -15,6 +16,10 @@ mixin TokenRenderingMixin { String? eventId, }) async { TokensUtil.collectToken(cacheKey, token.text); + if (!InstructionsEnum.shimmerNewToken.isToggledOff) { + InstructionsEnum.shimmerNewToken.setToggledOff(true); + } + final constructs = [ OneConstructUse( useType: ConstructUseTypeEnum.click, diff --git a/lib/pangea/toolbar/word_card/lemma_meaning_display.dart b/lib/pangea/toolbar/word_card/lemma_meaning_display.dart index 1859282b6..70dd51f3e 100644 --- a/lib/pangea/toolbar/word_card/lemma_meaning_display.dart +++ b/lib/pangea/toolbar/word_card/lemma_meaning_display.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart'; +import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/lemmas/lemma_meaning_builder.dart'; @@ -12,6 +13,7 @@ class LemmaMeaningDisplay extends StatelessWidget { final ConstructIdentifier constructId; final String text; final Map messageInfo; + final ValueNotifier? reloadNotifier; const LemmaMeaningDisplay({ super.key, @@ -19,6 +21,7 @@ class LemmaMeaningDisplay extends StatelessWidget { required this.constructId, required this.text, required this.messageInfo, + this.reloadNotifier, }); @override @@ -27,53 +30,47 @@ class LemmaMeaningDisplay extends StatelessWidget { langCode: langCode, constructId: constructId, messageInfo: messageInfo, + reloadNotifier: reloadNotifier, builder: (context, controller) { - if (controller.isError) { - return ErrorIndicator( - message: L10n.of(context).errorFetchingDefinition, - style: const TextStyle(fontSize: 14.0), - ); - } - - if (controller.isLoading || controller.lemmaInfo == null) { - return const TextLoadingShimmer( - width: 125.0, - height: 20.0, - ); - } - - final pos = getGrammarCopy( - category: "POS", - lemma: constructId.category, - context: context, - ) ?? - L10n.of(context).other; - - return RichText( - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - text: TextSpan( - style: DefaultTextStyle.of(context).style.copyWith( - fontSize: 14.0, - ), - children: [ - TextSpan( - text: "${constructId.lemma} ($pos)", + return switch (controller.state) { + AsyncError() => ErrorIndicator( + message: L10n.of(context).errorFetchingDefinition, + style: const TextStyle(fontSize: 14.0), + ), + AsyncLoaded(value: final lemmaInfo) => RichText( + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + text: TextSpan( + style: DefaultTextStyle.of(context).style.copyWith( + fontSize: 14.0, + ), + children: [ + TextSpan( + text: "${constructId.lemma} (${getGrammarCopy( + category: "POS", + lemma: constructId.category, + context: context, + ) ?? L10n.of(context).other})", + ), + const WidgetSpan( + child: SizedBox(width: 8.0), + ), + const TextSpan(text: ":"), + const WidgetSpan( + child: SizedBox(width: 8.0), + ), + TextSpan( + text: lemmaInfo.meaning, + ), + ], ), - const WidgetSpan( - child: SizedBox(width: 8.0), - ), - const TextSpan(text: ":"), - const WidgetSpan( - child: SizedBox(width: 8.0), - ), - TextSpan( - text: controller.lemmaInfo!.meaning, - ), - ], - ), - ); + ), + _ => const TextLoadingShimmer( + width: 125.0, + height: 20.0, + ), + }; }, ); } diff --git a/lib/pangea/toolbar/word_card/word_zoom_widget.dart b/lib/pangea/toolbar/word_card/word_zoom_widget.dart index f8094f26c..4e9b40ffc 100644 --- a/lib/pangea/toolbar/word_card/word_zoom_widget.dart +++ b/lib/pangea/toolbar/word_card/word_zoom_widget.dart @@ -30,6 +30,8 @@ class WordZoomWidget extends StatelessWidget { final bool enableEmojiSelection; final VoidCallback? onDismissNewWordOverlay; final Function(LemmaInfoResponse, String)? onFlagTokenInfo; + final ValueNotifier? reloadNotifier; + final double? maxWidth; const WordZoomWidget({ super.key, @@ -41,6 +43,8 @@ class WordZoomWidget extends StatelessWidget { this.enableEmojiSelection = true, this.onDismissNewWordOverlay, this.onFlagTokenInfo, + this.reloadNotifier, + this.maxWidth, }); String get transformTargetId => "word-zoom-card-${token.uniqueKey}"; @@ -63,8 +67,8 @@ class WordZoomWidget extends StatelessWidget { Container( height: AppConfig.toolbarMaxHeight - 8, padding: const EdgeInsets.all(12.0), - constraints: const BoxConstraints( - maxWidth: AppConfig.toolbarMinWidth, + constraints: BoxConstraints( + maxWidth: maxWidth ?? AppConfig.toolbarMinWidth, ), child: CompositedTransformTarget( link: layerLink, @@ -141,6 +145,7 @@ class WordZoomWidget extends StatelessWidget { style: const TextStyle(fontSize: 14.0), iconSize: 24.0, maxLines: 2, + reloadNotifier: reloadNotifier, ) : WordAudioButton( text: token.content, @@ -159,6 +164,7 @@ class WordZoomWidget extends StatelessWidget { constructId: construct, text: token.content, messageInfo: event?.content ?? {}, + reloadNotifier: reloadNotifier, ), ], ), diff --git a/lib/pangea/user/pangea_push_rules_extension.dart b/lib/pangea/user/pangea_push_rules_extension.dart index ca670f69b..c7e92462a 100644 --- a/lib/pangea/user/pangea_push_rules_extension.dart +++ b/lib/pangea/user/pangea_push_rules_extension.dart @@ -16,6 +16,29 @@ extension PangeaPushRulesExtension on Client { } } + if (!(globalPushRules?.override?.any( + (element) => element.ruleId == PangeaEventTypes.analyticsInviteRule, + ) ?? + false)) { + await setPushRule( + PushRuleKind.override, + PangeaEventTypes.analyticsInviteRule, + [PushRuleAction.dontNotify], + conditions: [ + PushCondition( + kind: 'event_match', + key: 'type', + pattern: EventTypes.RoomMember, + ), + PushCondition( + kind: 'event_match', + key: 'content.reason', + pattern: PangeaEventTypes.analyticsInviteContent, + ), + ], + ); + } + if (!(globalPushRules?.override?.any( (element) => element.ruleId == PangeaEventTypes.textToSpeechRule, ) ?? diff --git a/lib/pangea/user/style_settings_repo.dart b/lib/pangea/user/style_settings_repo.dart new file mode 100644 index 000000000..65f452fec --- /dev/null +++ b/lib/pangea/user/style_settings_repo.dart @@ -0,0 +1,82 @@ +import 'package:get_storage/get_storage.dart'; + +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; + +class StyleSettings { + final double fontSizeFactor; + final bool useActivityImageBackground; + + const StyleSettings({ + this.fontSizeFactor = 1.0, + this.useActivityImageBackground = true, + }); + + Map toJson() { + return { + 'fontSizeFactor': fontSizeFactor, + 'useActivityImageBackground': useActivityImageBackground, + }; + } + + factory StyleSettings.fromJson(Map 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, + ); + } +} + +class StyleSettingsRepo { + static final GetStorage _storage = GetStorage("style_settings"); + + static String _storageKey(String userId) => '${userId}_style_settings'; + + static Future settings(String userId) async { + await GetStorage.init("style_settings"); + final key = _storageKey(userId); + final json = _storage.read>(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 setFontSizeFactor(String userId, double factor) async { + final currentSettings = await settings(userId); + final updatedSettings = currentSettings.copyWith(fontSizeFactor: factor); + await _storage.write(_storageKey(userId), updatedSettings.toJson()); + } + + static Future setUseActivityImageBackground( + String userId, + bool useBackground, + ) async { + final currentSettings = await settings(userId); + final updatedSettings = currentSettings.copyWith( + useActivityImageBackground: useBackground, + ); + await _storage.write(_storageKey(userId), updatedSettings.toJson()); + } +} diff --git a/lib/pangea/user/user_controller.dart b/lib/pangea/user/user_controller.dart index 43b9736ea..2e3be4226 100644 --- a/lib/pangea/user/user_controller.dart +++ b/lib/pangea/user/user_controller.dart @@ -431,6 +431,8 @@ class UserController { : langModel; } + String? get voice => profile.userSettings.voice; + bool get languagesSet => userL1Code != null && userL2Code != null && diff --git a/lib/pangea/user/user_model.dart b/lib/pangea/user/user_model.dart index 60ce1873a..ff0184627 100644 --- a/lib/pangea/user/user_model.dart +++ b/lib/pangea/user/user_model.dart @@ -19,6 +19,7 @@ class UserSettings { GenderEnum gender; String? country; LanguageLevelTypeEnum cefrLevel; + String? voice; UserSettings({ this.dateOfBirth, @@ -29,6 +30,7 @@ class UserSettings { this.gender = GenderEnum.unselected, this.country, this.cefrLevel = LanguageLevelTypeEnum.a1, + this.voice, }); factory UserSettings.fromJson(Map json) => UserSettings( @@ -52,6 +54,7 @@ class UserSettings { json[ModelKey.cefrLevel], ) : LanguageLevelTypeEnum.a1, + voice: json[ModelKey.voice], ); Map toJson() { @@ -64,6 +67,7 @@ class UserSettings { data[ModelKey.userGender] = gender.string; data[ModelKey.userCountry] = country; data[ModelKey.cefrLevel] = cefrLevel.string; + data[ModelKey.voice] = voice; return data; } @@ -123,6 +127,7 @@ class UserSettings { gender: gender, country: country, cefrLevel: cefrLevel, + voice: voice, ); } @@ -138,7 +143,8 @@ class UserSettings { other.sourceLanguage == sourceLanguage && other.gender == gender && other.country == country && - other.cefrLevel == cefrLevel; + other.cefrLevel == cefrLevel && + other.voice == voice; } @override @@ -151,6 +157,7 @@ class UserSettings { gender.hashCode, country.hashCode, cefrLevel.hashCode, + voice.hashCode, ]); } diff --git a/lib/pangea/user/user_search_extension.dart b/lib/pangea/user/user_search_extension.dart new file mode 100644 index 000000000..871b72101 --- /dev/null +++ b/lib/pangea/user/user_search_extension.dart @@ -0,0 +1,19 @@ +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/common/config/environment.dart'; + +extension UserSearchExtension on Client { + Future searchUser( + String search, { + int? limit, + }) async { + String searchText = search; + if (!searchText.startsWith("@")) { + searchText = "@$searchText"; + } + if (!searchText.contains(":")) { + searchText = "$searchText:${Environment.homeServer}"; + } + return searchUserDirectory(searchText, limit: limit); + } +} diff --git a/lib/pangea/vocab_practice/choice_cards/game_choice_card.dart b/lib/pangea/vocab_practice/choice_cards/game_choice_card.dart deleted file mode 100644 index 5d08a4105..000000000 --- a/lib/pangea/vocab_practice/choice_cards/game_choice_card.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -/// A unified choice card that handles flipping, color tinting, hovering, and alt widgets -class GameChoiceCard extends StatefulWidget { - final Widget child; - final Widget? altChild; - final VoidCallback onPressed; - final bool isCorrect; - final double height; - final bool shouldFlip; - final String? transformId; - final bool isEnabled; - - const GameChoiceCard({ - required this.child, - this.altChild, - required this.onPressed, - required this.isCorrect, - this.height = 72.0, - this.shouldFlip = false, - this.transformId, - this.isEnabled = true, - super.key, - }); - - @override - State createState() => _GameChoiceCardState(); -} - -class _GameChoiceCardState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _scaleAnim; - bool _flipped = false; - bool _isHovered = false; - bool _useAltChild = false; - bool _clicked = false; - - @override - void initState() { - super.initState(); - - if (widget.shouldFlip) { - _controller = AnimationController( - duration: const Duration(milliseconds: 220), - vsync: this, - ); - - _scaleAnim = Tween(begin: 1.0, end: 0.0).animate( - CurvedAnimation(parent: _controller, curve: Curves.easeInOut), - ); - - _controller.addListener(_onAnimationUpdate); - } - } - - void _onAnimationUpdate() { - // Swap to altChild when card is almost fully shrunk - if (_controller.value >= 0.95 && !_useAltChild && widget.altChild != null) { - setState(() => _useAltChild = true); - } - - // Mark as flipped when card is fully shrunk - if (_controller.value >= 0.95 && !_flipped) { - setState(() => _flipped = true); - } - } - - @override - void dispose() { - if (widget.shouldFlip) { - _controller.removeListener(_onAnimationUpdate); - _controller.dispose(); - } - super.dispose(); - } - - Future _handleTap() async { - if (!widget.isEnabled) return; - - if (widget.shouldFlip) { - if (_flipped) return; - // Animate forward (shrink), then reverse (expand) - await _controller.forward(); - await _controller.reverse(); - } else { - if (_clicked) return; - setState(() => _clicked = true); - } - - widget.onPressed(); - } - - @override - Widget build(BuildContext context) { - final ColorScheme colorScheme = Theme.of(context).colorScheme; - - final Color baseColor = colorScheme.surfaceContainerHighest; - final Color hoverColor = colorScheme.onSurface.withValues(alpha: 0.08); - final Color tintColor = widget.isCorrect - ? AppConfig.success.withValues(alpha: 0.3) - : AppConfig.error.withValues(alpha: 0.3); - - Widget card = MouseRegion( - onEnter: - widget.isEnabled ? ((_) => setState(() => _isHovered = true)) : null, - onExit: - widget.isEnabled ? ((_) => setState(() => _isHovered = false)) : null, - child: SizedBox( - width: double.infinity, - height: widget.height, - child: GestureDetector( - onTap: _handleTap, - child: widget.shouldFlip - ? AnimatedBuilder( - animation: _scaleAnim, - builder: (context, child) { - final bool showContent = _scaleAnim.value > 0.1; - return Transform.scale( - scaleY: _scaleAnim.value, - child: Container( - decoration: BoxDecoration( - color: baseColor, - borderRadius: BorderRadius.circular(16), - ), - foregroundDecoration: BoxDecoration( - color: _flipped - ? tintColor - : (_isHovered ? hoverColor : Colors.transparent), - borderRadius: BorderRadius.circular(16), - ), - margin: const EdgeInsets.symmetric( - vertical: 6, - horizontal: 0, - ), - padding: const EdgeInsets.symmetric(horizontal: 16), - height: widget.height, - alignment: Alignment.center, - child: Opacity( - opacity: showContent ? 1.0 : 0.0, - child: _useAltChild && widget.altChild != null - ? widget.altChild! - : widget.child, - ), - ), - ); - }, - ) - : Container( - decoration: BoxDecoration( - color: baseColor, - borderRadius: BorderRadius.circular(16), - ), - foregroundDecoration: BoxDecoration( - color: _clicked - ? tintColor - : (_isHovered ? hoverColor : Colors.transparent), - borderRadius: BorderRadius.circular(16), - ), - margin: - const EdgeInsets.symmetric(vertical: 6, horizontal: 0), - padding: const EdgeInsets.symmetric(horizontal: 16), - height: widget.height, - alignment: Alignment.center, - child: widget.child, - ), - ), - ), - ); - - // Wrap with transform target if transformId is provided - if (widget.transformId != null) { - final transformTargetId = - 'vocab-choice-card-${widget.transformId!.replaceAll(' ', '_')}'; - card = CompositedTransformTarget( - link: MatrixState.pAnyState.layerLinkAndKey(transformTargetId).link, - child: card, - ); - } - - return card; - } -} diff --git a/lib/pangea/vocab_practice/completed_activity_session_view.dart b/lib/pangea/vocab_practice/completed_activity_session_view.dart deleted file mode 100644 index 177b6b6eb..000000000 --- a/lib/pangea/vocab_practice/completed_activity_session_view.dart +++ /dev/null @@ -1,292 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart'; -import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart'; -import 'package:fluffychat/pangea/vocab_practice/percent_marker_bar.dart'; -import 'package:fluffychat/pangea/vocab_practice/stat_card.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import 'package:fluffychat/widgets/mxc_image.dart'; - -class CompletedActivitySessionView extends StatefulWidget { - final VocabPracticeState controller; - const CompletedActivitySessionView(this.controller, {super.key}); - - @override - State createState() => - _CompletedActivitySessionViewState(); -} - -class _CompletedActivitySessionViewState - extends State { - late final Future> progressChangeFuture; - double currentProgress = 0.0; - Uri? avatarUrl; - bool shouldShowRain = false; - - @override - void initState() { - super.initState(); - - // Fetch avatar URL - final client = Matrix.of(context).client; - client.fetchOwnProfile().then((profile) { - if (mounted) { - setState(() => avatarUrl = profile.avatarUrl); - } - }); - - progressChangeFuture = widget.controller.calculateProgressChange( - widget.controller.sessionLoader.value!.totalXpGained, - ); - } - - void _onProgressChangeLoaded(Map progressChange) { - //start with before progress - currentProgress = progressChange['before'] ?? 0.0; - - //switch to after progress after first frame, to activate animation - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - currentProgress = progressChange['after'] ?? 0.0; - // Start the star rain - shouldShowRain = true; - }); - } - }); - } - - String _formatTime(int seconds) { - final minutes = seconds ~/ 60; - final remainingSeconds = seconds % 60; - return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}'; - } - - @override - Widget build(BuildContext context) { - final username = - Matrix.of(context).client.userID?.split(':').first.substring(1) ?? ''; - final bool accuracyAchievement = - widget.controller.sessionLoader.value!.accuracy == 100; - final bool timeAchievement = - widget.controller.sessionLoader.value!.elapsedSeconds <= 60; - final int numBonusPoints = widget - .controller.sessionLoader.value!.completedUses - .where((use) => use.xp > 0) - .length; - //give double bonus for both, single for one, none for zero - final int bonusXp = (accuracyAchievement && timeAchievement) - ? numBonusPoints * 2 - : (accuracyAchievement || timeAchievement) - ? numBonusPoints - : 0; - - return FutureBuilder>( - future: progressChangeFuture, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const SizedBox.shrink(); - } - - // Initialize progress when data is available - if (currentProgress == 0.0 && !shouldShowRain) { - _onProgressChangeLoaded(snapshot.data!); - } - - return Stack( - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 16, right: 16, left: 16), - child: Column( - children: [ - Text( - L10n.of(context).congratulationsYouveCompletedPractice, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: avatarUrl == null - ? Avatar( - name: username, - showPresence: false, - size: 100, - ) - : ClipOval( - child: MxcImage( - uri: avatarUrl, - width: 100, - height: 100, - ), - ), - ), - Column( - children: [ - Padding( - padding: const EdgeInsets.only( - top: 16.0, - bottom: 16.0, - ), - child: AnimatedProgressBar( - height: 20.0, - widthPercent: currentProgress, - backgroundColor: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - duration: const Duration(milliseconds: 500), - ), - ), - Text( - "+ ${widget.controller.sessionLoader.value!.totalXpGained + bonusXp} XP", - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith( - color: AppConfig.goldLight, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - StatCard( - icon: Icons.my_location, - text: - "${L10n.of(context).accuracy}: ${widget.controller.sessionLoader.value!.accuracy}%", - isAchievement: accuracyAchievement, - achievementText: "+ $numBonusPoints XP", - child: PercentMarkerBar( - height: 20.0, - widthPercent: widget - .controller.sessionLoader.value!.accuracy / - 100.0, - markerWidth: 20.0, - markerColor: AppConfig.success, - backgroundColor: !(widget.controller.sessionLoader - .value!.accuracy == - 100) - ? Theme.of(context) - .colorScheme - .surfaceContainerHighest - : Color.alphaBlend( - AppConfig.goldLight.withValues(alpha: 0.3), - Theme.of(context) - .colorScheme - .surfaceContainerHighest, - ), - ), - ), - StatCard( - icon: Icons.alarm, - text: - "${L10n.of(context).time}: ${_formatTime(widget.controller.sessionLoader.value!.elapsedSeconds)}", - isAchievement: timeAchievement, - achievementText: "+ $numBonusPoints XP", - child: TimeStarsWidget( - elapsedSeconds: widget - .controller.sessionLoader.value!.elapsedSeconds, - timeForBonus: widget - .controller.sessionLoader.value!.timeForBonus, - ), - ), - Column( - children: [ - //expanded row button - ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - vertical: 8.0, - ), - ), - onPressed: () => - widget.controller.reloadSession(), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - L10n.of(context).anotherRound, - ), - ], - ), - ), - const SizedBox(height: 16), - ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 12.0, - vertical: 8.0, - ), - ), - onPressed: () => Navigator.of(context).pop(), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - L10n.of(context).quit, - ), - ], - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - if (shouldShowRain) - const StarRainWidget( - showBlast: true, - rainDuration: Duration(seconds: 5), - ), - ], - ); - }, - ); - } -} - -class TimeStarsWidget extends StatelessWidget { - final int elapsedSeconds; - final int timeForBonus; - - const TimeStarsWidget({ - required this.elapsedSeconds, - required this.timeForBonus, - super.key, - }); - - int get starCount { - if (elapsedSeconds <= timeForBonus) return 5; - if (elapsedSeconds <= timeForBonus * 1.5) return 4; - if (elapsedSeconds <= timeForBonus * 2) return 3; - if (elapsedSeconds <= timeForBonus * 2.5) return 2; - return 1; // anything above 2.5x timeForBonus - } - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: List.generate( - 5, - (index) => Icon( - index < starCount ? Icons.star : Icons.star_outline, - color: AppConfig.goldLight, - size: 36, - ), - ), - ); - } -} diff --git a/lib/pangea/vocab_practice/vocab_practice_page.dart b/lib/pangea/vocab_practice/vocab_practice_page.dart deleted file mode 100644 index f05026c46..000000000 --- a/lib/pangea/vocab_practice/vocab_practice_page.dart +++ /dev/null @@ -1,478 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import 'package:collection/collection.dart'; - -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; -import 'package:fluffychat/pangea/common/utils/async_state.dart'; -import 'package:fluffychat/pangea/common/utils/overlay.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart'; -import 'package:fluffychat/pangea/practice_activities/activity_type_enum.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/vocab_practice/vocab_practice_session_model.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_repo.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_view.dart'; -import 'package:fluffychat/widgets/future_loading_dialog.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -class SessionLoader extends AsyncLoader { - @override - Future fetch() => - VocabPracticeSessionRepo.currentSession; -} - -class VocabPractice extends StatefulWidget { - const VocabPractice({super.key}); - - @override - VocabPracticeState createState() => VocabPracticeState(); -} - -class VocabPracticeState extends State { - SessionLoader sessionLoader = SessionLoader(); - PracticeActivityModel? currentActivity; - bool isLoadingActivity = true; - bool isAwaitingNextActivity = false; - String? activityError; - - bool isLoadingLemmaInfo = false; - final Map _choiceTexts = {}; - final Map _choiceEmojis = {}; - - StreamSubscription? _languageStreamSubscription; - bool _sessionClearedDueToLanguageChange = false; - - @override - void initState() { - super.initState(); - _startSession(); - _listenToLanguageChanges(); - } - - @override - void dispose() { - _languageStreamSubscription?.cancel(); - if (isComplete) { - VocabPracticeSessionRepo.clearSession(); - } else if (!_sessionClearedDueToLanguageChange) { - //don't save if session was cleared due to language change - _saveCurrentTime(); - } - sessionLoader.dispose(); - super.dispose(); - } - - void _saveCurrentTime() { - if (sessionLoader.isLoaded) { - VocabPracticeSessionRepo.updateSession(sessionLoader.value!); - } - } - - /// Resets all session state without disposing the widget - void _resetState() { - currentActivity = null; - isLoadingActivity = true; - isAwaitingNextActivity = false; - activityError = null; - isLoadingLemmaInfo = false; - _choiceTexts.clear(); - _choiceEmojis.clear(); - } - - bool get isComplete => - sessionLoader.isLoaded && sessionLoader.value!.hasCompletedCurrentGroup; - - double get progress => - sessionLoader.isLoaded ? sessionLoader.value!.progress : 0.0; - - int get availableActivities => sessionLoader.isLoaded - ? sessionLoader.value!.currentAvailableActivities - : 0; - - int get completedActivities => - sessionLoader.isLoaded ? sessionLoader.value!.currentIndex : 0; - - int get elapsedSeconds => - sessionLoader.isLoaded ? sessionLoader.value!.elapsedSeconds : 0; - - void updateElapsedTime(int seconds) { - if (sessionLoader.isLoaded) { - sessionLoader.value!.elapsedSeconds = seconds; - } - } - - Future _waitForAnalytics() async { - if (!MatrixState.pangeaController.matrixState.analyticsDataService - .initCompleter.isCompleted) { - MatrixState.pangeaController.initControllers(); - await MatrixState.pangeaController.matrixState.analyticsDataService - .initCompleter.future; - } - } - - void _listenToLanguageChanges() { - _languageStreamSubscription = MatrixState - .pangeaController.userController.languageStream.stream - .listen((_) async { - // If language changed, clear session and back out of vocab practice - if (await _shouldReloadSession()) { - _sessionClearedDueToLanguageChange = true; - await VocabPracticeSessionRepo.clearSession(); - if (mounted) { - Navigator.of(context).pop(); - } - } - }); - } - - Future _startSession() async { - await _waitForAnalytics(); - await sessionLoader.load(); - - // If user languages have changed since last session, clear session - if (await _shouldReloadSession()) { - await VocabPracticeSessionRepo.clearSession(); - sessionLoader.dispose(); - sessionLoader = SessionLoader(); - await sessionLoader.load(); - } - - loadActivity(); - } - - // check if current l1 and l2 have changed from those of the loaded session - Future _shouldReloadSession() async { - if (!sessionLoader.isLoaded) return false; - - final session = sessionLoader.value!; - final currentL1 = - MatrixState.pangeaController.userController.userL1?.langCode; - final currentL2 = - MatrixState.pangeaController.userController.userL2?.langCode; - - if (session.userL1 != currentL1 || session.userL2 != currentL2) { - return true; - } - return false; - } - - Future completeActivitySession() async { - if (!sessionLoader.isLoaded) return; - - _saveCurrentTime(); - sessionLoader.value!.finishSession(); - await VocabPracticeSessionRepo.updateSession(sessionLoader.value!); - - setState(() {}); - } - - Future reloadSession() async { - await showFutureLoadingDialog( - context: context, - future: () async { - // Clear current session storage, dispose old session loader, and clear state variables - await VocabPracticeSessionRepo.clearSession(); - sessionLoader.dispose(); - sessionLoader = SessionLoader(); - _resetState(); - await _startSession(); - }, - ); - - if (mounted) { - setState(() {}); - } - } - - Future?> getExampleMessage( - ConstructIdentifier construct, - ) async { - final ConstructUses constructUse = await Matrix.of(context) - .analyticsDataService - .getConstructUse(construct); - for (final use in constructUse.cappedUses) { - if (use.metadata.eventId == null || use.metadata.roomId == null) { - continue; - } - - final room = MatrixState.pangeaController.matrixState.client - .getRoomById(use.metadata.roomId!); - if (room == null) continue; - - final event = await room.getEventById(use.metadata.eventId!); - if (event == null) continue; - - final timeline = await room.getTimeline(); - final pangeaMessageEvent = PangeaMessageEvent( - event: event, - timeline: timeline, - ownMessage: event.senderId == - MatrixState.pangeaController.matrixState.client.userID, - ); - - final tokens = pangeaMessageEvent.messageDisplayRepresentation?.tokens; - if (tokens == null || tokens.isEmpty) continue; - final token = tokens.firstWhereOrNull( - (token) => token.text.content == use.form, - ); - if (token == null) continue; - - final text = pangeaMessageEvent.messageDisplayText; - final tokenText = token.text.content; - int tokenIndex = text.indexOf(tokenText); - if (tokenIndex == -1) continue; - - final beforeSubstring = text.substring(0, tokenIndex); - if (beforeSubstring.length != beforeSubstring.characters.length) { - tokenIndex = beforeSubstring.characters.length; - } - - final int tokenLength = tokenText.characters.length; - final before = text.characters.take(tokenIndex).toString(); - final after = text.characters.skip(tokenIndex + tokenLength).toString(); - return [ - TextSpan(text: before), - TextSpan( - text: tokenText, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - TextSpan(text: after), - ]; - } - - return null; - } - - Future loadActivity() async { - if (!sessionLoader.isLoaded) { - try { - await sessionLoader.completer.future; - } catch (_) { - return; - } - } - - if (!mounted) return; - - setState(() { - isAwaitingNextActivity = false; - currentActivity = null; - isLoadingActivity = true; - activityError = null; - _choiceTexts.clear(); - _choiceEmojis.clear(); - }); - - final session = sessionLoader.value!; - final activityRequest = session.currentActivityRequest; - if (activityRequest == null) { - setState(() { - activityError = L10n.of(context).noActivityRequest; - isLoadingActivity = false; - }); - return; - } - - final result = await PracticeRepo.getPracticeActivity( - activityRequest, - messageInfo: {}, - ); - if (result.isError) { - activityError = L10n.of(context).oopsSomethingWentWrong; - } else { - currentActivity = result.result!; - } - - // Prefetch lemma info for meaning activities before marking ready - if (currentActivity != null && - currentActivity!.activityType == ActivityTypeEnum.lemmaMeaning) { - final choices = currentActivity!.multipleChoiceContent!.choices.toList(); - await _prefetchLemmaInfo(choices); - } - - if (mounted) { - setState(() => isLoadingActivity = false); - } - } - - Future onSelectChoice( - ConstructIdentifier choiceConstruct, - String choiceContent, - ) async { - if (currentActivity == null) return; - final activity = currentActivity!; - - activity.onMultipleChoiceSelect(choiceConstruct, choiceContent); - final correct = activity.multipleChoiceContent!.isCorrect(choiceContent); - - // Submit answer immediately (records use and gives XP) - sessionLoader.value!.submitAnswer(activity, correct); - await VocabPracticeSessionRepo.updateSession(sessionLoader.value!); - - final transformTargetId = - 'vocab-choice-card-${choiceContent.replaceAll(' ', '_')}'; - if (correct) { - OverlayUtil.showPointsGained(transformTargetId, 5, context); - } else { - OverlayUtil.showPointsGained(transformTargetId, -1, context); - } - if (!correct) return; - - // display the fact that the choice was correct before loading the next activity - setState(() => isAwaitingNextActivity = true); - await Future.delayed(const Duration(milliseconds: 1000)); - setState(() => isAwaitingNextActivity = false); - - // Only move to next activity when answer is correct - sessionLoader.value!.completeActivity(activity); - await VocabPracticeSessionRepo.updateSession(sessionLoader.value!); - - if (isComplete) { - await completeActivitySession(); - } - - await loadActivity(); - } - - Future> calculateProgressChange(int xpGained) async { - final derivedData = await MatrixState - .pangeaController.matrixState.analyticsDataService.derivedData; - final currentLevel = derivedData.level; - final currentXP = derivedData.totalXP; - - final minXPForCurrentLevel = - DerivedAnalyticsDataModel.calculateXpWithLevel(currentLevel); - final minXPForNextLevel = derivedData.minXPForNextLevel; - - final xpRange = minXPForNextLevel - minXPForCurrentLevel; - - final progressBefore = - ((currentXP - minXPForCurrentLevel) / xpRange).clamp(0.0, 1.0); - - final newTotalXP = currentXP + xpGained; - final progressAfter = - ((newTotalXP - minXPForCurrentLevel) / xpRange).clamp(0.0, 1.0); - - return { - 'before': progressBefore, - 'after': progressAfter, - }; - } - - @override - Widget build(BuildContext context) => VocabPracticeView(this); - - String getChoiceText(String choiceId) { - if (_choiceTexts.containsKey(choiceId)) return _choiceTexts[choiceId]!; - final cId = ConstructIdentifier.fromString(choiceId); - return cId?.lemma ?? choiceId; - } - - String? getChoiceEmoji(String choiceId) => _choiceEmojis[choiceId]; - - //fetches display info for all choices from constructIDs - Future _prefetchLemmaInfo(List choiceIds) async { - if (!mounted) return; - setState(() => isLoadingLemmaInfo = true); - - final results = await Future.wait( - choiceIds.map((id) async { - final cId = ConstructIdentifier.fromString(id); - if (cId == null) { - return null; - } - try { - final result = await cId.getLemmaInfo({}); - return result; - } catch (e) { - return null; - } - }), - ); - - // Check if any result is an error - for (int i = 0; i < results.length; i++) { - final res = results[i]; - if (res != null && res.isError) { - // Clear cache for failed items so retry will fetch fresh - final failedId = choiceIds[i]; - final cId = ConstructIdentifier.fromString(failedId); - if (cId != null) { - LemmaInfoRepo.clearCache(cId.lemmaInfoRequest({})); - } - - if (mounted) { - setState(() { - activityError = L10n.of(context).oopsSomethingWentWrong; - isLoadingLemmaInfo = false; - }); - } - return; - } - // Update choice texts/emojis if successful - if (res != null && !res.isError) { - final id = choiceIds[i]; - final info = res.result!; - _choiceTexts[id] = info.meaning; - _choiceEmojis[id] = _choiceEmojis[id] ?? info.emoji.firstOrNull; - } - } - - // Check for duplicate choice texts and remove duplicates - _removeDuplicateChoices(); - - if (mounted) { - setState(() => isLoadingLemmaInfo = false); - } - } - - /// Removes duplicate choice texts, keeping the correct answer if it's a duplicate, or the first otherwise - void _removeDuplicateChoices() { - if (currentActivity?.multipleChoiceContent == null) return; - - final activity = currentActivity!.multipleChoiceContent!; - final correctAnswers = activity.answers; - - final Map> textToIds = {}; - - for (final id in _choiceTexts.keys) { - final text = _choiceTexts[id]!; - textToIds.putIfAbsent(text, () => []).add(id); - } - - // Find duplicates and remove them - final Set idsToRemove = {}; - for (final entry in textToIds.entries) { - final duplicateIds = entry.value; - if (duplicateIds.length > 1) { - // Find if any of the duplicates is the correct answer - final correctId = duplicateIds.firstWhereOrNull( - (id) => correctAnswers.contains(id), - ); - - // Remove all duplicates except one - if (correctId != null) { - idsToRemove.addAll(duplicateIds.where((id) => id != correctId)); - } else { - idsToRemove.addAll(duplicateIds.skip(1)); - } - } - } - - if (idsToRemove.isNotEmpty) { - activity.choices.removeAll(idsToRemove); - for (final id in idsToRemove) { - _choiceTexts.remove(id); - _choiceEmojis.remove(id); - } - } - } -} diff --git a/lib/pangea/vocab_practice/vocab_practice_session_model.dart b/lib/pangea/vocab_practice/vocab_practice_session_model.dart deleted file mode 100644 index 0cbe7f270..000000000 --- a/lib/pangea/vocab_practice/vocab_practice_session_model.dart +++ /dev/null @@ -1,253 +0,0 @@ -import 'dart:math'; - -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/constructs/construct_identifier.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/lemmas/lemma.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_activity_model.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -class VocabPracticeSessionModel { - final DateTime startedAt; - final List sortedConstructIds; - final List activityTypes; - final String userL1; - final String userL2; - - int currentIndex; - int currentGroup; - - final List completedUses; - bool finished; - int elapsedSeconds; - - VocabPracticeSessionModel({ - required this.startedAt, - required this.sortedConstructIds, - required this.activityTypes, - required this.userL1, - required this.userL2, - required this.completedUses, - this.currentIndex = 0, - this.currentGroup = 0, - this.finished = false, - this.elapsedSeconds = 0, - }) : assert( - activityTypes.every( - (t) => {ActivityTypeEnum.lemmaMeaning, ActivityTypeEnum.lemmaAudio} - .contains(t), - ), - ), - assert( - activityTypes.length == practiceGroupSize, - ); - - static const int practiceGroupSize = 10; - - int get currentAvailableActivities => min( - ((currentGroup + 1) * practiceGroupSize), - sortedConstructIds.length, - ); - - bool get hasCompletedCurrentGroup => - currentIndex >= currentAvailableActivities; - - int get timeForBonus => 60; - - double get progress => - (currentIndex / currentAvailableActivities).clamp(0.0, 1.0); - - List get currentPracticeGroup => sortedConstructIds - .skip(currentGroup * practiceGroupSize) - .take(practiceGroupSize) - .toList(); - - ConstructIdentifier? get currentConstructId { - if (currentIndex < 0 || hasCompletedCurrentGroup) { - return null; - } - return currentPracticeGroup[currentIndex % practiceGroupSize]; - } - - ActivityTypeEnum? get currentActivityType { - if (currentIndex < 0 || hasCompletedCurrentGroup) { - return null; - } - return activityTypes[currentIndex % practiceGroupSize]; - } - - MessageActivityRequest? get currentActivityRequest { - final constructId = currentConstructId; - if (constructId == null || currentActivityType == null) return null; - - final activityType = currentActivityType; - return MessageActivityRequest( - userL1: userL1, - userL2: userL2, - activityQualityFeedback: null, - targetTokens: [ - PangeaToken( - lemma: Lemma( - text: constructId.lemma, - saveVocab: true, - form: constructId.lemma, - ), - pos: constructId.category, - text: PangeaTokenText.fromString(constructId.lemma), - morph: {}, - ), - ], - targetType: activityType!, - targetMorphFeature: null, - ); - } - - int get totalXpGained => completedUses.fold(0, (sum, use) => sum + use.xp); - - double get accuracy { - if (completedUses.isEmpty) return 0.0; - final correct = completedUses.where((use) => use.xp > 0).length; - final result = correct / completedUses.length; - return (result * 100).truncateToDouble(); - } - - void finishSession() { - finished = true; - - // give bonus XP uses for each construct if earned - if (accuracy >= 100) { - final bonusUses = completedUses - .where((use) => use.xp > 0) - .map( - (use) => OneConstructUse( - useType: ConstructUseTypeEnum.bonus, - constructType: use.constructType, - metadata: ConstructUseMetaData( - roomId: use.metadata.roomId, - timeStamp: DateTime.now(), - ), - category: use.category, - lemma: use.lemma, - form: use.form, - xp: ConstructUseTypeEnum.bonus.pointValue, - ), - ) - .toList(); - - MatrixState - .pangeaController.matrixState.analyticsDataService.updateService - .addAnalytics( - null, - bonusUses, - ); - } - - if (elapsedSeconds <= timeForBonus) { - final bonusUses = completedUses - .where((use) => use.xp > 0) - .map( - (use) => OneConstructUse( - useType: ConstructUseTypeEnum.bonus, - constructType: use.constructType, - metadata: ConstructUseMetaData( - roomId: use.metadata.roomId, - timeStamp: DateTime.now(), - ), - category: use.category, - lemma: use.lemma, - form: use.form, - xp: ConstructUseTypeEnum.bonus.pointValue, - ), - ) - .toList(); - - MatrixState - .pangeaController.matrixState.analyticsDataService.updateService - .addAnalytics( - null, - bonusUses, - ); - } - } - - void submitAnswer(PracticeActivityModel activity, bool isCorrect) { - final useType = isCorrect - ? activity.activityType.correctUse - : activity.activityType.incorrectUse; - - final use = OneConstructUse( - useType: useType, - constructType: ConstructTypeEnum.vocab, - metadata: ConstructUseMetaData( - roomId: null, - timeStamp: DateTime.now(), - ), - category: activity.targetTokens.first.pos, - lemma: activity.targetTokens.first.lemma.text, - form: activity.targetTokens.first.lemma.text, - xp: useType.pointValue, - ); - - completedUses.add(use); - - // Give XP immediately - MatrixState.pangeaController.matrixState.analyticsDataService.updateService - .addAnalytics( - null, - [use], - ); - } - - void completeActivity(PracticeActivityModel activity) { - currentIndex += 1; - } - - factory VocabPracticeSessionModel.fromJson(Map json) { - return VocabPracticeSessionModel( - startedAt: DateTime.parse(json['startedAt'] as String), - sortedConstructIds: (json['sortedConstructIds'] as List) - .map((e) => ConstructIdentifier.fromJson(e)) - .whereType() - .toList(), - activityTypes: (json['activityTypes'] as List) - .map( - (e) => ActivityTypeEnum.values.firstWhere( - (at) => at.name == (e as String), - ), - ) - .whereType() - .toList(), - userL1: json['userL1'] as String, - userL2: json['userL2'] as String, - currentIndex: json['currentIndex'] as int, - currentGroup: json['currentGroup'] as int, - completedUses: (json['completedUses'] as List?) - ?.map((e) => OneConstructUse.fromJson(e)) - .whereType() - .toList() ?? - [], - finished: json['finished'] as bool? ?? false, - elapsedSeconds: json['elapsedSeconds'] as int? ?? 0, - ); - } - - Map toJson() { - return { - 'startedAt': startedAt.toIso8601String(), - 'sortedConstructIds': sortedConstructIds.map((e) => e.toJson()).toList(), - 'activityTypes': activityTypes.map((e) => e.name).toList(), - 'userL1': userL1, - 'userL2': userL2, - 'currentIndex': currentIndex, - 'currentGroup': currentGroup, - 'completedUses': completedUses.map((e) => e.toJson()).toList(), - 'finished': finished, - 'elapsedSeconds': elapsedSeconds, - }; - } -} diff --git a/lib/pangea/vocab_practice/vocab_practice_session_repo.dart b/lib/pangea/vocab_practice/vocab_practice_session_repo.dart deleted file mode 100644 index 955b65d98..000000000 --- a/lib/pangea/vocab_practice/vocab_practice_session_repo.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'dart:math'; - -import 'package:get_storage/get_storage.dart'; - -import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -class VocabPracticeSessionRepo { - static final GetStorage _storage = GetStorage('vocab_practice_session'); - - static Future get currentSession async { - final cached = _getCached(); - if (cached != null) { - return cached; - } - - final r = Random(); - final activityTypes = [ - ActivityTypeEnum.lemmaMeaning, - //ActivityTypeEnum.lemmaAudio, - ]; - - final types = List.generate( - VocabPracticeSessionModel.practiceGroupSize, - (_) => activityTypes[r.nextInt(activityTypes.length)], - ); - - final targets = await _fetch(); - final session = VocabPracticeSessionModel( - userL1: MatrixState.pangeaController.userController.userL1!.langCode, - userL2: MatrixState.pangeaController.userController.userL2!.langCode, - startedAt: DateTime.now(), - sortedConstructIds: targets, - activityTypes: types, - completedUses: [], - ); - await _setCached(session); - return session; - } - - static Future updateSession( - VocabPracticeSessionModel session, - ) => - _setCached(session); - - static Future reloadSession() async { - _storage.erase(); - return currentSession; - } - - static Future clearSession() => _storage.erase(); - - static Future> _fetch() async { - final constructs = await MatrixState - .pangeaController.matrixState.analyticsDataService - .getAggregatedConstructs(ConstructTypeEnum.vocab) - .then((map) => map.values.toList()); - - // maintain a Map of ConstructIDs to last use dates and a sorted list of ConstructIDs - // based on last use. Update the map / list on practice completion - final Map constructLastUseMap = {}; - final List sortedTargetIds = []; - for (final construct in constructs) { - constructLastUseMap[construct.id] = construct.lastUsed; - sortedTargetIds.add(construct.id); - } - - sortedTargetIds.sort((a, b) { - final dateA = constructLastUseMap[a]; - final dateB = constructLastUseMap[b]; - if (dateA == null && dateB == null) return 0; - if (dateA == null) return -1; - if (dateB == null) return 1; - return dateA.compareTo(dateB); - }); - - return sortedTargetIds; - } - - static VocabPracticeSessionModel? _getCached() { - final keys = List.from(_storage.getKeys()); - if (keys.isEmpty) return null; - try { - final json = _storage.read(keys.first) as Map; - return VocabPracticeSessionModel.fromJson(json); - } catch (e) { - _storage.remove(keys.first); - return null; - } - } - - static Future _setCached(VocabPracticeSessionModel session) async { - await _storage.erase(); - await _storage.write( - session.startedAt.toIso8601String(), - session.toJson(), - ); - } -} diff --git a/lib/pangea/vocab_practice/vocab_practice_view.dart b/lib/pangea/vocab_practice/vocab_practice_view.dart deleted file mode 100644 index 258251bd0..000000000 --- a/lib/pangea/vocab_practice/vocab_practice_view.dart +++ /dev/null @@ -1,333 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/l10n/l10n.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/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; -import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; -import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; -import 'package:fluffychat/pangea/vocab_practice/choice_cards/audio_choice_card.dart'; -import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart'; -import 'package:fluffychat/pangea/vocab_practice/choice_cards/meaning_choice_card.dart'; -import 'package:fluffychat/pangea/vocab_practice/completed_activity_session_view.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_timer_widget.dart'; -import 'package:fluffychat/widgets/layouts/max_width_body.dart'; - -class VocabPracticeView extends StatelessWidget { - final VocabPracticeState controller; - - const VocabPracticeView(this.controller, {super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Row( - spacing: 8.0, - children: [ - Expanded( - child: AnimatedProgressBar( - height: 20.0, - widthPercent: controller.progress, - barColor: Theme.of(context).colorScheme.primary, - ), - ), - //keep track of state to update timer - ValueListenableBuilder( - valueListenable: controller.sessionLoader.state, - builder: (context, state, __) { - if (state is AsyncLoaded) { - return VocabTimerWidget( - key: ValueKey(state.value.startedAt), - initialSeconds: state.value.elapsedSeconds, - onTimeUpdate: controller.updateElapsedTime, - isRunning: !controller.isComplete, - ); - } - return const SizedBox.shrink(); - }, - ), - ], - ), - ), - body: MaxWidthBody( - withScrolling: false, - padding: const EdgeInsets.all(0.0), - showBorder: false, - child: controller.isComplete - ? CompletedActivitySessionView(controller) - : _OngoingActivitySessionView(controller), - ), - ); - } -} - -class _OngoingActivitySessionView extends StatelessWidget { - final VocabPracticeState controller; - const _OngoingActivitySessionView(this.controller); - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: controller.sessionLoader.state, - builder: (context, state, __) { - return switch (state) { - AsyncError(:final error) => - ErrorIndicator(message: error.toString()), - AsyncLoaded(:final value) => - value.currentConstructId != null && - value.currentActivityType != null - ? _VocabActivityView( - value.currentConstructId!, - value.currentActivityType!, - controller, - ) - : const Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator.adaptive(), - ), - ), - _ => const Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator.adaptive(), - ), - ), - }; - }, - ); - } -} - -class _VocabActivityView extends StatelessWidget { - final ConstructIdentifier constructId; - final ActivityTypeEnum activityType; - final VocabPracticeState controller; - - const _VocabActivityView( - this.constructId, - this.activityType, - this.controller, - ); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - //per-activity instructions, add switch statement once there are more types - const InstructionsInlineTooltip( - instructionsEnum: InstructionsEnum.selectMeaning, - padding: EdgeInsets.symmetric(horizontal: 16.0), - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - constructId.lemma, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - _ExampleMessageWidget(controller, constructId), - Flexible( - child: _ActivityChoicesWidget( - controller, - activityType, - constructId, - ), - ), - ], - ), - ), - ], - ); - } -} - -class _ExampleMessageWidget extends StatelessWidget { - final VocabPracticeState controller; - final ConstructIdentifier constructId; - - const _ExampleMessageWidget(this.controller, this.constructId); - - @override - Widget build(BuildContext context) { - return FutureBuilder?>( - future: controller.getExampleMessage(constructId), - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data == null) { - return const SizedBox(); - } - - return Padding( - //styling like sent message bubble - padding: const EdgeInsets.all(16.0), - 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: RichText( - text: TextSpan( - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryFixed, - fontSize: - AppConfig.fontSizeFactor * AppConfig.messageFontSize, - ), - children: snapshot.data!, - ), - ), - ), - ); - }, - ); - } -} - -class _ActivityChoicesWidget extends StatelessWidget { - final VocabPracticeState controller; - final ActivityTypeEnum activityType; - final ConstructIdentifier constructId; - - const _ActivityChoicesWidget( - this.controller, - this.activityType, - this.constructId, - ); - - @override - Widget build(BuildContext context) { - if (controller.activityError != null) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - //allow try to reload activity in case of error - ErrorIndicator(message: controller.activityError!), - const SizedBox(height: 16), - TextButton.icon( - onPressed: controller.loadActivity, - icon: const Icon(Icons.refresh), - label: Text(L10n.of(context).tryAgain), - ), - ], - ); - } - - final activity = controller.currentActivity; - if (controller.isLoadingActivity || - activity == null || - (activity.activityType == ActivityTypeEnum.lemmaMeaning && - controller.isLoadingLemmaInfo)) { - return Container( - constraints: const BoxConstraints(maxHeight: 400.0), - child: const Center( - child: CircularProgressIndicator.adaptive(), - ), - ); - } - - final choices = activity.multipleChoiceContent!.choices.toList(); - return LayoutBuilder( - builder: (context, constraints) { - //Constrain max height to keep choices together on large screens, and allow shrinking to fit on smaller screens - final constrainedHeight = constraints.maxHeight.clamp(0.0, 400.0); - final cardHeight = - (constrainedHeight / (choices.length + 1)).clamp(50.0, 80.0); - - return Container( - constraints: const BoxConstraints(maxHeight: 400.0), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: choices.map((choiceId) { - final bool isEnabled = !controller.isAwaitingNextActivity; - return _buildChoiceCard( - activity: activity, - choiceId: choiceId, - cardHeight: cardHeight, - isEnabled: isEnabled, - onPressed: () => - controller.onSelectChoice(constructId, choiceId), - ); - }).toList(), - ), - ), - ); - }, - ); - } - - Widget _buildChoiceCard({ - required activity, - required String choiceId, - required double cardHeight, - required bool isEnabled, - required VoidCallback onPressed, - }) { - final isCorrect = activity.multipleChoiceContent!.isCorrect(choiceId); - - switch (activity.activityType) { - case ActivityTypeEnum.lemmaMeaning: - return MeaningChoiceCard( - key: ValueKey( - '${constructId.string}_${activityType.name}_meaning_$choiceId', - ), - choiceId: choiceId, - displayText: controller.getChoiceText(choiceId), - emoji: controller.getChoiceEmoji(choiceId), - onPressed: onPressed, - isCorrect: isCorrect, - height: cardHeight, - isEnabled: isEnabled, - ); - - case ActivityTypeEnum.lemmaAudio: - return AudioChoiceCard( - key: ValueKey( - '${constructId.string}_${activityType.name}_audio_$choiceId', - ), - text: choiceId, - onPressed: onPressed, - isCorrect: isCorrect, - height: cardHeight, - isEnabled: isEnabled, - ); - - default: - return GameChoiceCard( - key: ValueKey( - '${constructId.string}_${activityType.name}_basic_$choiceId', - ), - shouldFlip: false, - transformId: choiceId, - onPressed: onPressed, - isCorrect: isCorrect, - height: cardHeight, - isEnabled: isEnabled, - child: Text(controller.getChoiceText(choiceId)), - ); - } - } -} diff --git a/lib/utils/background_push.dart b/lib/utils/background_push.dart index 0579ea92c..98d1902f3 100644 --- a/lib/utils/background_push.dart +++ b/lib/utils/background_push.dart @@ -84,9 +84,15 @@ class BackgroundPush { const InitializationSettings( // #Pangea // android: AndroidInitializationSettings('notifications_icon'), + // iOS: DarwinInitializationSettings(), android: AndroidInitializationSettings('@mipmap/ic_launcher'), + iOS: DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + requestProvisionalPermission: false, + ), // Pangea# - iOS: DarwinInitializationSettings(), ), onDidReceiveNotificationResponse: goToRoom, ); @@ -229,19 +235,9 @@ class BackgroundPush { instance.matrix = matrix; // ignore: prefer_initializing_formals instance.onFcmError = onFcmError; - // #Pangea - instance.fullInit(); - // Pangea# return instance; } - // #Pangea - Future fullInit() => setupPush(); - - void handleLoginStateChanged(_) => setupPush(); - - StreamSubscription? onLogin; - Future cancelNotification(String roomId) async { Logs().v('Cancel notification for room', roomId); await _flutterLocalNotificationsPlugin.cancel(roomId.hashCode); @@ -275,25 +271,16 @@ class BackgroundPush { bool useDeviceSpecificAppId = false, }) async { // #Pangea - try { - // Pangea# - if (PlatformInfos.isIOS) { - //await firebase?.requestPermission(); - } - if (PlatformInfos.isAndroid) { - _flutterLocalNotificationsPlugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>() - ?.requestNotificationsPermission(); - } - // #Pangea - } catch (err, s) { - ErrorHandler.logError( - e: "Error requesting notifications permission: $err", - s: s, - data: {}, - ); - } + // if (PlatformInfos.isIOS) { + // await firebase.requestPermission(); + // } + // if (PlatformInfos.isAndroid) { + // _flutterLocalNotificationsPlugin + // .resolvePlatformSpecificImplementation< + // AndroidFlutterLocalNotificationsPlugin + // >() + // ?.requestNotificationsPermission(); + // } // Pangea# final clientName = PlatformInfos.clientName; oldTokens ??= {}; diff --git a/lib/utils/error_reporter.dart b/lib/utils/error_reporter.dart index adddefd1b..d529292da 100644 --- a/lib/utils/error_reporter.dart +++ b/lib/utils/error_reporter.dart @@ -22,6 +22,9 @@ class ErrorReporter { content: Text( l10n.oopsSomethingWentWrong, // Use the non-null L10n instance to get the error message ), + // #Pangea + showCloseIcon: true, + // Pangea# ), ); } catch (err) { diff --git a/lib/utils/localized_exception_extension.dart b/lib/utils/localized_exception_extension.dart index d50dbbbfa..0b1989b46 100644 --- a/lib/utils/localized_exception_extension.dart +++ b/lib/utils/localized_exception_extension.dart @@ -8,6 +8,8 @@ import 'package:matrix/encryption.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pages/chat/recording_dialog.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_repo.dart'; import 'package:fluffychat/pangea/common/network/requests.dart'; import 'package:fluffychat/utils/other_party_can_receive.dart'; import 'uia_request_manager.dart'; @@ -34,6 +36,14 @@ extension LocalizedExceptionExtension on Object { if (this is UnsubscribedException) { return L10n.of(context).unsubscribedResponseError; } + + if (this is InsufficientDataException) { + return L10n.of(context).notEnoughToPractice; + } + + if (this is EmptyAudioException) { + return L10n.of(context).emptyAudioError; + } // Pangea# if (this is FileTooBigMatrixException) { final exception = this as FileTooBigMatrixException; diff --git a/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart b/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart index 65bd3119c..f15e1f119 100644 --- a/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart +++ b/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart @@ -64,7 +64,8 @@ extension IsStateExtension on Event { bool get isVisibleInPangeaGui { if (!room.showActivityChatUI) { return type != EventTypes.RoomMember || - roomMemberChangeType != RoomMemberChangeType.avatar; + (roomMemberChangeType != RoomMemberChangeType.avatar && + roomMemberChangeType != RoomMemberChangeType.other); } return type != EventTypes.RoomMember; diff --git a/lib/widgets/adaptive_dialogs/user_dialog.dart b/lib/widgets/adaptive_dialogs/user_dialog.dart index 1b6e9c6ff..102101336 100644 --- a/lib/widgets/adaptive_dialogs/user_dialog.dart +++ b/lib/widgets/adaptive_dialogs/user_dialog.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; @@ -12,7 +11,6 @@ import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/adaptive_dialog_action.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/presence_builder.dart'; -import '../../utils/url_launcher.dart'; import '../future_loading_dialog.dart'; import '../hover_builder.dart'; import '../matrix.dart'; @@ -60,7 +58,9 @@ class UserDialog extends StatelessWidget { client: Matrix.of(context).client, builder: (context, presence) { if (presence == null) return const SizedBox.shrink(); - final statusMsg = presence.statusMsg; + // #Pangea + // final statusMsg = presence.statusMsg; + // Pangea# final lastActiveTimestamp = presence.lastActiveTimestamp; final presenceText = presence.currentlyActive == true ? L10n.of(context).currentlyActive @@ -145,22 +145,22 @@ class UserDialog extends StatelessWidget { style: const TextStyle(fontSize: 10), textAlign: TextAlign.center, ), - if (statusMsg != null) - SelectableLinkify( - text: statusMsg, - textScaleFactor: - MediaQuery.textScalerOf(context).scale(1), - textAlign: TextAlign.center, - options: const LinkifyOptions(humanize: false), - linkStyle: TextStyle( - color: theme.colorScheme.primary, - decoration: TextDecoration.underline, - decorationColor: theme.colorScheme.primary, - ), - onOpen: (url) => - UrlLauncher(context, url.url).launchUrl(), - ), // #Pangea + // if (statusMsg != null) + // SelectableLinkify( + // text: statusMsg, + // textScaleFactor: + // MediaQuery.textScalerOf(context).scale(1), + // textAlign: TextAlign.center, + // options: const LinkifyOptions(humanize: false), + // linkStyle: TextStyle( + // color: theme.colorScheme.primary, + // decoration: TextDecoration.underline, + // decorationColor: theme.colorScheme.primary, + // ), + // onOpen: (url) => + // UrlLauncher(context, url.url).launchUrl(), + // ), Padding( padding: const EdgeInsets.all(4.0), child: Row( diff --git a/lib/widgets/local_notifications_extension.dart b/lib/widgets/local_notifications_extension.dart index a9bd77654..ff78aca9a 100644 --- a/lib/widgets/local_notifications_extension.dart +++ b/lib/widgets/local_notifications_extension.dart @@ -6,10 +6,12 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:desktop_notifications/desktop_notifications.dart'; import 'package:matrix/matrix.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:universal_html/html.dart' as html; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/utils/client_download_content_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/platform_infos.dart'; @@ -44,6 +46,9 @@ extension LocalNotificationsExtension on MatrixState { ); if (kIsWeb) { + // #Pangea + if (html.Notification.permission != 'granted') return; + // Pangea# final avatarUrl = event.senderFromMemoryOrFallback.avatarUrl; Uri? thumbnailUri; @@ -122,6 +127,43 @@ extension LocalNotificationsExtension on MatrixState { linuxNotificationIds[roomId] = notification.id; } } + + // #Pangea + Future get notificationsEnabled { + return kIsWeb + ? Future.value(html.Notification.permission == 'granted') + : Permission.notification.isGranted; + } + + Future requestNotificationPermission() async { + try { + if (kIsWeb) { + await html.Notification.requestPermission(); + } else { + final status = await Permission.notification.request(); + if (status.isGranted) { + // Notification permissions granted + } else if (status.isDenied) { + // Notification permissions denied + } else if (status.isPermanentlyDenied) { + // Notification permissions permanently denied, open app settings + await openAppSettings(); + } + } + + notifPermissionNotifier.value = notifPermissionNotifier.value + 1; + } catch (e, s) { + final permission = await notificationsEnabled; + ErrorHandler.logError( + e: e, + s: s, + data: { + 'permission': permission, + }, + ); + } + } + // Pangea# } enum DesktopNotificationActions { seen, openChat } diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index ef3a4b537..c67c4a933 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -24,8 +24,8 @@ import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/common/utils/any_state_holder.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/join_codes/space_code_controller.dart'; import 'package:fluffychat/pangea/languages/locale_provider.dart'; +import 'package:fluffychat/pangea/user/style_settings_repo.dart'; import 'package:fluffychat/utils/client_manager.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_file_extension.dart'; import 'package:fluffychat/utils/platform_infos.dart'; @@ -85,6 +85,9 @@ class MatrixState extends State with WidgetsBindingObserver { bool? loginRegistrationSupported; BackgroundPush? backgroundPush; + // #Pangea + ValueNotifier notifPermissionNotifier = ValueNotifier(0); + // Pangea# Client get client { if (_activeClient < 0 || _activeClient >= widget.clients.length) { @@ -196,7 +199,7 @@ class MatrixState extends State with WidgetsBindingObserver { .stream .where((l) => l == LoginState.loggedIn) .first - .then((_) { + .then((_) async { // #Pangea MatrixState.pangeaController.handleLoginStateChange( LoginState.loggedIn, @@ -213,7 +216,13 @@ class MatrixState extends State with WidgetsBindingObserver { ); _registerSubs(_loginClientCandidate!.clientName); _loginClientCandidate = null; - FluffyChatApp.router.go('/rooms'); + // #Pangea + // FluffyChatApp.router.go('/rooms'); + final isL2Set = await pangeaController.userController.isUserL2Set; + FluffyChatApp.router.go( + isL2Set ? '/rooms' : '/registration/create', + ); + // Pangea# }); // #Pangea candidate.homeserver = Uri.parse("https://${AppConfig.defaultHomeserver}"); @@ -553,9 +562,18 @@ class MatrixState extends State with WidgetsBindingObserver { } void initSettings() { - AppConfig.fontSizeFactor = - double.tryParse(store.getString(SettingKeys.fontSizeFactor) ?? '') ?? - AppConfig.fontSizeFactor; + // #Pangea + // AppConfig.fontSizeFactor = + // double.tryParse(store.getString(SettingKeys.fontSizeFactor) ?? '') ?? + // AppConfig.fontSizeFactor; + if (client.isLogged()) { + StyleSettingsRepo.settings(client.userID!).then((settings) { + AppConfig.fontSizeFactor = settings.fontSizeFactor; + AppConfig.useActivityImageAsChatBackground = + settings.useActivityImageBackground; + }); + } + // Pangea# AppConfig.renderHtml = store.getBool(SettingKeys.renderHtml) ?? AppConfig.renderHtml; @@ -629,6 +647,7 @@ class MatrixState extends State with WidgetsBindingObserver { // #Pangea _languageListener?.cancel(); _uriListener?.cancel(); + notifPermissionNotifier.dispose(); // Pangea# super.dispose(); @@ -672,8 +691,14 @@ class MatrixState extends State with WidgetsBindingObserver { // #Pangea Future _processIncomingUris(Uri? uri) async { - if (uri == null) return; - await SpaceCodeController.onOpenAppViaUrl(uri); + if (uri == null || uri.fragment.isEmpty) return; + + final path = + uri.fragment.startsWith('/') ? uri.fragment : '/${uri.fragment}'; + + WidgetsBinding.instance.addPostFrameCallback((_) { + FluffyChatApp.router.go(path); + }); } // Pangea# } diff --git a/lib/widgets/navigation_rail.dart b/lib/widgets/navigation_rail.dart index 04f81476a..3fc977dee 100644 --- a/lib/widgets/navigation_rail.dart +++ b/lib/widgets/navigation_rail.dart @@ -25,6 +25,8 @@ class SpacesNavigationRail extends StatelessWidget { final double railWidth; final bool expanded; final VoidCallback collapse; + final Profile? profile; + final Function(Profile) onProfileUpdate; // Pangea# const SpacesNavigationRail({ @@ -35,7 +37,9 @@ class SpacesNavigationRail extends StatelessWidget { required this.path, required this.railWidth, required this.collapse, + required this.onProfileUpdate, this.expanded = false, + this.profile, // Pangea# super.key, }); @@ -111,23 +115,38 @@ class SpacesNavigationRail extends StatelessWidget { }, backgroundColor: Colors.transparent, icon: FutureBuilder( + // #Pangea + initialData: profile, + // Pangea# future: client.fetchOwnProfile(), - builder: (context, snapshot) => Stack( - alignment: Alignment.center, - children: [ - Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(99), - child: Avatar( - mxContent: snapshot.data?.avatarUrl, - name: snapshot.data?.displayName ?? - client.userID!.localpart, - size: - width - (isColumnMode ? 32.0 : 24.0), + // #Pangea + // builder: (context, snapshot) => Stack( + builder: (context, snapshot) { + if (snapshot.data?.avatarUrl != null && + snapshot.data?.avatarUrl != + profile?.avatarUrl) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => onProfileUpdate(snapshot.data!), + ); + } + return Stack( + // Pangea# + alignment: Alignment.center, + children: [ + Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(99), + child: Avatar( + mxContent: snapshot.data?.avatarUrl, + name: snapshot.data?.displayName ?? + client.userID!.localpart, + size: width - + (isColumnMode ? 32.0 : 24.0), + ), ), - ), - ], - ), + ], + ); + }, ), toolTip: L10n.of(context).home, // #Pangea @@ -202,7 +221,7 @@ class SpacesNavigationRail extends StatelessWidget { child: const Icon(Icons.add), ), ), - toolTip: L10n.of(context).addCourse, + toolTip: L10n.of(context).findCourse, expanded: expanded, // Pangea# ); diff --git a/pubspec.yaml b/pubspec.yaml index cfc708560..620576dd3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ description: Learn a language while texting your friends. # Pangea# publish_to: none # On version bump also increase the build number for F-Droid -version: 4.1.16+6 +version: 4.1.17+7 environment: sdk: ">=3.0.0 <4.0.0"