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] 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"