resolve conflicts

This commit is contained in:
ggurdin 2026-02-09 16:48:53 -05:00
commit 8e9a1bac24
No known key found for this signature in database
GPG key ID: A01CB41737CBB478
121 changed files with 21025 additions and 13711 deletions

47
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,47 @@
You own the docs. Three sources of truth must agree: **docs**, **code**, and **prior user guidance**. When they don't, resolve it. Update `.github/instructions/` docs when your changes shift conventions. Fix obvious factual errors (paths, class names) without asking. Flag ambiguity when sources contradict.
# client - Flutter/Dart Language Learning Chat App
## Tech Stack
- **Framework**: Flutter (SDK ≥3.0), Dart
- **Base**: Fork of FluffyChat (package name `fluffychat`)
- **Protocol**: Matrix Client-Server API via `matrix` Dart SDK
- **Subscriptions**: RevenueCat
- **Backend**: 2-step-choreographer (FastAPI) via `PApiUrls`
- **Error Tracking**: Sentry
- **Routing**: GoRouter
## Quick Reference
### Project Structure
- `lib/pages/`, `lib/widgets/`, `lib/utils/`, `lib/config/` — FluffyChat base code
- `lib/pangea/`**All Pangea language-learning code** (~30 feature modules)
- `pangea_packages/` — Shared isolate packages
- Pangea modifications in FluffyChat files marked with `// #Pangea` ... `// Pangea#`
### Key Files
- **Entry point**: `lib/main.dart`
- **Root state**: `lib/widgets/matrix.dart` (`MatrixState`)
- **Pangea controller**: `lib/pangea/common/controllers/pangea_controller.dart`
- **Routes**: `lib/config/routes.dart`
- **API URLs**: `lib/pangea/common/network/urls.dart`
- **HTTP client**: `lib/pangea/common/network/requests.dart`
- **Environment**: `lib/pangea/common/config/environment.dart` (reads `.env` / `config.sample.json`)
- **Event types**: `lib/pangea/events/constants/pangea_event_types.dart`
- **Choreographer**: `lib/pangea/choreographer/choreographer.dart`
### Conventions
- Package imports use `package:fluffychat/...`
- Feature modules follow pattern: `models/`, `repo/` (API calls), `widgets/`, `utils/`, `constants/`
- API repo files pair with request/response models (e.g., `igc_repo.dart` + `igc_request_model.dart` + `igc_response_model.dart`)
- Controllers extend `ChangeNotifier` or use `BaseController<T>` (stream-based)
## Documentation
Detailed guides auto-load from `.github/instructions/` when editing matching files:
| File | Applies To | Content |
|------|-----------|---------|
| `modules.instructions.md` | `lib/pangea/**` | Full map of ~30 feature modules |
| `choreographer.instructions.md` | `lib/pangea/choreographer/**` | Writing assistance flow (IGC, IT, text editing) |
| `events-and-tokens.instructions.md` | `lib/pangea/events/**,lib/pangea/extensions/**` | Custom Matrix events, token model, event wrappers |

View file

@ -0,0 +1,161 @@
---
applyTo: "lib/pangea/choreographer/**"
---
# Choreographer — Writing Assistance Flow
The choreographer is the client-side orchestrator for real-time writing assistance. It coordinates user text input, API calls to `/grammar_v2`, match display, and the creation of choreo records saved with sent messages.
> **⚠️ IT (Interactive Translation) is deprecated.** The `it/` directory, `ITController`, and `/it_initialstep` endpoint are still wired into the choreographer but are being phased out. IT will become just another match type returned by IGC. Do not add new IT functionality.
## Architecture
```
Choreographer (ChangeNotifier)
├── PangeaTextController ← Extended TextEditingController (tracks edit types)
├── IgcController ← Grammar check matches (primary flow)
├── ITController ← ⚠️ DEPRECATED — Translation step-by-step flow
├── ChoreographerErrorController ← Error state + backoff
└── ChoreographerStateExtension ← AssistanceStateEnum derivation
```
### Key files
| File | Purpose |
|---|---|
| `choreographer.dart` | Main orchestrator (ChangeNotifier) |
| `choreographer_state_extension.dart` | Derives `AssistanceStateEnum` from current state |
| `assistance_state_enum.dart` | UI states: noSub, noMessage, notFetched, fetching, fetched, complete, error |
| `choreo_mode_enum.dart` | `igc` (active) or `it` (⚠️ deprecated) |
| `choreo_record_model.dart` | Record of edits saved with message |
| `igc/igc_controller.dart` | IGC state management (437 lines) |
| `igc/replacement_type_enum.dart` | Granular match type taxonomy (grammar, surface, word-choice, etc.) |
| `igc/autocorrect_popup.dart` | Undo popup for auto-applied corrections |
| `text_editing/pangea_text_controller.dart` | Text controller with edit type tracking |
| `it/it_controller.dart` | ⚠️ DEPRECATED — IT state |
## Flow
### 1. User types → debounce → IGC request
1. User types in chat input. `PangeaTextController` fires `_onChange`.
2. After debounce (`ChoreoConstants.msBeforeIGCStart`), `requestWritingAssistance()` is called.
3. `IgcController.getIGCTextData()` calls `/grammar_v2` via `IgcRepo`.
4. Response contains a list of `SpanData` (matches) — grammar errors, out-of-target markers, normalization fixes.
5. Auto-apply matches (punct, diacritics, spell, cap) are accepted automatically via `acceptNormalizationMatches()`. Grammar/word-choice matches become `openMatches`.
### 2. Matches displayed → Span cards
1. Open matches render as colored underlines in the text field (colors set by `ReplacementTypeEnum.underlineColor()`).
2. Tapping a match opens a **span card** overlay (`span_card.dart`) showing the error category (`ReplacementTypeEnum.displayName()`), choices, and the error message.
3. Auto-applied corrections show an `AutocorrectPopup` with undo capability.
### 3. User resolves matches
Each match goes through `PangeaMatchState` with status transitions:
- `open``accepted` (user chose a replacement)
- `open``ignored` (user dismissed)
- `open``automatic` (auto-apply correction)
- Any → `undo` (user reverted)
When a match is accepted/ignored, the `IgcController` fires `matchUpdateStream`. The `Choreographer` listens and:
- Updates the text via `textController.setSystemText()`
- Records the step in `ChoreoRecordModel`
### 4. Feedback rerun
If the user is unsatisfied with results, `rerunWithFeedback(feedbackText)` re-calls IGC with user feedback and the previous request/response context (`_lastRequest`, `_lastResponse`).
### 5. Sending
On send, `Choreographer.getMessageContent()`:
1. Calls `/tokenize` to get `PangeaToken` data for the final text (with exponential backoff on errors).
2. Builds `PangeaMessageContentModel` containing:
- The final message text
- `ChoreoRecordModel` (full editing history)
- `PangeaRepresentation` for original written text (if IT was used)
- `PangeaMessageTokens` (token/lemma/morph data)
## AssistanceStateEnum
Derived in `choreographer_state_extension.dart`. Drives the send-button color and UI hints:
| State | Meaning |
|---|---|
| `noSub` | User has no active subscription |
| `noMessage` | Text field is empty |
| `notFetched` | Text entered but IGC hasn't run yet |
| `fetching` | IGC request in flight |
| `fetched` | Matches present — user needs to resolve them |
| `complete` | All matches resolved, ready to send |
| `error` | IGC error (backoff active) |
## ReplacementTypeEnum — Match Type Taxonomy
Defined in `igc/replacement_type_enum.dart`. Categories returned by `/grammar_v2`:
| Category | Types | Behavior |
|---|---|---|
| **Client-only** | `definition`, `practice`, `itStart` | Not from server; `itStart` triggers deprecated IT flow |
| **Grammar** (~21 types) | `verbConjugation`, `verbTense`, `verbMood`, `subjectVerbAgreement`, `genderAgreement`, `numberAgreement`, `caseError`, `article`, `preposition`, `pronoun`, `wordOrder`, `negation`, `questionFormation`, `relativeClause`, `connector`, `possessive`, `comparative`, `passiveVoice`, `conditional`, `infinitiveGerund`, `modal` | Orange underline, user must accept/ignore |
| **Surface corrections** | `punct`, `diacritics`, `spell`, `cap` | Auto-applied (no user interaction), undo via `AutocorrectPopup` |
| **Word choice** | `falseCognate`, `l1Interference`, `collocation`, `semanticConfusion` | Blue underline, user must accept/ignore |
| **Higher-level** | `transcription`, `style`, `fluency`, `didYouMean`, `translation`, `other` | Teal (style/fluency) or error color |
Key extension helpers: `isAutoApply`, `isGrammarType`, `isWordChoiceType`, `underlineColor()`, `displayName()`, `fromString()` (handles legacy snake_case and old type names like `grammar``subjectVerbAgreement`).
## Key Models
| Model | File | Purpose |
|---|---|---|
| `SpanData` | `igc/span_data_model.dart` | A match span (offset, length, choices, message, rule, `ReplacementTypeEnum`) |
| `PangeaMatch` | `igc/pangea_match_model.dart` | SpanData + status |
| `PangeaMatchState` | `igc/pangea_match_state_model.dart` | Mutable wrapper tracking original vs updated match state |
| `ChoreoRecordModel` | `choreo_record_model.dart` | Full editing history: steps, open matches, original text |
| `ChoreoRecordStepModel` | `choreo_edit_model.dart` | Single edit step (text before/after, accepted match) |
| `IGCRequestModel` | `igc/igc_request_model.dart` | Request to `/grammar_v2` |
| `IGCResponseModel` | `igc/igc_response_model.dart` | Response from `/grammar_v2` |
| `MatchRuleIdModel` | `igc/match_rule_id_model.dart` | Rule ID constants (⚠️ `tokenNeedsTranslation`, `tokenSpanNeedsTranslation`, `l1SpanAndGrammar` — not currently sent by server) |
| `AutocorrectPopup` | `igc/autocorrect_popup.dart` | Undo widget for auto-applied corrections |
## API Endpoints
| Endpoint | Repo File | Status |
|---|---|---|
| `/choreo/grammar_v2` | `igc/igc_repo.dart` | ✅ Active — primary IGC endpoint |
| `/choreo/tokenize` | `events/repo/tokens_repo.dart` | ✅ Active — tokenizes final text on send |
| `/choreo/span_details` | `igc/span_data_repo.dart` | ❌ Dead code — `SpanDataRepo` class is defined but never imported anywhere |
| `/choreo/it_initialstep` | `it/it_repo.dart` | ⚠️ Deprecated — IT flow |
| `/choreo/contextual_definition` | `contextual_definition_repo.dart` | ⚠️ Deprecated — only used by IT's `word_data_card.dart` |
## Edit Types (`EditTypeEnum`)
- `keyboard` — User typing
- `igc` — System applying IGC match
- `it` — ⚠️ Deprecated — System applying IT continuance
- `itDismissed` — ⚠️ Deprecated — IT dismissed, restoring source text
## Deprecated: SpanChoiceTypeEnum
In `igc/span_choice_type_enum.dart`:
- `bestCorrection``@Deprecated('Use suggestion instead')`
- `bestAnswer``@Deprecated('Use suggestion instead')`
- `suggestion` — Active replacement
## Error Handling
- IGC and token errors trigger exponential backoff (`_igcErrorBackoff *= 2`, `_tokenErrorBackoff *= 2`)
- Backoff resets on next successful request
- Errors surfaced via `ChoreographerErrorController`
- Error state exposed in `AssistanceStateEnum.error`
## ⚠️ Deprecated: Interactive Translation (IT)
> **Do not extend.** IT is being deprecated. Translation will become a match type within IGC.
The `it/` directory still contains `ITController`, `ITRepo`, `ITStepModel`, `CompletedITStepModel`, `GoldRouteTrackerModel`, `it_bar.dart`, `it_feedback_card.dart`, and `word_data_card.dart`. The choreographer still wires up IT via `_onOpenIT()` / `_onCloseIT()` / `_onAcceptContinuance()`, triggered when an `itStart` match is found. The `it_bar.dart` widget is still imported by `chat_input_bar.dart`.
This entire flow will be removed once testing confirms IT is no longer needed as a separate mode.

View file

@ -0,0 +1,174 @@
---
applyTo: "lib/pangea/events/**,lib/pangea/extensions/**"
---
# Events & Tokens — Matrix Event Data Model
Messages in Pangea carry rich metadata stored as Matrix events related to the main message. This doc covers custom event types, the token model, event wrappers, and how they connect.
## Event Hierarchy for a Message
When a user sends a message, the client creates a tree of related Matrix events:
```
m.room.message (the chat message)
├── pangea.representation ← PangeaRepresentation (sent text + lang)
│ ├── pangea.tokens ← PangeaMessageTokens (tokenized text)
│ └── pangea.record ← ChoreoRecordModel (editing history)
├── pangea.representation ← (optional: L1 original if IT was used)
│ └── pangea.tokens
├── pangea.translation ← Full-text translation
├── pangea.activity_req ← Request to generate practice activities
├── pangea.activity_res ← Generated practice activity
├── pangea.activity_completion ← User's activity completion record
└── pangea.stt_translation ← Speech-to-text translation
```
## Custom Event Types (`PangeaEventTypes`)
Defined in `lib/pangea/events/constants/pangea_event_types.dart`:
### Message-related
| Type | Constant | Purpose |
|---|---|---|
| `pangea.representation` | `representation` | A text representation with language code |
| `pangea.tokens` | `tokens` | Tokenized text (lemmas, POS, morphology) |
| `pangea.record` | `choreoRecord` | Choreographer editing history |
| `pangea.translation` | `translation` | Full-text translation |
| `pangea.stt_translation` | `sttTranslation` | Speech-to-text translation |
### Activities
| Type | Constant | Purpose |
|---|---|---|
| `pangea.activity_req` | `activityRequest` | Request server to generate activities |
| `pangea.activity_res` | `pangeaActivity` | A practice activity for a message |
| `pangea.activity_completion` | `activityRecord` | Per-user activity completion record |
| `pangea.activity_plan` | `activityPlan` | Activity plan definition |
| `pangea.activity_roles` | `activityRole` | Roles in a structured activity |
| `pangea.activity_summary` | `activitySummary` | Post-activity summary |
### Analytics & Learning
| Type | Constant | Purpose |
|---|---|---|
| `pangea.construct` | `construct` | A tracked learning construct |
| `pangea.construct_summary` | `constructSummary` | Aggregate construct data |
| `pangea.summaryAnalytics` | `summaryAnalytics` | Summary analytics data |
| `pangea.analytics_profile` | `profileAnalytics` | User analytics profile |
| `pangea.activities_profile` | `profileActivities` | User activities profile |
| `pangea.analytics_settings` | `analyticsSettings` | Analytics display settings |
| `p.user_lemma_info` | `userSetLemmaInfo` | User-customized lemma info |
| `p.emoji` | `userChosenEmoji` | User-chosen emoji for a word |
### Room/Course Settings
| Type | Constant | Purpose |
|---|---|---|
| `pangea.class` | `languageSettings` | Room language configuration |
| `p.rules` | `rules` | Room rules |
| `pangea.roomtopic` | `roomInfo` | Room topic info |
| `pangea.bot_options` | `botOptions` | Bot behavior configuration |
| `pangea.capacity` | `capacity` | Room capacity limit |
| `pangea.course_plan` | `coursePlan` | Course plan reference |
| `p.course_user` | `courseUser` | User's course enrollment |
| `pangea.teacher_mode` | `teacherMode` | Teacher mode toggle |
| `pangea.course_chat_list` | `courseChatList` | Course chat list |
### Audio & Media
| Type | Constant | Purpose |
|---|---|---|
| `p.audio` | `audio` | Audio attachment |
| `pangea.transcript` | `transcript` | Audio transcript |
| `p.rule.text_to_speech` | `textToSpeechRule` | TTS settings |
### User & Misc
| Type | Constant | Purpose |
|---|---|---|
| `pangea.user_age` | `userAge` | User age bracket |
| `m.report` | `report` | Content report |
| `p.rule.analytics_invite` | `analyticsInviteRule` | Analytics sharing rules |
| `p.analytics_request` | `analyticsInviteContent` | Analytics sharing request |
| `pangea.regeneration_request` | `regenerationRequest` | Content regeneration request |
| `pangea.activity_room_ids` | `activityRoomIds` | Activity room references |
## Core Data Models
### PangeaToken (`events/models/pangea_token_model.dart`)
The fundamental unit of linguistic analysis. Each token represents one word/unit.
```
PangeaToken
├── text: PangeaTokenText ← {content: "running", offset: 5}
├── lemma: Lemma ← {text: "run", saveVocab: true, form: "run"}
├── pos: String ← "VERB" (Universal Dependencies POS tag)
└── morph: Map<MorphFeaturesEnum, String> ← {Tense: "Pres", VerbForm: "Part"}
```
- POS tags follow [Universal Dependencies](https://universaldependencies.org/u/pos/)
- Morph features follow [Universal Dependencies features](https://universaldependencies.org/u/feat/)
- Lemma includes `saveVocab` flag for vocab tracking
### PangeaMessageTokens (`events/models/tokens_event_content_model.dart`)
Container for a tokenized message, stored as `pangea.tokens` event:
- `tokens: List<PangeaToken>` — tokenized words
- `detections: List<LanguageDetectionModel>?` — per-span language detection
### PangeaRepresentation (`events/models/representation_content_model.dart`)
A text representation of a message, stored as `pangea.representation` event:
- `text` — the text content
- `langCode` — detected language
- `originalSent` — true if this is the text that was actually sent
- `originalWritten` — true if this is what the user originally typed
Interpretation matrix:
| `originalSent` | `originalWritten` | Meaning |
|:-:|:-:|---|
| ✓ | ✗ | Text went through IGC/IT before sending |
| ✗ | ✗ | Added by another user (e.g., translation) |
| ✓ | ✓ | User wrote and sent as-is (L1 or perfect L2) |
| ✗ | ✓ | User's original L1 that was then translated via IT |
## Event Wrappers
### PangeaMessageEvent (`events/event_wrappers/pangea_message_event.dart`)
Wraps a Matrix `Event` of type `m.room.message` and provides access to all Pangea child events (representations, tokens, choreo records, translations, activities, etc.). This is the primary object used by the toolbar and reading assistance.
Key capabilities:
- Access tokens for the message
- Get translations and representations
- Trigger TTS/STT
- Get associated practice activities
### PangeaRepresentationEvent (`events/event_wrappers/pangea_representation_event.dart`)
Wraps a `pangea.representation` event. Provides typed access to `PangeaRepresentation` content.
### PangeaChoreoEvent (`events/event_wrappers/pangea_choreo_event.dart`)
Wraps a `pangea.record` event. Provides typed access to `ChoreoRecordModel` (editing history).
## Room Extensions for Events
`lib/pangea/extensions/room_events_extension.dart` extends Matrix `Room` with methods to:
- Query child events by type
- Find representations and tokens for a message
- Access pangea-specific event data
## Token Flow: Writing → Saving → Reading
1. **Writing**: Choreographer gets tokens from `/tokenize` on send
2. **Saving**: `PangeaMessageContentModel` bundles tokens + choreo record + representations → saved as Matrix child events
3. **Reading**: `PangeaMessageEvent` loads child events → toolbar uses `PangeaToken` list for word cards, practice activities, analytics

View file

@ -0,0 +1,127 @@
---
applyTo: "lib/pangea/**"
---
# Pangea Feature Modules (`lib/pangea/`)
Each subdirectory is a self-contained feature module. This doc provides the full map.
## Core Infrastructure
| Module | Purpose | Key Files |
|---|---|---|
| `common/controllers/` | Central controllers | `pangea_controller.dart` (owns UserController, SubscriptionController, PLanguageStore), `base_controller.dart` (stream-based generic controller) |
| `common/network/` | API communication | `urls.dart` (all choreo API URLs), `requests.dart` (HTTP client) |
| `common/config/` | Environment config | `environment.dart` (reads `.env` / `config.sample.json` for URLs, homeserver, etc.) |
| `common/constants/` | Shared constants | `local.key.dart` (storage keys), `model_keys.dart` |
| `common/models/` | Base models | `base_request_model.dart`, `llm_feedback_model.dart` |
| `common/utils/` | Shared utilities | `error_handler.dart`, `firebase_analytics.dart`, `overlay.dart`, `p_vguard.dart` (route guards) |
| `common/widgets/` | Shared widgets | `pressable_button.dart`, `overlay_container.dart`, `shimmer_background.dart`, ~20 others |
| `design_system/` | Design tokens | `tokens/` |
| `navigation/` | Navigation | `navigation_util.dart` |
## Writing Assistance (Choreographer)
| Module | Purpose | Key Files |
|---|---|---|
| `choreographer/` | Writing flow orchestrator | `choreographer.dart` (ChangeNotifier), `choreographer_state_extension.dart`, `assistance_state_enum.dart`, `choreo_record_model.dart`, `choreo_mode_enum.dart` |
| `choreographer/igc/` | Interactive Grammar Correction | `igc_controller.dart`, `igc_repo.dart`, `replacement_type_enum.dart`, `pangea_match_model.dart`, `span_card.dart`, `span_data_model.dart`, `autocorrect_popup.dart`, `autocorrect_span.dart`, `start_igc_button.dart`, `text_normalization_util.dart` |
| `choreographer/it/` | ⚠️ DEPRECATED — Interactive Translation | `it_controller.dart`, `it_repo.dart`, `it_step_model.dart`, `it_feedback_card.dart`, `word_data_card.dart` |
| `choreographer/text_editing/` | Text controller | `pangea_text_controller.dart`, `edit_type_enum.dart` |
## Message Toolbar (Reading Assistance)
| Module | Purpose | Key Files |
|---|---|---|
| `toolbar/layout/` | Overlay positioning | `message_selection_positioner.dart`, `over_message_overlay.dart`, `reading_assistance_mode_enum.dart` |
| `toolbar/reading_assistance/` | Token-level reading UX | `underline_text_widget.dart`, `token_rendering_util.dart`, `select_mode_controller.dart`, `new_word_overlay.dart` |
| `toolbar/word_card/` | Word detail card | `word_card_switcher.dart`, `reading_assistance_content.dart`, `lemma_meaning_display.dart`, `token_feedback_button.dart` |
| `toolbar/message_practice/` | In-message practice | `practice_controller.dart`, `practice_activity_card.dart`, `practice_match_card.dart`, `morph_selection.dart` |
## Events & Data Model
| Module | Purpose | Key Files |
|---|---|---|
| `events/constants/` | Event type strings | `pangea_event_types.dart` (~30 custom types) |
| `events/event_wrappers/` | Typed event wrappers | `pangea_message_event.dart`, `pangea_choreo_event.dart`, `pangea_representation_event.dart` |
| `events/models/` | Event content models | `pangea_token_model.dart`, `pangea_token_text_model.dart`, `tokens_event_content_model.dart`, `representation_content_model.dart` |
| `events/repo/` | Token/language API | `tokens_repo.dart`, `token_api_models.dart`, `language_detection_repo.dart` |
| `events/extensions/` | Event helpers | `pangea_event_extension.dart` |
| `extensions/` | Room extensions | `pangea_room_extension.dart`, `room_events_extension.dart`, `room_user_permissions_extension.dart`, etc. |
## Language & Linguistics
| Module | Purpose | Key Files |
|---|---|---|
| `languages/` | Language data | `language_model.dart`, `language_repo.dart`, `language_service.dart`, `p_language_store.dart`, `locale_provider.dart` |
| `lemmas/` | Lemma (dictionary form) | `lemma.dart`, `lemma_info_repo.dart`, `user_set_lemma_info.dart` |
| `morphs/` | Morphological analysis | `morph_models.dart`, `morph_repo.dart`, `parts_of_speech_enum.dart`, `morph_features_enum.dart` |
| `constructs/` | Grammar/vocab constructs | `construct_identifier.dart`, `construct_repo.dart`, `construct_form.dart` |
| `translation/` | Full-text translation | `full_text_translation_repo.dart` + request/response models |
| `phonetic_transcription/` | IPA transcriptions | repo + models |
## Practice & Activities
| Module | Purpose | Key Files |
|---|---|---|
| `practice_activities/` | Activity generation | `practice_activity_model.dart`, `practice_generation_repo.dart`, `multiple_choice_activity_model.dart`, type-specific generators |
| `activity_sessions/` | Session management | `activity_room_extension.dart`, `activity_session_chat/`, `activity_session_start/` |
| `activity_planner/` | Activity planning UI | `activity_plan_model.dart`, `activity_planner_page.dart` |
| `activity_generator/` | Activity creation | `activity_generator.dart`, `activity_plan_generation_repo.dart` |
| `activity_suggestions/` | Activity suggestions | `activity_suggestion_dialog.dart`, `activity_plan_search_repo.dart` |
| `activity_summary/` | Post-activity summary | `activity_summary_model.dart`, `activity_summary_repo.dart` |
| `activity_feedback/` | Activity feedback | `activity_feedback_repo.dart` + request/response |
## Analytics
| Module | Purpose | Key Files |
|---|---|---|
| `analytics_data/` | Local DB & sync | `analytics_data_service.dart`, `analytics_database.dart`, `analytics_sync_controller.dart` |
| `analytics_misc/` | Models & utilities | `construct_use_model.dart`, `constructs_model.dart`, `room_analytics_extension.dart`, `level_up/` |
| `analytics_page/` | Analytics UI | `activity_archive.dart` |
| `analytics_summary/` | Summary views | `level_analytics_details_content.dart` |
| `analytics_practice/` | Practice analytics | `analytics_practice_page.dart` |
| `analytics_details_popup/` | Detail popups | `analytics_details_popup.dart` |
| `analytics_settings/` | Analytics config | settings UI |
| `analytics_downloads/` | Analytics export | download utilities |
| `space_analytics/` | Course-level analytics | `space_analytics.dart` |
## User & Auth
| Module | Purpose | Key Files |
|---|---|---|
| `user/` | Profile & settings | `user_controller.dart`, `user_model.dart`, `analytics_profile_model.dart`, `style_settings_repo.dart` |
| `authentication/` | Login/logout | `p_login.dart`, `p_logout.dart` |
| `login/` | Signup flow pages | `pages/` — language selection, course code, signup, find course |
| `subscription/` | RevenueCat | `controllers/subscription_controller.dart`, `pages/`, `repo/` |
## Courses & Spaces
| Module | Purpose | Key Files |
|---|---|---|
| `spaces/` | Matrix Spaces extensions | `client_spaces_extension.dart`, `space_navigation_column.dart` |
| `course_creation/` | Browse/join courses | `public_course_preview.dart`, `selected_course_page.dart` |
| `course_plans/` | CMS course data | `courses/`, `course_topics/`, `course_activities/`, `course_locations/`, `course_media/` |
| `course_settings/` | Course config | `course_settings.dart`, `teacher_mode_model.dart` |
| `chat_settings/` | Room bot/language config | `models/bot_options_model.dart`, `utils/bot_client_extension.dart` |
| `chat_list/` | Chat list customization | custom chat list logic |
| `chat/` | In-chat customization | `constants/`, `extensions/`, `utils/`, `widgets/` |
| `join_codes/` | Room code invitations | `join_with_link_page.dart` |
## Media & I/O
| Module | Purpose | Key Files |
|---|---|---|
| `speech_to_text/` | STT | `speech_to_text_repo.dart` + models |
| `text_to_speech/` | TTS | `tts_controller.dart`, `text_to_speech_repo.dart` |
| `download/` | Room data export | `download_room_extension.dart`, `download_type_enum.dart` |
| `payload_client/` | CMS API client | `payload_client.dart`, `models/course_plan/` |
## Misc
| Module | Purpose | Key Files |
|---|---|---|
| `bot/` | Bot UI & utils | `utils/`, `widgets/` |
| `instructions/` | In-app tutorials | tutorial content |
| `token_info_feedback/` | Token feedback dialog | `token_info_feedback_dialog.dart`, `token_info_feedback_repo.dart` |
| `learning_settings/` | Learning preferences | `settings_learning.dart`, `tool_settings_enum.dart` |

4
.gitignore vendored
View file

@ -17,6 +17,7 @@ prime
keys.json
!/public/.env
*.env.local_choreo
assets/.env.local_choreo
*.env.prod
envs.json
# libolm package
@ -50,8 +51,7 @@ venv/
.fvm/
.fvmrc
# copilot-instructions.md
.github/copilot-instructions.md
# Web related
docs/tailwind.css

View file

@ -1,6 +1,6 @@
{
"@@locale": "ar",
"@@last_modified": "2021-08-14 12:41:10.156221",
"@@last_modified": "2026-02-09 15:31:53.731729",
"about": "حول",
"@about": {
"type": "String",
@ -10942,8 +10942,6 @@
"congratulations": "مبروك!",
"anotherRound": "جولة أخرى",
"noActivityRequest": "لا يوجد طلب نشاط حالي.",
"quit": "خروج",
"congratulationsYouveCompletedPractice": "تهانينا! لقد أكملت جلسة التدريب.",
"mustHave10Words": "يجب أن يكون لديك على الأقل 10 كلمات مفردات لممارستها. حاول التحدث إلى صديق أو بوت بانجيا لاكتشاف المزيد!",
"botSettings": "إعدادات البوت",
"activitySettingsOverrideWarning": "اللغة ومستوى اللغة محددان بواسطة خطة النشاط",
@ -10992,14 +10990,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11175,6 +11165,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "عنّي",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "تعبير اصطلاحي",
"grammarCopyPOSphrasalv": "فعل مركب",
"grammarCopyPOScompn": "مركب",
@ -11189,5 +11184,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "ممارسة مثالية!",
"greatPractice": "ممارسة رائعة!",
"usedNoHints": "عمل رائع بعدم استخدام أي تلميحات!",
"youveCompletedPractice": "لقد أكملت الممارسة، استمر في ذلك لتحسين مهاراتك!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "تغيير البريد الإلكتروني",
"withTheseAddressesDescription": "باستخدام هذه العناوين البريدية يمكنك تسجيل الدخول، واستعادة كلمة المرور، وإدارة الاشتراكات.",
"noAddressDescription": "لم تقم بإضافة أي عناوين بريد إلكتروني بعد.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "قواعد اللغة",
"spanTypeWordChoice": "اختيار الكلمات",
"spanTypeSpelling": "التهجئة",
"spanTypePunctuation": "علامات الترقيم",
"spanTypeStyle": "الأسلوب",
"spanTypeFluency": "الطلاقة",
"spanTypeAccents": "اللكنات",
"spanTypeCapitalization": "حروف الجر",
"spanTypeCorrection": "تصحيح",
"spanFeedbackTitle": "الإبلاغ عن مشكلة تصحيح",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2021-08-14 12:41:10.154280",
"@@last_modified": "2026-02-09 15:32:17.400141",
"about": "সম্পর্কে",
"@about": {
"type": "String",
@ -11336,8 +11336,6 @@
"congratulations": "অভিনন্দন!",
"anotherRound": "আরেকটি রাউন্ড",
"noActivityRequest": "বর্তমান কোন কার্যকলাপের অনুরোধ নেই।",
"quit": "বিরতি",
"congratulationsYouveCompletedPractice": "অভিনন্দন! আপনি অনুশীলন সেশন সম্পন্ন করেছেন।",
"mustHave10Words": "আপনার অনুশীলনের জন্য অন্তত 10টি শব্দ থাকতে হবে। আরও জানার জন্য একটি বন্ধুর সাথে কথা বলুন বা Pangea Bot এর সাথে কথা বলুন!",
"botSettings": "বট সেটিংস",
"activitySettingsOverrideWarning": "কার্যকলাপ পরিকল্পনার দ্বারা নির্ধারিত ভাষা এবং ভাষার স্তর",
@ -11386,14 +11384,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11569,6 +11559,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "আমার সম্পর্কে",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "বাগধারা",
"grammarCopyPOSphrasalv": "ফ্রেজাল ক্রিয়া",
"grammarCopyPOScompn": "যুগ্ম",
@ -11583,5 +11578,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "পারফেক্ট প্র্যাকটিস!",
"greatPractice": "দারুণ প্র্যাকটিস!",
"usedNoHints": "কোনো হিন্ট ব্যবহার না করার জন্য ভালো কাজ!",
"youveCompletedPractice": "আপনি প্র্যাকটিস সম্পন্ন করেছেন, উন্নতির জন্য এভাবে চালিয়ে যান!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "ইমেইল পরিবর্তন করুন",
"withTheseAddressesDescription": "এই ইমেইল ঠিকানাগুলির মাধ্যমে আপনি লগ ইন করতে, আপনার পাসওয়ার্ড পুনরুদ্ধার করতে এবং সাবস্ক্রিপশন পরিচালনা করতে পারেন।",
"noAddressDescription": "আপনি এখনও কোন ইমেইল ঠিকানা যোগ করেননি।",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "ব্যাকরণ",
"spanTypeWordChoice": "শব্দ নির্বাচন",
"spanTypeSpelling": "বানান",
"spanTypePunctuation": "বিরাম চিহ্ন",
"spanTypeStyle": "শৈলী",
"spanTypeFluency": "প্রবাহ",
"spanTypeAccents": "উচ্চারণ",
"spanTypeCapitalization": "বড় হাতের অক্ষর",
"spanTypeCorrection": "সংশোধন",
"spanFeedbackTitle": "সংশোধন সমস্যা রিপোর্ট করুন",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -3781,7 +3781,7 @@
"joinPublicTrip": "མི་ཚེས་ལ་ལོག་འབད།",
"startOwnTrip": "ངེད་རང་གི་ལོག་ལ་སྦྱོར་བཅོས།",
"@@locale": "bo",
"@@last_modified": "2026-02-05 10:10:06.262776",
"@@last_modified": "2026-02-09 15:32:12.071200",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -9993,8 +9993,6 @@
"congratulations": "Čestitamo!",
"anotherRound": "Još jedan krug",
"noActivityRequest": "Ninguna solicitud de actividad actual.",
"quit": "Salir",
"congratulationsYouveCompletedPractice": "¡Felicidades! Has completado la sesión de práctica.",
"mustHave10Words": "Debes tener al menos 10 palabras de vocabulario para practicarlas. ¡Intenta hablar con un amigo o con Pangea Bot para descubrir más!",
"botSettings": "Configuraciones del Bot",
"activitySettingsOverrideWarning": "Idioma y nivel de idioma determinados por el plan de actividad",
@ -10043,14 +10041,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -10226,6 +10216,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Bok o meni",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Phrasal Verb",
"grammarCopyPOScompn": "Compound",
@ -10240,5 +10235,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Perfekta praktik!",
"greatPractice": "Stora praktik!",
"usedNoHints": "Bra jobbat utan att använda några ledtrådar!",
"youveCompletedPractice": "Du har slutfört praktiken, fortsätt så för att bli bättre!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Bohor email",
"withTheseAddressesDescription": "S dengan alamat email ini, Anda dapat masuk, memulihkan kata sandi Anda, dan mengelola langganan.",
"noAddressDescription": "Anda belum menambahkan alamat email apa pun.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Izbor reči",
"spanTypeSpelling": "Pravopis",
"spanTypePunctuation": "Interpunkcija",
"spanTypeStyle": "Stil",
"spanTypeFluency": "Tečnost",
"spanTypeAccents": "Akcenti",
"spanTypeCapitalization": "Velika slova",
"spanTypeCorrection": "Ispravka",
"spanFeedbackTitle": "Prijavi problem sa ispravkom",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-05 10:09:47.712187",
"@@last_modified": "2026-02-09 15:31:37.024931",
"about": "Quant a",
"@about": {
"type": "String",
@ -10752,8 +10752,6 @@
"congratulations": "Felicitats!",
"anotherRound": "Una altra ronda",
"noActivityRequest": "No hi ha cap sol·licitud d'activitat actual.",
"quit": "Sortir",
"congratulationsYouveCompletedPractice": "Felicitats! Has completat la sessió de pràctica.",
"mustHave10Words": "Has de tenir almenys 10 paraules de vocabulari per practicar-les. Prova a parlar amb un amic o amb el Pangea Bot per descobrir-ne més!",
"botSettings": "Configuració del Bot",
"activitySettingsOverrideWarning": "Idioma i nivell d'idioma determinats pel pla d'activitat",
@ -10802,14 +10800,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -10985,6 +10975,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Sobre mi",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Verb Phrasal",
"grammarCopyPOScompn": "Compost",
@ -10999,5 +10994,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Pràctica perfecta!",
"greatPractice": "Gran pràctica!",
"usedNoHints": "Bon treball sense utilitzar cap pista!",
"youveCompletedPractice": "Has completat la pràctica, continua així per millorar!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Canvia l'email",
"withTheseAddressesDescription": "Amb aquestes adreces de correu electrònic pots iniciar sessió, recuperar la teva contrasenya i gestionar les subscripcions.",
"noAddressDescription": "Encara no has afegit cap adreça de correu electrònic.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramàtica",
"spanTypeWordChoice": "Elecció de paraules",
"spanTypeSpelling": "Ortografia",
"spanTypePunctuation": "Puntuació",
"spanTypeStyle": "Estil",
"spanTypeFluency": "Fluïdesa",
"spanTypeAccents": "Accents",
"spanTypeCapitalization": "Majúscules",
"spanTypeCorrection": "Correcció",
"spanFeedbackTitle": "Informar d'un problema de correcció",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "cs",
"@@last_modified": "2021-08-14 12:41:10.131133",
"@@last_modified": "2026-02-09 15:31:28.643814",
"about": "O aplikaci",
"@about": {
"type": "String",
@ -11178,8 +11178,6 @@
"congratulations": "Gratulujeme!",
"anotherRound": "Další kolo",
"noActivityRequest": "Žádná aktuální žádost o aktivitu.",
"quit": "Ukončit",
"congratulationsYouveCompletedPractice": "Gratulujeme! Dokončili jste cvičební sezení.",
"mustHave10Words": "Musíte mít alespoň 10 slovní zásoby, abyste je mohli procvičovat. Zkuste si promluvit s přítelem nebo Pangea Botem, abyste objevili více!",
"botSettings": "Nastavení bota",
"activitySettingsOverrideWarning": "Jazyk a jazyková úroveň určené plánem aktivity",
@ -11228,14 +11226,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11411,6 +11401,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "O mně",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Frázové sloveso",
"grammarCopyPOScompn": "Složenina",
@ -11425,5 +11420,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Dokonalá praxe!",
"greatPractice": "Skvělá praxe!",
"usedNoHints": "Dobrá práce, že jsi nepoužil žádné nápovědy!",
"youveCompletedPractice": "Dokončil jsi praxi, pokračuj v tom, abys se zlepšil!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Změnit e-mail",
"withTheseAddressesDescription": "S těmito e-mailovými adresami se můžete přihlásit, obnovit své heslo a spravovat předplatné.",
"noAddressDescription": "Zatím jste nepřidali žádné e-mailové adresy.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Výběr slov",
"spanTypeSpelling": "Pravopis",
"spanTypePunctuation": "Interpunkce",
"spanTypeStyle": "Styl",
"spanTypeFluency": "Plynulost",
"spanTypeAccents": "Přízvuky",
"spanTypeCapitalization": "Velká písmena",
"spanTypeCorrection": "Oprava",
"spanFeedbackTitle": "Nahlásit problém s opravou",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1926,7 +1926,7 @@
"playWithAI": "Leg med AI for nu",
"courseStartDesc": "Pangea Bot er klar til at starte når som helst!\n\n...men læring er bedre med venner!",
"@@locale": "da",
"@@last_modified": "2026-02-05 10:09:17.541713",
"@@last_modified": "2026-02-09 15:30:41.802679",
"@aboutHomeserver": {
"type": "String",
"placeholders": {
@ -11787,8 +11787,6 @@
"congratulations": "Tillykke!",
"anotherRound": "En runde mere",
"noActivityRequest": "Ingen aktuelle aktivitetsanmodning.",
"quit": "Afslut",
"congratulationsYouveCompletedPractice": "Tillykke! Du har gennemført øvelsessessionen.",
"mustHave10Words": "Du skal have mindst 10 ordforrådsord for at øve dem. Prøv at tale med en ven eller Pangea Bot for at opdage mere!",
"botSettings": "Botindstillinger",
"activitySettingsOverrideWarning": "Sprog og sprogniveau bestemt af aktivitetsplan",
@ -11837,14 +11835,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -12020,6 +12010,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Om mig",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Phrasal Verb",
"grammarCopyPOScompn": "Sammensat",
@ -12034,5 +12029,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Perfekt praksis!",
"greatPractice": "God praksis!",
"usedNoHints": "Godt klaret uden at bruge nogen hints!",
"youveCompletedPractice": "Du har gennemført praksis, bliv ved med det for at blive bedre!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Ændre email",
"withTheseAddressesDescription": "Med disse emailadresser kan du logge ind, gendanne din adgangskode og administrere abonnementer.",
"noAddressDescription": "Du har endnu ikke tilføjet nogen emailadresser.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Grammatik",
"spanTypeWordChoice": "Ordvalg",
"spanTypeSpelling": "Stavning",
"spanTypePunctuation": "Interpunktion",
"spanTypeStyle": "Stil",
"spanTypeFluency": "Flydende",
"spanTypeAccents": "Accenter",
"spanTypeCapitalization": "Versalisering",
"spanTypeCorrection": "Korrektur",
"spanFeedbackTitle": "Rapporter korrektion problem",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -5277,8 +5277,6 @@
"genericWebRecordingError": "Something went wrong. We recommend using the Chrome browser when recording messages.",
"screenSizeWarning": "For the best experience using this application, please expand your screen size.",
"noActivityRequest": "No current activity request.",
"quit": "Quit",
"congratulationsYouveCompletedPractice": "Congratulations! You've completed the practice session.",
"activitiesToUnlockTopicTitle": "Activities to Unlock Next Topic",
"activitiesToUnlockTopicDesc": "Set the number of activities to unlock the next course topic",
"mustHave10Words": "You must have at least 10 vocab words to practice them. Try talking to a friend or Pangea Bot to discover more!",
@ -5323,5 +5321,25 @@
"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.",
"spanTypeGrammar": "Grammar",
"spanTypeWordChoice": "Word Choice",
"spanTypeSpelling": "Spelling",
"spanTypePunctuation": "Punctuation",
"spanTypeStyle": "Style",
"spanTypeFluency": "Fluency",
"spanTypeAccents": "Accents",
"spanTypeCapitalization": "Capitalization",
"spanTypeCorrection": "Correction",
"spanFeedbackTitle": "Report correction issue",
"selectAllWords": "Select all the words you hear in the audio",
"aboutMeHint": "About me",
"changeEmail": "Change email",
"withTheseAddressesDescription": "With these email addresses you can log in, recover your password, and manage subscriptions.",
"noAddressDescription": "You have not added any email addresses yet.",
"perfectPractice": "Perfect practice!",
"greatPractice": "Great practice!",
"usedNoHints": "Nice job not using any hints!",
"youveCompletedPractice": "You've completed practice, keep it up to get better!",
"emptyAudioError": "Recording failed. Please check your audio permissions and try again."
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2021-08-14 12:41:10.107750",
"@@last_modified": "2026-02-09 15:32:34.842172",
"about": "Prio",
"@about": {
"type": "String",
@ -11815,8 +11815,6 @@
"congratulations": "Gratulon!",
"anotherRound": "Alia rundo",
"noActivityRequest": "Neniu aktuala aktivitecpeticio.",
"quit": "Eliri",
"congratulationsYouveCompletedPractice": "Gratulojn! Vi kompletigis la praktikadon.",
"mustHave10Words": "Vi devas havi almenaŭ 10 vortojn por praktiki ilin. Provu paroli kun amiko aŭ Pangea Bot por malkovri pli!",
"botSettings": "Botaj Agordoj",
"activitySettingsOverrideWarning": "Lingvo kaj lingvonivelo determinita de la aktiviteca plano",
@ -11865,14 +11863,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -12048,6 +12038,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Pri mi",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Phrasal Verb",
"grammarCopyPOScompn": "Kunmetita",
@ -12062,5 +12057,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Perfekta praktiko!",
"greatPractice": "Granda praktiko!",
"usedNoHints": "Bonega laboro ne uzi iujn sugestojn!",
"youveCompletedPractice": "Vi finis la praktikon, daŭrigu por pliboniĝi!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Ŝanĝi retpoŝton",
"withTheseAddressesDescription": "Kun ĉi tiuj retpoŝtaj adresoj vi povas ensaluti, rekuperi vian pasvorton, kaj administri abonojn.",
"noAddressDescription": "Vi ankoraŭ ne aldonis iujn retpoŝtajn adresojn.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatiko",
"spanTypeWordChoice": "Vortelekto",
"spanTypeSpelling": "Ortografio",
"spanTypePunctuation": "Punkto",
"spanTypeStyle": "Stilo",
"spanTypeFluency": "Flueco",
"spanTypeAccents": "Akcentoj",
"spanTypeCapitalization": "Kapitaligo",
"spanTypeCorrection": "Korektado",
"spanFeedbackTitle": "Raporti korektadon",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "es",
"@@last_modified": "2021-08-14 12:41:10.097243",
"@@last_modified": "2026-02-09 15:30:33.438936",
"about": "Acerca de",
"@about": {
"type": "String",
@ -8010,8 +8010,6 @@
"congratulations": "¡Felicidades!",
"anotherRound": "Otra ronda",
"noActivityRequest": "No hay solicitudes de actividad actuales.",
"quit": "Salir",
"congratulationsYouveCompletedPractice": "¡Felicidades! Has completado la sesión de práctica.",
"mustHave10Words": "Debes tener al menos 10 palabras de vocabulario para practicarlas. ¡Intenta hablar con un amigo o con Pangea Bot para descubrir más!",
"botSettings": "Configuración del Bot",
"activitySettingsOverrideWarning": "Idioma y nivel de idioma determinados por el plan de actividad",
@ -8060,14 +8058,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -8243,6 +8233,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Acerca de mí",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Modismo",
"grammarCopyPOSphrasalv": "Verbo Frasal",
"grammarCopyPOScompn": "Compuesto",
@ -8257,5 +8252,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "¡Práctica perfecta!",
"greatPractice": "¡Gran práctica!",
"usedNoHints": "¡Buen trabajo no usando ninguna pista!",
"youveCompletedPractice": "¡Has completado la práctica, sigue así para mejorar!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Cambiar correo electrónico",
"withTheseAddressesDescription": "Con estas direcciones de correo electrónico puedes iniciar sesión, recuperar tu contraseña y gestionar suscripciones.",
"noAddressDescription": "Aún no has añadido ninguna dirección de correo electrónico.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramática",
"spanTypeWordChoice": "Elección de palabras",
"spanTypeSpelling": "Ortografía",
"spanTypePunctuation": "Puntuación",
"spanTypeStyle": "Estilo",
"spanTypeFluency": "Fluidez",
"spanTypeAccents": "Acentos",
"spanTypeCapitalization": "Capitalización",
"spanTypeCorrection": "Corrección",
"spanFeedbackTitle": "Informar problema de corrección",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "et",
"@@last_modified": "2021-08-14 12:41:10.079944",
"@@last_modified": "2026-02-09 15:31:13.687328",
"about": "Rakenduse teave",
"@about": {
"type": "String",
@ -11133,8 +11133,6 @@
"congratulations": "Palju õnne!",
"anotherRound": "Veel üks voor",
"noActivityRequest": "Praegu ei ole aktiivsuse taotlust.",
"quit": "Välju",
"congratulationsYouveCompletedPractice": "Palju õnne! Olete lõpetanud harjut seansi.",
"mustHave10Words": "Te peate omama vähemalt 10 sõnavara sõna, et neid harjutada. Proovige rääkida sõbraga või Pangea Botiga, et rohkem avastada!",
"botSettings": "Boti seaded",
"activitySettingsOverrideWarning": "Keele ja keele taseme määrab tegevusplaan",
@ -11183,14 +11181,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11366,6 +11356,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Minust",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idioom",
"grammarCopyPOSphrasalv": "Fraasi Verb",
"grammarCopyPOScompn": "Kompleks",
@ -11380,5 +11375,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Täiuslik harjutamine!",
"greatPractice": "Suurepärane harjutamine!",
"usedNoHints": "Hea töö, et ei kasutanud mingeid vihjeid!",
"youveCompletedPractice": "Oled harjutamise lõpetanud, jätka samas vaimus, et paremaks saada!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Muuda e-posti",
"withTheseAddressesDescription": "Nende e-posti aadressidega saad sisse logida, oma parooli taastada ja tellimusi hallata.",
"noAddressDescription": "Sa ei ole veel ühtegi e-posti aadressi lisanud.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Grammatika",
"spanTypeWordChoice": "Sõnavalik",
"spanTypeSpelling": "Õigekiri",
"spanTypePunctuation": "Interpunktsioon",
"spanTypeStyle": "Stiil",
"spanTypeFluency": "Sujuvus",
"spanTypeAccents": "Aksendid",
"spanTypeCapitalization": "Suurtähed",
"spanTypeCorrection": "Parandus",
"spanFeedbackTitle": "Teata paranduse probleemist",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "eu",
"@@last_modified": "2021-08-14 12:41:10.062383",
"@@last_modified": "2026-02-09 15:31:08.773071",
"about": "Honi buruz",
"@about": {
"type": "String",
@ -10845,8 +10845,6 @@
"congratulations": "Zorionak!",
"anotherRound": "Beste txanda bat",
"noActivityRequest": "Ez dago egungo jarduera eskaerarik.",
"quit": "Irten",
"congratulationsYouveCompletedPractice": "Zorionak! Praktika saioa amaitu duzu.",
"mustHave10Words": "Gutxienez 10 hiztegi hitz izan behar dituzu praktikan jartzeko. Saiatu lagun batekin edo Pangea Bot-ekin hitz egiten gehiago ezagutzeko!",
"botSettings": "Botaren Ezarpenak",
"activitySettingsOverrideWarning": "Jarduera planak zehaztutako hizkuntza eta hizkuntza maila",
@ -10895,14 +10893,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11078,6 +11068,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Niri buruz",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Phrasal Verb",
"grammarCopyPOScompn": "Konposatu",
@ -11092,5 +11087,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Praktika perfektua!",
"greatPractice": "Praktika handia!",
"usedNoHints": "Lan ona, ez duzu inolako iradokizunik erabili!",
"youveCompletedPractice": "Praktika amaitu duzu, jarraitu horrela hobetzeko!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Aldatu posta elektronikoa",
"withTheseAddressesDescription": "Posta elektroniko helbide hauekin saioa hasi dezakezu, zure pasahitza berreskuratu eta harpidetzak kudeatu.",
"noAddressDescription": "Oraindik ez duzu posta elektroniko helbiderik gehitu.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Hitz Aukera",
"spanTypeSpelling": "Ortografia",
"spanTypePunctuation": "Puntuazioa",
"spanTypeStyle": "Estiloa",
"spanTypeFluency": "Fluentzia",
"spanTypeAccents": "Azentuak",
"spanTypeCapitalization": "Kapitalizazioa",
"spanTypeCorrection": "Zuzenketa",
"spanFeedbackTitle": "Zuzenketa arazoa txostenatu",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2021-08-14 12:41:10.061080",
"@@last_modified": "2026-02-09 15:32:19.749220",
"repeatPassword": "تکرار گذرواژه",
"@repeatPassword": {},
"about": "درباره",
@ -10718,8 +10718,6 @@
"congratulations": "تبریک می‌گویم!",
"anotherRound": "یک دور دیگر",
"noActivityRequest": "درخواست فعالیت فعلی وجود ندارد.",
"quit": "خروج",
"congratulationsYouveCompletedPractice": "تبریک! شما جلسه تمرین را کامل کرده‌اید.",
"mustHave10Words": "شما باید حداقل 10 کلمه واژگان برای تمرین داشته باشید. سعی کنید با یک دوست یا ربات پانژیا صحبت کنید تا بیشتر کشف کنید!",
"botSettings": "تنظیمات ربات",
"activitySettingsOverrideWarning": "زبان و سطح زبان تعیین شده توسط برنامه فعالیت",
@ -10768,14 +10766,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -10951,6 +10941,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "درباره من",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "اصطلاح",
"grammarCopyPOSphrasalv": "فعل عبارتی",
"grammarCopyPOScompn": "ترکیب",
@ -10965,5 +10960,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "تمرین عالی!",
"greatPractice": "تمرین فوق‌العاده!",
"usedNoHints": "کار خوبی کردید که از هیچ راهنمایی استفاده نکردید!",
"youveCompletedPractice": "شما تمرین را کامل کردید، ادامه دهید تا بهتر شوید!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "تغییر ایمیل",
"withTheseAddressesDescription": "با این آدرس‌های ایمیل می‌توانید وارد شوید، رمز عبور خود را بازیابی کنید و اشتراک‌ها را مدیریت کنید.",
"noAddressDescription": "شما هنوز هیچ آدرس ایمیلی اضافه نکرده‌اید.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "قواعد",
"spanTypeWordChoice": "انتخاب واژه",
"spanTypeSpelling": "هجی",
"spanTypePunctuation": "نقطه‌گذاری",
"spanTypeStyle": "سبک",
"spanTypeFluency": "روانی",
"spanTypeAccents": "لهجه‌ها",
"spanTypeCapitalization": "حروف بزرگ",
"spanTypeCorrection": "تصحیح",
"spanFeedbackTitle": "گزارش مشکل تصحیح",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -2783,7 +2783,7 @@
"selectAll": "Piliin lahat",
"deselectAll": "Huwag piliin lahat",
"@@locale": "fil",
"@@last_modified": "2026-02-05 10:09:53.428313",
"@@last_modified": "2026-02-09 15:31:47.672143",
"@setCustomPermissionLevel": {
"type": "String",
"placeholders": {}
@ -11702,8 +11702,6 @@
"congratulations": "Binabati kita!",
"anotherRound": "Isa pang round",
"noActivityRequest": "Walang kasalukuyang kahilingan sa aktibidad.",
"quit": "Lumabas",
"congratulationsYouveCompletedPractice": "Binabati kita! Natapos mo na ang sesyon ng pagsasanay.",
"mustHave10Words": "Dapat mayroon kang hindi bababa sa 10 salita ng bokabularyo upang sanayin ang mga ito. Subukan mong makipag-usap sa isang kaibigan o sa Pangea Bot upang matuklasan pa!",
"botSettings": "Mga Setting ng Bot",
"activitySettingsOverrideWarning": "Wika at antas ng wika na tinutukoy ng plano ng aktibidad",
@ -11752,14 +11750,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11935,6 +11925,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Tungkol sa akin",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idyoma",
"grammarCopyPOSphrasalv": "Phrasal Verb",
"grammarCopyPOScompn": "Pinagsama",
@ -11949,5 +11944,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Perpektong pagsasanay!",
"greatPractice": "Mahusay na pagsasanay!",
"usedNoHints": "Magandang trabaho sa hindi paggamit ng anumang mga pahiwatig!",
"youveCompletedPractice": "Natapos mo na ang pagsasanay, ipagpatuloy mo lang ito upang maging mas mahusay!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Palitan ang email",
"withTheseAddressesDescription": "Sa mga email address na ito, maaari kang mag-log in, i-recover ang iyong password, at pamahalaan ang mga subscription.",
"noAddressDescription": "Wala ka pang naidagdag na anumang email address.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Balarila",
"spanTypeWordChoice": "Pagpili ng Salita",
"spanTypeSpelling": "Pagbaybay",
"spanTypePunctuation": "Bantas",
"spanTypeStyle": "Estilo",
"spanTypeFluency": "Kadaluyan",
"spanTypeAccents": "Mga Tono",
"spanTypeCapitalization": "Pagkakapital",
"spanTypeCorrection": "Pagwawasto",
"spanFeedbackTitle": "Iulat ang isyu sa pagwawasto",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "fr",
"@@last_modified": "2021-08-14 12:41:10.051787",
"@@last_modified": "2026-02-09 15:32:46.273653",
"about": "À propos",
"@about": {
"type": "String",
@ -11013,8 +11013,6 @@
"congratulations": "Félicitations !",
"anotherRound": "Un autre tour",
"noActivityRequest": "Aucune demande d'activité en cours.",
"quit": "Quitter",
"congratulationsYouveCompletedPractice": "Félicitations ! Vous avez terminé la session de pratique.",
"mustHave10Words": "Vous devez avoir au moins 10 mots de vocabulaire à pratiquer. Essayez de parler à un ami ou au Pangea Bot pour en découvrir plus !",
"botSettings": "Paramètres du bot",
"activitySettingsOverrideWarning": "Langue et niveau de langue déterminés par le plan d'activité",
@ -11063,14 +11061,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11246,6 +11236,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "À propos de moi",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Verbe à particule",
"grammarCopyPOScompn": "Composé",
@ -11260,5 +11255,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Pratique parfaite !",
"greatPractice": "Super pratique !",
"usedNoHints": "Bien joué de ne pas avoir utilisé d'indices !",
"youveCompletedPractice": "Vous avez terminé la pratique, continuez comme ça pour vous améliorer !",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Changer l'email",
"withTheseAddressesDescription": "Avec ces adresses email, vous pouvez vous connecter, récupérer votre mot de passe et gérer vos abonnements.",
"noAddressDescription": "Vous n'avez pas encore ajouté d'adresses email.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Grammaire",
"spanTypeWordChoice": "Choix des mots",
"spanTypeSpelling": "Orthographe",
"spanTypePunctuation": "Ponctuation",
"spanTypeStyle": "Style",
"spanTypeFluency": "Fluidité",
"spanTypeAccents": "Accents",
"spanTypeCapitalization": "Capitalisation",
"spanTypeCorrection": "Correction",
"spanFeedbackTitle": "Signaler un problème de correction",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -4639,7 +4639,7 @@
"playWithAI": "Imir le AI faoi láthair",
"courseStartDesc": "Tá Bot Pangea réidh chun dul am ar bith!\n\n...ach is fearr foghlaim le cairde!",
"@@locale": "ga",
"@@last_modified": "2026-02-05 10:10:23.901035",
"@@last_modified": "2026-02-09 15:32:44.231605",
"@writeAMessageLangCodes": {
"type": "String",
"placeholders": {
@ -10852,8 +10852,6 @@
"congratulations": "Comhghairdeas!",
"anotherRound": "Ciorcal eile",
"noActivityRequest": "Níl aon iarratas gníomhaíochta reatha.",
"quit": "Dícheangail",
"congratulationsYouveCompletedPractice": "Comhghairdeas! Tá an seisiún cleachtaidh críochnaithe agat.",
"mustHave10Words": "Caithfidh go mbeidh 10 focal le haghaidh cleachtaidh agat ar a laghad. Bain triail as labhairt le cara nó le Pangea Bot chun tuilleadh a fháil amach!",
"botSettings": "Socruithe an Bhot",
"activitySettingsOverrideWarning": "Teanga agus leibhéal teanga a chinneadh de réir plean gníomhaíochta",
@ -10902,14 +10900,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11085,6 +11075,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Fúm",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Frása",
"grammarCopyPOSphrasalv": "Gníomhhacht Phrásúil",
"grammarCopyPOScompn": "Comhoibriú",
@ -11099,5 +11094,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Cleachtadh foirfe!",
"greatPractice": "Cleachtadh iontach!",
"usedNoHints": "Obair mhaith gan aon leideanna a úsáid!",
"youveCompletedPractice": "Tá do chleachtadh críochnaithe, coinnigh ort chun feabhsú!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Athraigh an ríomhphost",
"withTheseAddressesDescription": "Leis na seoltaí ríomhphoist seo, is féidir leat logáil isteach, do phasfhocal a chur ar ais, agus síntiúis a bhainistiú.",
"noAddressDescription": "Níl aon seoltaí ríomhphoist curtha leis fós.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramadach",
"spanTypeWordChoice": "Rogha Focal",
"spanTypeSpelling": "Litriú",
"spanTypePunctuation": "Póntú",
"spanTypeStyle": "Stíl",
"spanTypeFluency": "Sreabhadh",
"spanTypeAccents": "Guthanna",
"spanTypeCapitalization": "Caipitleadh",
"spanTypeCorrection": "Córas",
"spanFeedbackTitle": "Tuairisc a dhéanamh ar fhadhb le ceartú",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "gl",
"@@last_modified": "2021-08-14 12:41:10.040321",
"@@last_modified": "2026-02-09 15:30:36.091824",
"about": "Acerca de",
"@about": {
"type": "String",
@ -10845,8 +10845,6 @@
"congratulations": "Parabéns!",
"anotherRound": "Outra ronda",
"noActivityRequest": "Non hai solicitudes de actividade actuais.",
"quit": "Saír",
"congratulationsYouveCompletedPractice": "Parabéns! Completaches a sesión de práctica.",
"mustHave10Words": "Debes ter polo menos 10 palabras de vocabulario para practicálas. Intenta falar cun amigo ou co Pangea Bot para descubrir máis!",
"botSettings": "Configuración do Bot",
"activitySettingsOverrideWarning": "Idioma e nivel de idioma determinados polo plan de actividade",
@ -10895,14 +10893,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11078,6 +11068,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Sobre min",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Verbo Frasal",
"grammarCopyPOScompn": "Composto",
@ -11092,5 +11087,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Práctica perfecta!",
"greatPractice": "Gran práctica!",
"usedNoHints": "Bo traballo sen usar pistas!",
"youveCompletedPractice": "Completaches a práctica, segue así para mellorar!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Cambiar correo electrónico",
"withTheseAddressesDescription": "Con estes enderezos de correo electrónico podes iniciar sesión, recuperar a túa contrasinal e xestionar subscricións.",
"noAddressDescription": "Non engadiches ningún enderezo de correo electrónico aínda.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramática",
"spanTypeWordChoice": "Escolma de Palabras",
"spanTypeSpelling": "Ortografía",
"spanTypePunctuation": "Pontuación",
"spanTypeStyle": "Estilo",
"spanTypeFluency": "Fluidez",
"spanTypeAccents": "Acentos",
"spanTypeCapitalization": "Capitalización",
"spanTypeCorrection": "Corrección",
"spanFeedbackTitle": "Informar de problemas de corrección",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2021-08-14 12:41:10.036931",
"@@last_modified": "2026-02-09 15:31:01.183623",
"about": "אודות",
"@about": {
"type": "String",
@ -11775,8 +11775,6 @@
"congratulations": "מזל טוב!",
"anotherRound": "סיבוב נוסף",
"noActivityRequest": "אין בקשת פעילות נוכחית.",
"quit": "צא",
"congratulationsYouveCompletedPractice": "מזל טוב! סיימת את מושב האימון.",
"mustHave10Words": "עליך שיהיו לפחות 10 מילים לאוצר מילים כדי לתרגל אותן. נסה לדבר עם חבר או עם פנגיאה בוט כדי לגלות עוד!",
"botSettings": "הגדרות בוט",
"activitySettingsOverrideWarning": "שפה ורמת שפה נקבעות על ידי תוכנית הפעילות",
@ -11825,14 +11823,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -12008,6 +11998,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "עליי",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "ביטוי",
"grammarCopyPOSphrasalv": "פועל פיזי",
"grammarCopyPOScompn": "מורכב",
@ -12022,5 +12017,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "תרגול מושלם!",
"greatPractice": "תרגול נהדר!",
"usedNoHints": "עבודה טובה שלא השתמשת ברמזים!",
"youveCompletedPractice": "סיימת את התרגול, המשך כך כדי להשתפר!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "שנה דוא\"ל",
"withTheseAddressesDescription": "עם כתובות הדוא\"ל הללו אתה יכול להתחבר, לשחזר את הסיסמה שלך ולנהל מנויים.",
"noAddressDescription": "עדיין לא הוספת כתובות דוא\"ל.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "דקדוק",
"spanTypeWordChoice": "בחירת מילים",
"spanTypeSpelling": "איות",
"spanTypePunctuation": "פיסוק",
"spanTypeStyle": "סגנון",
"spanTypeFluency": "שפה רהוטה",
"spanTypeAccents": "הטעמה",
"spanTypeCapitalization": "הגדלת אותיות",
"spanTypeCorrection": "תיקון",
"spanFeedbackTitle": "דווח על בעיית תיקון",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -3999,7 +3999,7 @@
"playWithAI": "अभी के लिए एआई के साथ खेलें",
"courseStartDesc": "पैंजिया बॉट कभी भी जाने के लिए तैयार है!\n\n...लेकिन दोस्तों के साथ सीखना बेहतर है!",
"@@locale": "hi",
"@@last_modified": "2026-02-05 10:10:16.696075",
"@@last_modified": "2026-02-09 15:32:32.199640",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -11339,8 +11339,6 @@
"congratulations": "बधाई हो!",
"anotherRound": "एक और राउंड",
"noActivityRequest": "कोई वर्तमान गतिविधि अनुरोध नहीं है।",
"quit": "बंद करें",
"congratulationsYouveCompletedPractice": "बधाई हो! आपने अभ्यास सत्र पूरा कर लिया है।",
"mustHave10Words": "आपके पास उन्हें अभ्यास करने के लिए कम से कम 10 शब्द होने चाहिए। अधिक जानने के लिए किसी मित्र या Pangea Bot से बात करने की कोशिश करें!",
"botSettings": "बॉट सेटिंग्स",
"activitySettingsOverrideWarning": "भाषा और भाषा स्तर गतिविधि योजना द्वारा निर्धारित किया गया है",
@ -11389,14 +11387,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11572,6 +11562,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "मेरे बारे में",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "मुहावरा",
"grammarCopyPOSphrasalv": "फ्रेज़ल वर्ब",
"grammarCopyPOScompn": "संयुक्त",
@ -11586,5 +11581,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "संपूर्ण अभ्यास!",
"greatPractice": "महान अभ्यास!",
"usedNoHints": "कोई संकेत न उपयोग करने के लिए अच्छा काम!",
"youveCompletedPractice": "आपने अभ्यास पूरा कर लिया है, बेहतर होने के लिए इसे जारी रखें!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "ईमेल बदलें",
"withTheseAddressesDescription": "इन ईमेल पतों के साथ आप लॉग इन कर सकते हैं, अपना पासवर्ड पुनर्प्राप्त कर सकते हैं, और सब्सक्रिप्शन प्रबंधित कर सकते हैं।",
"noAddressDescription": "आपने अभी तक कोई ईमेल पता नहीं जोड़ा है।",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "व्याकरण",
"spanTypeWordChoice": "शब्द चयन",
"spanTypeSpelling": "वर्तनी",
"spanTypePunctuation": "विराम चिह्न",
"spanTypeStyle": "शैली",
"spanTypeFluency": "धाराप्रवाहता",
"spanTypeAccents": "उच्चारण",
"spanTypeCapitalization": "बड़े अक्षर",
"spanTypeCorrection": "सुधार",
"spanFeedbackTitle": "सुधार समस्या की रिपोर्ट करें",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "hr",
"@@last_modified": "2021-08-14 12:41:10.025984",
"@@last_modified": "2026-02-09 15:30:58.744003",
"about": "Informacije",
"@about": {
"type": "String",
@ -11085,8 +11085,6 @@
"congratulations": "Čestitamo!",
"anotherRound": "Još jedan krug",
"noActivityRequest": "Nema trenutnog zahtjeva za aktivnost.",
"quit": "Izlaz",
"congratulationsYouveCompletedPractice": "Čestitamo! Završili ste sesiju vježbanja.",
"mustHave10Words": "Morate imati najmanje 10 riječi za vokabular kako biste ih vježbali. Pokušajte razgovarati s prijateljem ili Pangea Botom kako biste otkrili više!",
"botSettings": "Postavke Bota",
"activitySettingsOverrideWarning": "Jezik i razina jezika određeni planom aktivnosti",
@ -11135,14 +11133,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11318,6 +11308,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "O meni",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Phrasalni Glagol",
"grammarCopyPOScompn": "Složenica",
@ -11332,5 +11327,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Savršena praksa!",
"greatPractice": "Sjajna praksa!",
"usedNoHints": "Odlično, niste koristili nikakve savjete!",
"youveCompletedPractice": "Završili ste praksu, nastavite tako da postanete bolji!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Promijeni email",
"withTheseAddressesDescription": "S ovim email adresama možete se prijaviti, oporaviti svoju lozinku i upravljati pretplatama.",
"noAddressDescription": "Još niste dodali nijednu email adresu.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Odabir riječi",
"spanTypeSpelling": "Pravopis",
"spanTypePunctuation": "Interpunkcija",
"spanTypeStyle": "Stil",
"spanTypeFluency": "Tečnost",
"spanTypeAccents": "Naglasci",
"spanTypeCapitalization": "Velika slova",
"spanTypeCorrection": "Ispravak",
"spanFeedbackTitle": "Prijavi problem s ispravkom",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "hu",
"@@last_modified": "2021-08-14 12:41:10.016566",
"@@last_modified": "2026-02-09 15:30:44.809310",
"about": "Névjegy",
"@about": {
"type": "String",
@ -10729,8 +10729,6 @@
"congratulations": "Gratulálunk!",
"anotherRound": "Még egy kör",
"noActivityRequest": "Jelenleg nincs aktivitás kérés.",
"quit": "Kilépés",
"congratulationsYouveCompletedPractice": "Gratulálunk! Befejezted a gyakorló ülést.",
"mustHave10Words": "Legalább 10 szókincsszót kellene gyakorolnod. Próbálj meg beszélni egy baráttal vagy a Pangea Bot-tal, hogy többet felfedezhess!",
"botSettings": "Bot beállítások",
"activitySettingsOverrideWarning": "A nyelvet és a nyelvi szintet az aktivitási terv határozza meg",
@ -10779,14 +10777,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -10962,6 +10952,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Rólam",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idióma",
"grammarCopyPOSphrasalv": "Frazális ige",
"grammarCopyPOScompn": "Összetett",
@ -10976,5 +10971,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Tökéletes gyakorlás!",
"greatPractice": "Nagyszerű gyakorlás!",
"usedNoHints": "Jó munka, hogy nem használtál semmilyen tippet!",
"youveCompletedPractice": "Befejezted a gyakorlást, folytasd így, hogy jobb legyél!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Email cím módosítása",
"withTheseAddressesDescription": "Ezekkel az email címekkel be tudsz jelentkezni, vissza tudod állítani a jelszavadat, és kezelni tudod az előfizetéseket.",
"noAddressDescription": "Még nem adtál hozzá email címeket.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Nyelvtan",
"spanTypeWordChoice": "Szóválasztás",
"spanTypeSpelling": "Helyesírás",
"spanTypePunctuation": "Írásjelek",
"spanTypeStyle": "Stílus",
"spanTypeFluency": "Folyékonyság",
"spanTypeAccents": "Akcentusok",
"spanTypeCapitalization": "Nagybetűs írás",
"spanTypeCorrection": "Javítás",
"spanFeedbackTitle": "Javítási probléma jelentése",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1954,7 +1954,7 @@
"playWithAI": "Joca con le IA pro ora",
"courseStartDesc": "Pangea Bot es preste a comenzar a qualunque momento!\n\n...ma apprender es melior con amicos!",
"@@locale": "ia",
"@@last_modified": "2026-02-05 10:09:29.962506",
"@@last_modified": "2026-02-09 15:31:03.556297",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -11804,8 +11804,6 @@
"congratulations": "Gratulon!",
"anotherRound": "Alia rundo",
"noActivityRequest": "Ninguna solicitud de actividad actual.",
"quit": "Salir",
"congratulationsYouveCompletedPractice": "¡Felicidades! Has completado la sesión de práctica.",
"mustHave10Words": "Debes tener al menos 10 palabras de vocabulario para practicarlas. ¡Intenta hablar con un amigo o con Pangea Bot para descubrir más!",
"botSettings": "Configuraciones del Bot",
"activitySettingsOverrideWarning": "Idioma y nivel de idioma determinados por el plan de actividad",
@ -11854,14 +11852,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -12037,6 +12027,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Despre mine",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Verbo Phrasal",
"grammarCopyPOScompn": "Compuesto",
@ -12051,5 +12046,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Praktiko perfekta!",
"greatPractice": "Praktiko granda!",
"usedNoHints": "Bonega laboro ne uzante iujn indikojn!",
"youveCompletedPractice": "Vi finis la praktikon, daŭrigu por pliboniĝi!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Cambia email",
"withTheseAddressesDescription": "Con estas direcciones de correo electrónico puedes iniciar sesión, recuperar tu contraseña y gestionar suscripciones.",
"noAddressDescription": "Aún no has añadido ninguna dirección de correo electrónico.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Escolha de Palavras",
"spanTypeSpelling": "Ortografia",
"spanTypePunctuation": "Pontuação",
"spanTypeStyle": "Estilo",
"spanTypeFluency": "Fluência",
"spanTypeAccents": "Acentos",
"spanTypeCapitalization": "Capitalização",
"spanTypeCorrection": "Correção",
"spanFeedbackTitle": "Relatar problema de correção",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2021-08-14 12:41:10.002360",
"@@last_modified": "2026-02-09 15:30:47.056945",
"setAsCanonicalAlias": "Atur sebagai alias utama",
"@setAsCanonicalAlias": {
"type": "String",
@ -10728,8 +10728,6 @@
"congratulations": "Selamat!",
"anotherRound": "Putaran lain",
"noActivityRequest": "Tidak ada permintaan aktivitas saat ini.",
"quit": "Keluar",
"congratulationsYouveCompletedPractice": "Selamat! Anda telah menyelesaikan sesi latihan.",
"mustHave10Words": "Anda harus memiliki setidaknya 10 kata kosakata untuk berlatih. Cobalah berbicara dengan teman atau Pangea Bot untuk menemukan lebih banyak!",
"botSettings": "Pengaturan Bot",
"activitySettingsOverrideWarning": "Bahasa dan tingkat bahasa ditentukan oleh rencana aktivitas",
@ -10778,14 +10776,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -10961,6 +10951,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Tentang saya",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Kata Kerja Phrasal",
"grammarCopyPOScompn": "Kombinasi",
@ -10975,5 +10970,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Latihan yang sempurna!",
"greatPractice": "Latihan yang hebat!",
"usedNoHints": "Kerja bagus tidak menggunakan petunjuk!",
"youveCompletedPractice": "Anda telah menyelesaikan latihan, teruskan untuk menjadi lebih baik!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Ubah email",
"withTheseAddressesDescription": "Dengan alamat email ini, Anda dapat masuk, memulihkan kata sandi Anda, dan mengelola langganan.",
"noAddressDescription": "Anda belum menambahkan alamat email apapun.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Tata Bahasa",
"spanTypeWordChoice": "Pilihan Kata",
"spanTypeSpelling": "Ejaan",
"spanTypePunctuation": "Tanda Baca",
"spanTypeStyle": "Gaya",
"spanTypeFluency": "Kefasihan",
"spanTypeAccents": "Aksen",
"spanTypeCapitalization": "Kapitalisasi",
"spanTypeCorrection": "Koreksi",
"spanFeedbackTitle": "Laporkan masalah koreksi",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -4000,7 +4000,7 @@
"playWithAI": "Joca con AI pro ora",
"courseStartDesc": "Pangea Bot es preste a partir a qualunque momento!\n\n...ma apprender es melior con amicos!",
"@@locale": "ie",
"@@last_modified": "2026-02-05 10:09:26.195275",
"@@last_modified": "2026-02-09 15:30:55.872849",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -11340,8 +11340,6 @@
"congratulations": "Gratulon!",
"anotherRound": "Alia rundo",
"noActivityRequest": "Ninguna solicitud de actividad actual.",
"quit": "Salir",
"congratulationsYouveCompletedPractice": "¡Felicidades! Has completado la sesión de práctica.",
"mustHave10Words": "Debes tener al menos 10 palabras de vocabulario para practicarlas. ¡Intenta hablar con un amigo o con Pangea Bot para descubrir más!",
"botSettings": "Configuración del Bot",
"activitySettingsOverrideWarning": "Idioma y nivel de idioma determinados por el plan de actividad",
@ -11390,14 +11388,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11573,6 +11563,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Faoi m'ainm",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Phrasal Verb",
"grammarCopyPOScompn": "Composé",
@ -11587,5 +11582,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Praktika perfekt!",
"greatPractice": "Praktika granda!",
"usedNoHints": "Bonega laboro ne uzante iujn indicojn!",
"youveCompletedPractice": "Vi kompletigis la praktikon, daŭrigu por pliboniĝi!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Mudara e-posta",
"withTheseAddressesDescription": "Izi e-posta adresleri ile giriş yapabilir, şifrenizi kurtarabilir ve aboneliklerinizi yönetebilirsiniz.",
"noAddressDescription": "Henüz herhangi bir e-posta adresi eklemediniz.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatica",
"spanTypeWordChoice": "Elección de palabras",
"spanTypeSpelling": "Ortografía",
"spanTypePunctuation": "Puntuación",
"spanTypeStyle": "Estilo",
"spanTypeFluency": "Fluidez",
"spanTypeAccents": "Acentos",
"spanTypeCapitalization": "Capitalización",
"spanTypeCorrection": "Corrección",
"spanFeedbackTitle": "Informar problema de corrección",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2021-08-14 12:41:09.992206",
"@@last_modified": "2026-02-09 15:31:23.943569",
"about": "Informazioni",
"@about": {
"type": "String",
@ -10736,8 +10736,6 @@
"congratulations": "Congratulazioni!",
"anotherRound": "Un altro turno",
"noActivityRequest": "Nessuna richiesta di attività attuale.",
"quit": "Esci",
"congratulationsYouveCompletedPractice": "Congratulazioni! Hai completato la sessione di pratica.",
"mustHave10Words": "Devi avere almeno 10 parole di vocabolario per praticarle. Prova a parlare con un amico o con Pangea Bot per scoprire di più!",
"botSettings": "Impostazioni del Bot",
"activitySettingsOverrideWarning": "Lingua e livello di lingua determinati dal piano di attività",
@ -10786,14 +10784,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -10969,6 +10959,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Informazioni su di me",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Verbo Frazionale",
"grammarCopyPOScompn": "Composto",
@ -10983,5 +10978,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Pratica perfetta!",
"greatPractice": "Ottima pratica!",
"usedNoHints": "Ottimo lavoro non usando suggerimenti!",
"youveCompletedPractice": "Hai completato la pratica, continua così per migliorare!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Cambia email",
"withTheseAddressesDescription": "Con queste email puoi accedere, recuperare la tua password e gestire gli abbonamenti.",
"noAddressDescription": "Non hai ancora aggiunto alcun indirizzo email.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Grammatica",
"spanTypeWordChoice": "Scelta delle parole",
"spanTypeSpelling": "Ortografia",
"spanTypePunctuation": "Punteggiatura",
"spanTypeStyle": "Stile",
"spanTypeFluency": "Fluidità",
"spanTypeAccents": "Accenti",
"spanTypeCapitalization": "Capitalizzazione",
"spanTypeCorrection": "Correzione",
"spanFeedbackTitle": "Segnala problema di correzione",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "ja",
"@@last_modified": "2021-08-14 12:41:09.978060",
"@@last_modified": "2026-02-09 15:32:29.891676",
"about": "このアプリについて",
"@about": {
"type": "String",
@ -11516,8 +11516,6 @@
"congratulations": "おめでとうございます!",
"anotherRound": "もう一回",
"noActivityRequest": "現在のアクティビティリクエストはありません。",
"quit": "終了",
"congratulationsYouveCompletedPractice": "おめでとうございます!練習セッションを完了しました。",
"mustHave10Words": "練習するには、少なくとも10語の語彙が必要です。友達やPangea Botに話しかけて、もっと発見してみてください",
"botSettings": "ボット設定",
"activitySettingsOverrideWarning": "アクティビティプランによって決定された言語と言語レベル",
@ -11566,14 +11564,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11749,6 +11739,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "私について",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "イディオム",
"grammarCopyPOSphrasalv": "句動詞",
"grammarCopyPOScompn": "複合語",
@ -11763,5 +11758,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "完璧な練習!",
"greatPractice": "素晴らしい練習!",
"usedNoHints": "ヒントを使わずによくやった!",
"youveCompletedPractice": "練習を完了しました。これを続けて上達してください!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "メールアドレスを変更",
"withTheseAddressesDescription": "これらのメールアドレスを使用して、ログイン、パスワードの回復、サブスクリプションの管理ができます。",
"noAddressDescription": "まだメールアドレスを追加していません。",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "文法",
"spanTypeWordChoice": "単語の選択",
"spanTypeSpelling": "スペル",
"spanTypePunctuation": "句読点",
"spanTypeStyle": "スタイル",
"spanTypeFluency": "流暢さ",
"spanTypeAccents": "アクセント",
"spanTypeCapitalization": "大文字化",
"spanTypeCorrection": "修正",
"spanFeedbackTitle": "修正の問題を報告する",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -2590,7 +2590,7 @@
"playWithAI": "ამ დროისთვის ითამაშეთ AI-თან",
"courseStartDesc": "Pangea Bot მზადაა ნებისმიერ დროს გასასვლელად!\n\n...მაგრამ სწავლა უკეთესია მეგობრებთან ერთად!",
"@@locale": "ka",
"@@last_modified": "2026-02-05 10:10:20.523925",
"@@last_modified": "2026-02-09 15:32:39.485983",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -11756,8 +11756,6 @@
"congratulations": "გილოცავთ!",
"anotherRound": "მეორე რაუნდი",
"noActivityRequest": "ამჟამად აქტივობის მოთხოვნა არ არის.",
"quit": "გამოსვლა",
"congratulationsYouveCompletedPractice": "გილოცავთ! თქვენ დაასრულეთ პრაქტიკის სესია.",
"mustHave10Words": "თქვენ უნდა გქონდეთ მინიმუმ 10 სიტყვა, რომ მათ პრაქტიკაში გამოიყენოთ. სცადეთ მეგობართან ან Pangea Bot-თან საუბარი, რომ მეტი აღმოაჩინოთ!",
"botSettings": "ბოტის პარამეტრები",
"activitySettingsOverrideWarning": "ენასა და ენების დონეს განსაზღვრავს აქტივობის გეგმა",
@ -11806,14 +11804,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11989,6 +11979,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "ჩემზე",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "იდიომი",
"grammarCopyPOSphrasalv": "ფრაზული ზმნა",
"grammarCopyPOScompn": "კომპლექსური",
@ -12003,5 +11998,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "შესანიშნავი პრაქტიკა!",
"greatPractice": "დიდებული პრაქტიკა!",
"usedNoHints": "კარგი საქმე, რომ არ გამოიყენე არცერთი მინიშნება!",
"youveCompletedPractice": "თქვენ დაასრულეთ პრაქტიკა, გააგრძელეთ ასე, რომ უკეთესი გახდეთ!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "ელ. ფოსტის შეცვლა",
"withTheseAddressesDescription": "ამ ელ. ფოსტის მისამართების საშუალებით შეგიძლიათ შეხვიდეთ, აღადგინოთ თქვენი პაროლი და მართოთ გამოწერები.",
"noAddressDescription": "თქვენ ჯერ არ გაქვთ დამატებული ელ. ფოსტის მისამართები.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "გრამატიკა",
"spanTypeWordChoice": "სიტყვების არჩევანი",
"spanTypeSpelling": "წერა",
"spanTypePunctuation": "ნიშნის გამოყენება",
"spanTypeStyle": "სტილი",
"spanTypeFluency": "მიმდინარე",
"spanTypeAccents": "აქცენტები",
"spanTypeCapitalization": "დიდი ასოები",
"spanTypeCorrection": "კორექტირება",
"spanFeedbackTitle": "შეტყობინება კორექტირების პრობლემაზე",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2021-08-14 12:41:09.975135",
"@@last_modified": "2026-02-09 15:30:29.458374",
"about": "소개",
"@about": {
"type": "String",
@ -10818,8 +10818,6 @@
"congratulations": "축하합니다!",
"anotherRound": "또 다른 라운드",
"noActivityRequest": "현재 활동 요청이 없습니다.",
"quit": "종료",
"congratulationsYouveCompletedPractice": "축하합니다! 연습 세션을 완료했습니다.",
"mustHave10Words": "연습할 단어가 최소 10개 이상 있어야 합니다. 친구나 Pangea Bot과 대화하여 더 많은 것을 발견해 보세요!",
"botSettings": "봇 설정",
"activitySettingsOverrideWarning": "활동 계획에 의해 결정된 언어 및 언어 수준",
@ -10868,14 +10866,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11051,6 +11041,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "내 소개",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "관용구",
"grammarCopyPOSphrasalv": "구동사",
"grammarCopyPOScompn": "복합어",
@ -11065,5 +11060,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "완벽한 연습!",
"greatPractice": "훌륭한 연습!",
"usedNoHints": "힌트를 사용하지 않아서 잘했어요!",
"youveCompletedPractice": "연습을 완료했습니다. 계속해서 더 나아지세요!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "이메일 변경",
"withTheseAddressesDescription": "이 이메일 주소로 로그인하고, 비밀번호를 복구하며, 구독을 관리할 수 있습니다.",
"noAddressDescription": "아직 이메일 주소를 추가하지 않았습니다.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "문법",
"spanTypeWordChoice": "단어 선택",
"spanTypeSpelling": "철자",
"spanTypePunctuation": "구두점",
"spanTypeStyle": "스타일",
"spanTypeFluency": "유창성",
"spanTypeAccents": "억양",
"spanTypeCapitalization": "대문자 사용",
"spanTypeCorrection": "수정",
"spanFeedbackTitle": "수정 문제 보고",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -3857,7 +3857,7 @@
"playWithAI": "Žaiskite su dirbtiniu intelektu dabar",
"courseStartDesc": "Pangea botas pasiruošęs bet kada pradėti!\n\n...bet mokymasis yra geresnis su draugais!",
"@@locale": "lt",
"@@last_modified": "2026-02-05 10:10:01.069181",
"@@last_modified": "2026-02-09 15:32:01.402371",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -11531,8 +11531,6 @@
"congratulations": "Sveikiname!",
"anotherRound": "Dar viena raundas",
"noActivityRequest": "Nėra dabartinio veiklos prašymo.",
"quit": "Išeiti",
"congratulationsYouveCompletedPractice": "Sveikiname! Jūs baigėte praktikos sesiją.",
"mustHave10Words": "Turite turėti bent 10 žodžių, kad galėtumėte juos praktikuoti. Pabandykite pasikalbėti su draugu arba Pangea Bot, kad sužinotumėte daugiau!",
"botSettings": "Roboto nustatymai",
"activitySettingsOverrideWarning": "Kalba ir kalbos lygis nustatomi pagal veiklos planą",
@ -11581,14 +11579,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11764,6 +11754,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Apie mane",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Phrasal Verb",
"grammarCopyPOScompn": "Sudėtinis",
@ -11778,5 +11773,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Tobulas praktika!",
"greatPractice": "Puiki praktika!",
"usedNoHints": "Puikus darbas, kad nenaudojote jokių užuominų!",
"youveCompletedPractice": "Jūs baigėte praktiką, tęskite, kad taptumėte geresni!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Keisti el. paštą",
"withTheseAddressesDescription": "Su šiais el. pašto adresais galite prisijungti, atkurti slaptažodį ir valdyti prenumeratas.",
"noAddressDescription": "Jūs dar nepridėjote jokių el. pašto adresų.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Žodžių pasirinkimas",
"spanTypeSpelling": "Rašyba",
"spanTypePunctuation": "Skyryba",
"spanTypeStyle": "Stilius",
"spanTypeFluency": "Sklandumas",
"spanTypeAccents": "Akcentai",
"spanTypeCapitalization": "Didžiųjų raidžių naudojimas",
"spanTypeCorrection": "Korekcija",
"spanFeedbackTitle": "Pranešti apie korekcijos problemą",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -4605,7 +4605,7 @@
"playWithAI": "Tagad spēlējiet ar AI",
"courseStartDesc": "Pangea bots ir gatavs jebkurā laikā!\n\n...bet mācīties ir labāk ar draugiem!",
"@@locale": "lv",
"@@last_modified": "2026-02-05 10:09:54.766036",
"@@last_modified": "2026-02-09 15:31:50.771177",
"analyticsInactiveTitle": "Pieprasījumi neaktīviem lietotājiem nevar tikt nosūtīti",
"analyticsInactiveDesc": "Neaktīvi lietotāji, kuri nav pieteikušies kopš šīs funkcijas ieviešanas, neredzēs jūsu pieprasījumu.\n\nPieprasījuma poga parādīsies, kad viņi atgriezīsies. Jūs varat atkārtoti nosūtīt pieprasījumu vēlāk, noklikšķinot uz pieprasījuma pogas viņu vārdā, kad tā būs pieejama.",
"accessRequestedTitle": "Pieprasījums piekļūt analītikai",
@ -10840,8 +10840,6 @@
"congratulations": "Apsveicam!",
"anotherRound": "Vēl viena kārta",
"noActivityRequest": "Nav pašreizējo aktivitāšu pieprasījumu.",
"quit": "Iziet",
"congratulationsYouveCompletedPractice": "Apsveicam! Jūs esat pabeidzis prakses sesiju.",
"mustHave10Words": "Jums jābūt vismaz 10 vārdiem, lai tos praktizētu. Mēģiniet parunāt ar draugu vai Pangea Bot, lai uzzinātu vairāk!",
"botSettings": "Bota iestatījumi",
"activitySettingsOverrideWarning": "Valoda un valodas līmenis, ko nosaka aktivitāšu plāns",
@ -10890,14 +10888,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11073,6 +11063,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Par mani",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Frazēts darbības vārds",
"grammarCopyPOScompn": "Savienojums",
@ -11087,5 +11082,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Lieliska prakse!",
"greatPractice": "Lieliska prakse!",
"usedNoHints": "Lieliski, ka neizmantoji nevienu padomu!",
"youveCompletedPractice": "Tu esi pabeidzis praksi, turpini tādā garā, lai uzlabotos!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Mainīt e-pastu",
"withTheseAddressesDescription": "Ar šiem e-pasta adresēm jūs varat pieteikties, atjaunot savu paroli un pārvaldīt abonementus.",
"noAddressDescription": "Jūs vēl neesat pievienojis nevienu e-pasta adresi.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Vārdu izvēle",
"spanTypeSpelling": "Ortogrāfija",
"spanTypePunctuation": "Interpunkcija",
"spanTypeStyle": "Stils",
"spanTypeFluency": "Plūdums",
"spanTypeAccents": "Akcenti",
"spanTypeCapitalization": "Lielie burti",
"spanTypeCorrection": "Korekcija",
"spanFeedbackTitle": "Ziņot par korekcijas problēmu",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2021-08-14 12:41:09.967351",
"@@last_modified": "2026-02-09 15:31:32.332245",
"about": "Om",
"@about": {
"type": "String",
@ -10909,8 +10909,6 @@
"congratulations": "Gratulerer!",
"anotherRound": "En runde til",
"noActivityRequest": "Ingen nåværende aktivitetsforespørsel.",
"quit": "Avslutt",
"congratulationsYouveCompletedPractice": "Gratulerer! Du har fullført økt med øvelser.",
"mustHave10Words": "Du må ha minst 10 ordforråd for å øve på dem. Prøv å snakke med en venn eller Pangea Bot for å oppdage mer!",
"botSettings": "Bot-innstillinger",
"activitySettingsOverrideWarning": "Språk og språknivå bestemt av aktivitetsplan",
@ -10959,14 +10957,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11142,6 +11132,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Om meg",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Phrasal Verb",
"grammarCopyPOScompn": "Sammensatt",
@ -11156,5 +11151,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Perfekt øvelse!",
"greatPractice": "Flott øvelse!",
"usedNoHints": "Bra jobba med å ikke bruke noen hint!",
"youveCompletedPractice": "Du har fullført øvelsen, fortsett slik for å bli bedre!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Endre e-post",
"withTheseAddressesDescription": "Med disse e-postadressene kan du logge inn, gjenopprette passordet ditt og administrere abonnementer.",
"noAddressDescription": "Du har ikke lagt til noen e-postadresser ennå.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Grammatikk",
"spanTypeWordChoice": "Ordvalg",
"spanTypeSpelling": "Staving",
"spanTypePunctuation": "Tegnsetting",
"spanTypeStyle": "Stil",
"spanTypeFluency": "Flyt",
"spanTypeAccents": "Aksenter",
"spanTypeCapitalization": "Store bokstaver",
"spanTypeCorrection": "Korrigering",
"spanFeedbackTitle": "Rapporter korrigeringsproblem",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2021-08-14 12:41:09.955292",
"@@last_modified": "2026-02-09 15:32:09.504392",
"about": "Over ons",
"@about": {
"type": "String",
@ -10845,8 +10845,6 @@
"congratulations": "Gefeliciteerd!",
"anotherRound": "Nog een ronde",
"noActivityRequest": "Geen huidige activiteit aanvraag.",
"quit": "Afsluiten",
"congratulationsYouveCompletedPractice": "Gefeliciteerd! Je hebt de oefensessie voltooid.",
"mustHave10Words": "Je moet minstens 10 vocabulairewoorden hebben om ze te oefenen. Probeer met een vriend of Pangea Bot te praten om meer te ontdekken!",
"botSettings": "Botinstellingen",
"activitySettingsOverrideWarning": "Taal en taalniveau bepaald door het activiteitenplan",
@ -10895,14 +10893,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11078,6 +11068,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Over mij",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idioom",
"grammarCopyPOSphrasalv": "Frazal Werkwoord",
"grammarCopyPOScompn": "Samenstelling",
@ -11092,5 +11087,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Perfecte oefening!",
"greatPractice": "Geweldige oefening!",
"usedNoHints": "Goed gedaan, geen hints gebruikt!",
"youveCompletedPractice": "Je hebt de oefening voltooid, ga zo door om beter te worden!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Wijzig e-mailadres",
"withTheseAddressesDescription": "Met deze e-mailadressen kun je inloggen, je wachtwoord herstellen en abonnementen beheren.",
"noAddressDescription": "Je hebt nog geen e-mailadressen toegevoegd.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Grammatica",
"spanTypeWordChoice": "Woordkeuze",
"spanTypeSpelling": "Spelling",
"spanTypePunctuation": "Interpunctie",
"spanTypeStyle": "Stijl",
"spanTypeFluency": "Vloeiendheid",
"spanTypeAccents": "Accenten",
"spanTypeCapitalization": "Hoofdletters",
"spanTypeCorrection": "Correctie",
"spanFeedbackTitle": "Rapporteer correctiefout",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "pl",
"@@last_modified": "2021-08-14 12:41:09.943634",
"@@last_modified": "2026-02-09 15:32:22.411163",
"about": "O aplikacji",
"@about": {
"type": "String",
@ -10718,8 +10718,6 @@
"congratulations": "Gratulacje!",
"anotherRound": "Kolejna runda",
"noActivityRequest": "Brak bieżącego żądania aktywności.",
"quit": "Zakończ",
"congratulationsYouveCompletedPractice": "Gratulacje! Ukończyłeś sesję ćwiczeń.",
"mustHave10Words": "Musisz mieć co najmniej 10 słówek do ćwiczenia. Spróbuj porozmawiać z przyjacielem lub Pangea Bot, aby odkryć więcej!",
"botSettings": "Ustawienia bota",
"activitySettingsOverrideWarning": "Język i poziom językowy określone przez plan aktywności",
@ -10768,14 +10766,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -10951,6 +10941,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "O mnie",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Czasownik frazowy",
"grammarCopyPOScompn": "Złożony",
@ -10965,5 +10960,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Idealna praktyka!",
"greatPractice": "Świetna praktyka!",
"usedNoHints": "Dobra robota, że nie korzystałeś z żadnych wskazówek!",
"youveCompletedPractice": "Ukończyłeś praktykę, kontynuuj, aby się poprawić!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Zmień adres e-mail",
"withTheseAddressesDescription": "Dzięki tym adresom e-mail możesz się zalogować, odzyskać hasło i zarządzać subskrypcjami.",
"noAddressDescription": "Nie dodałeś jeszcze żadnych adresów e-mail.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatyka",
"spanTypeWordChoice": "Wybór słów",
"spanTypeSpelling": "Ortografia",
"spanTypePunctuation": "Interpunkcja",
"spanTypeStyle": "Styl",
"spanTypeFluency": "Płynność",
"spanTypeAccents": "Akcenty",
"spanTypeCapitalization": "Kapitalizacja",
"spanTypeCorrection": "Korekta",
"spanFeedbackTitle": "Zgłoś problem z korektą",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2021-08-14 12:41:09.940318",
"@@last_modified": "2026-02-09 15:31:11.061688",
"copiedToClipboard": "Copiada para a área de transferência",
"@copiedToClipboard": {
"type": "String",
@ -11813,8 +11813,6 @@
"congratulations": "Parabéns!",
"anotherRound": "Outra rodada",
"noActivityRequest": "Nenhum pedido de atividade atual.",
"quit": "Sair",
"congratulationsYouveCompletedPractice": "Parabéns! Você completou a sessão de prática.",
"mustHave10Words": "Você deve ter pelo menos 10 palavras de vocabulário para praticá-las. Tente conversar com um amigo ou com o Pangea Bot para descobrir mais!",
"botSettings": "Configurações do Bot",
"activitySettingsOverrideWarning": "Idioma e nível de idioma determinados pelo plano de atividade",
@ -11863,14 +11861,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -12046,6 +12036,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Sobre mim",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Verbo Frasal",
"grammarCopyPOScompn": "Composto",
@ -12060,5 +12055,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Prática perfeita!",
"greatPractice": "Ótima prática!",
"usedNoHints": "Bom trabalho em não usar dicas!",
"youveCompletedPractice": "Você completou a prática, continue assim para melhorar!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Alterar e-mail",
"withTheseAddressesDescription": "Com esses endereços de e-mail, você pode fazer login, recuperar sua senha e gerenciar assinaturas.",
"noAddressDescription": "Você ainda não adicionou nenhum endereço de e-mail.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramática",
"spanTypeWordChoice": "Escolha de Palavras",
"spanTypeSpelling": "Ortografia",
"spanTypePunctuation": "Pontuação",
"spanTypeStyle": "Estilo",
"spanTypeFluency": "Fluência",
"spanTypeAccents": "Acentos",
"spanTypeCapitalization": "Capitalização",
"spanTypeCorrection": "Correção",
"spanFeedbackTitle": "Relatar problema de correção",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2021-08-14 12:41:09.925971",
"@@last_modified": "2026-02-09 15:31:06.403516",
"about": "Sobre",
"@about": {
"type": "String",
@ -10840,8 +10840,6 @@
"congratulations": "Parabéns!",
"anotherRound": "Outra rodada",
"noActivityRequest": "Nenhum pedido de atividade atual.",
"quit": "Sair",
"congratulationsYouveCompletedPractice": "Parabéns! Você completou a sessão de prática.",
"mustHave10Words": "Você deve ter pelo menos 10 palavras de vocabulário para praticá-las. Tente conversar com um amigo ou com o Pangea Bot para descobrir mais!",
"botSettings": "Configurações do Bot",
"activitySettingsOverrideWarning": "Idioma e nível de idioma determinados pelo plano de atividade",
@ -10890,14 +10888,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11073,6 +11063,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Sobre mim",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Verbo Frasal",
"grammarCopyPOScompn": "Composto",
@ -11087,5 +11082,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Prática perfeita!",
"greatPractice": "Ótima prática!",
"usedNoHints": "Bom trabalho não usando dicas!",
"youveCompletedPractice": "Você completou a prática, continue assim para melhorar!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Alterar e-mail",
"withTheseAddressesDescription": "Com esses endereços de e-mail, você pode fazer login, recuperar sua senha e gerenciar assinaturas.",
"noAddressDescription": "Você ainda não adicionou nenhum endereço de e-mail.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramática",
"spanTypeWordChoice": "Escolha de Palavras",
"spanTypeSpelling": "Ortografia",
"spanTypePunctuation": "Pontuação",
"spanTypeStyle": "Estilo",
"spanTypeFluency": "Fluência",
"spanTypeAccents": "Acentos",
"spanTypeCapitalization": "Capitalização",
"spanTypeCorrection": "Correção",
"spanFeedbackTitle": "Relatar problema de correção",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -3327,7 +3327,7 @@
"selectAll": "Selecionar tudo",
"deselectAll": "Desmarcar tudo",
"@@locale": "pt_PT",
"@@last_modified": "2026-02-05 10:09:50.725651",
"@@last_modified": "2026-02-09 15:31:42.773873",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -11755,8 +11755,6 @@
"congratulations": "Parabéns!",
"anotherRound": "Outra rodada",
"noActivityRequest": "Nenhum pedido de atividade atual.",
"quit": "Sair",
"congratulationsYouveCompletedPractice": "Parabéns! Você completou a sessão de prática.",
"mustHave10Words": "Você deve ter pelo menos 10 palavras de vocabulário para praticar. Tente conversar com um amigo ou com o Pangea Bot para descobrir mais!",
"botSettings": "Configurações do Bot",
"activitySettingsOverrideWarning": "Idioma e nível de idioma determinados pelo plano de atividade",
@ -11805,14 +11803,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11988,6 +11978,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Sobre mim",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Verbo Frasal",
"grammarCopyPOScompn": "Composto",
@ -12002,5 +11997,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Prática perfeita!",
"greatPractice": "Ótima prática!",
"usedNoHints": "Bom trabalho em não usar dicas!",
"youveCompletedPractice": "Você completou a prática, continue assim para melhorar!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Alterar e-mail",
"withTheseAddressesDescription": "Com esses endereços de e-mail, você pode fazer login, recuperar sua senha e gerenciar assinaturas.",
"noAddressDescription": "Você ainda não adicionou nenhum endereço de e-mail.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramática",
"spanTypeWordChoice": "Escolha de Palavras",
"spanTypeSpelling": "Ortografia",
"spanTypePunctuation": "Pontuação",
"spanTypeStyle": "Estilo",
"spanTypeFluency": "Fluência",
"spanTypeAccents": "Acentos",
"spanTypeCapitalization": "Capitalização",
"spanTypeCorrection": "Correção",
"spanFeedbackTitle": "Relatar problema de correção",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2021-08-14 12:41:09.918296",
"@@last_modified": "2026-02-09 15:30:49.927369",
"about": "Despre",
"@about": {
"type": "String",
@ -11461,8 +11461,6 @@
"congratulations": "Felicitări!",
"anotherRound": "Încă o rundă",
"noActivityRequest": "Nu există cereri de activitate curente.",
"quit": "Ieși",
"congratulationsYouveCompletedPractice": "Felicitări! Ai completat sesiunea de practică.",
"mustHave10Words": "Trebuie să ai cel puțin 10 cuvinte de vocabular pentru a le exersa. Încearcă să vorbești cu un prieten sau cu Pangea Bot pentru a descoperi mai multe!",
"botSettings": "Setări Bot",
"activitySettingsOverrideWarning": "Limba și nivelul de limbă sunt determinate de planul de activitate",
@ -11511,14 +11509,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11694,6 +11684,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Despre mine",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Verb Phrastic",
"grammarCopyPOScompn": "Compus",
@ -11708,5 +11703,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Practica perfectă!",
"greatPractice": "Practica grozavă!",
"usedNoHints": "Bravo că nu ai folosit niciun indiciu!",
"youveCompletedPractice": "Ai finalizat practica, continuă așa pentru a te îmbunătăți!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Schimbă emailul",
"withTheseAddressesDescription": "Cu aceste adrese de email poți să te conectezi, să îți recuperezi parola și să gestionezi abonamentele.",
"noAddressDescription": "Nu ai adăugat încă nicio adresă de email.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatica",
"spanTypeWordChoice": "Alegerea cuvintelor",
"spanTypeSpelling": "Ortografie",
"spanTypePunctuation": "Punctuație",
"spanTypeStyle": "Stil",
"spanTypeFluency": "Fluență",
"spanTypeAccents": "Accente",
"spanTypeCapitalization": "Capitalizare",
"spanTypeCorrection": "Corectare",
"spanFeedbackTitle": "Raportează problema de corectare",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "ru",
"@@last_modified": "2021-08-14 12:41:09.903021",
"@@last_modified": "2026-02-09 15:32:37.241817",
"about": "О проекте",
"@about": {
"type": "String",
@ -10831,8 +10831,6 @@
"congratulations": "Поздравляем!",
"anotherRound": "Еще один раунд",
"noActivityRequest": "Нет текущего запроса на активность.",
"quit": "Выйти",
"congratulationsYouveCompletedPractice": "Поздравляем! Вы завершили практическую сессию.",
"mustHave10Words": "Вы должны иметь как минимум 10 слов для практики. Попробуйте поговорить с другом или Pangea Bot, чтобы узнать больше!",
"botSettings": "Настройки бота",
"activitySettingsOverrideWarning": "Язык и уровень языка определяются планом активности",
@ -10881,14 +10879,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11078,6 +11068,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Обо мне",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Идиома",
"grammarCopyPOSphrasalv": "Фразовый глагол",
"grammarCopyPOScompn": "Составное",
@ -11092,5 +11087,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Идеальная практика!",
"greatPractice": "Отличная практика!",
"usedNoHints": "Хорошая работа, что не использовали подсказки!",
"youveCompletedPractice": "Вы завершили практику, продолжайте в том же духе, чтобы стать лучше!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Сменить электронную почту",
"withTheseAddressesDescription": "С помощью этих адресов электронной почты вы можете войти в систему, восстановить пароль и управлять подписками.",
"noAddressDescription": "Вы еще не добавили ни одного адреса электронной почты.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Грамматика",
"spanTypeWordChoice": "Выбор слов",
"spanTypeSpelling": "Орфография",
"spanTypePunctuation": "Пунктуация",
"spanTypeStyle": "Стиль",
"spanTypeFluency": "Связность",
"spanTypeAccents": "Акценты",
"spanTypeCapitalization": "Капитализация",
"spanTypeCorrection": "Коррекция",
"spanFeedbackTitle": "Сообщить о проблеме с коррекцией",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "sk",
"@@last_modified": "2021-08-14 12:41:09.879987",
"@@last_modified": "2026-02-09 15:30:52.656999",
"about": "O aplikácii",
"@about": {
"type": "String",
@ -11810,8 +11810,6 @@
"congratulations": "Gratulujeme!",
"anotherRound": "Ďalšie kolo",
"noActivityRequest": "Žiadna aktuálna požiadavka na aktivitu.",
"quit": "Ukončiť",
"congratulationsYouveCompletedPractice": "Gratulujeme! Dokončili ste cvičebnú reláciu.",
"mustHave10Words": "Musíte mať aspoň 10 slovíčok na precvičovanie. Skúste sa porozprávať s priateľom alebo Pangea Botom, aby ste objavili viac!",
"botSettings": "Nastavenia bota",
"activitySettingsOverrideWarning": "Jazyk a jazyková úroveň určené plánom aktivity",
@ -11860,14 +11858,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -12043,6 +12033,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "O mne",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idióm",
"grammarCopyPOSphrasalv": "Frázové sloveso",
"grammarCopyPOScompn": "Zložené",
@ -12057,5 +12052,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Dokonalá prax!",
"greatPractice": "Skvelá prax!",
"usedNoHints": "Dobrý výkon, že si nepoužil žiadne nápovedy!",
"youveCompletedPractice": "Dokončil si prax, pokračuj v tom, aby si sa zlepšil!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Zmeniť e-mail",
"withTheseAddressesDescription": "S týmito e-mailovými adresami sa môžete prihlásiť, obnoviť svoje heslo a spravovať predplatné.",
"noAddressDescription": "Ešte ste nepridali žiadne e-mailové adresy.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Výber slov",
"spanTypeSpelling": "Pravopis",
"spanTypePunctuation": "Interpunkcia",
"spanTypeStyle": "Štýl",
"spanTypeFluency": "Plynulosť",
"spanTypeAccents": "Prízvuky",
"spanTypeCapitalization": "Veľké písmená",
"spanTypeCorrection": "Oprava",
"spanFeedbackTitle": "Nahlásiť problém s opravou",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -2460,7 +2460,7 @@
"playWithAI": "Za zdaj igrajte z AI-jem",
"courseStartDesc": "Pangea Bot je pripravljen kadarkoli!\n\n...ampak je bolje učiti se s prijatelji!",
"@@locale": "sl",
"@@last_modified": "2026-02-05 10:09:38.721866",
"@@last_modified": "2026-02-09 15:31:19.119268",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -11807,8 +11807,6 @@
"congratulations": "Čestitamo!",
"anotherRound": "Še en krog",
"noActivityRequest": "Trenutno ni zahtevka za aktivnost.",
"quit": "Izhod",
"congratulationsYouveCompletedPractice": "Čestitamo! Zaključili ste vadbeno sejo.",
"mustHave10Words": "Imeti morate vsaj 10 besed za besedišče, da jih lahko vadite. Poskusite se pogovoriti s prijateljem ali Pangea Botom, da odkrijete več!",
"botSettings": "Nastavitve bota",
"activitySettingsOverrideWarning": "Jezik in jezikovna raven sta določena z načrtom aktivnosti",
@ -11857,14 +11855,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -12040,6 +12030,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "O meni",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Phrasalni glagol",
"grammarCopyPOScompn": "Sestavljenka",
@ -12054,5 +12049,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Popolna praksa!",
"greatPractice": "Super praksa!",
"usedNoHints": "Odlično, da niste uporabili nobenih namigov!",
"youveCompletedPractice": "Zaključili ste prakso, nadaljujte tako, da boste boljši!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Spremeni e-pošto",
"withTheseAddressesDescription": "S temi e-poštnimi naslovi se lahko prijavite, obnovite geslo in upravljate z naročninami.",
"noAddressDescription": "Še niste dodali nobenih e-poštnih naslovov.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Izbira besed",
"spanTypeSpelling": "Pravopis",
"spanTypePunctuation": "Interpunkcija",
"spanTypeStyle": "Slog",
"spanTypeFluency": "Sposobnost",
"spanTypeAccents": "Naglaski",
"spanTypeCapitalization": "Velike začetnice",
"spanTypeCorrection": "Popravek",
"spanFeedbackTitle": "Poročilo o težavi s popravkom",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2021-08-14 12:41:09.857024",
"@@last_modified": "2026-02-09 15:32:41.761528",
"about": "О програму",
"@about": {
"type": "String",
@ -11822,8 +11822,6 @@
"congratulations": "Čestitamo!",
"anotherRound": "Još jedan krug",
"noActivityRequest": "Nema trenutnog zahteva za aktivnost.",
"quit": "Izlaz",
"congratulationsYouveCompletedPractice": "Čestitamo! Završili ste sesiju vežbanja.",
"mustHave10Words": "Morate imati najmanje 10 reči za rečnik da biste ih vežbali. Pokušajte da razgovarate sa prijateljem ili Pangea Bot-om da biste otkrili više!",
"botSettings": "Podešavanja Bota",
"activitySettingsOverrideWarning": "Jezik i nivo jezika određeni planom aktivnosti",
@ -11872,14 +11870,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -12055,6 +12045,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "О мени",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Phrasal Verb",
"grammarCopyPOScompn": "Kombinacija",
@ -12069,5 +12064,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Savršena praksa!",
"greatPractice": "Sjajna praksa!",
"usedNoHints": "Odlično, niste koristili nikakve savete!",
"youveCompletedPractice": "Završili ste praksu, nastavite tako da postanete bolji!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Promeni email",
"withTheseAddressesDescription": "Sa ovim email adresama možete se prijaviti, povratiti svoju lozinku i upravljati pretplatama.",
"noAddressDescription": "Još niste dodali nijednu email adresu.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Izbor reči",
"spanTypeSpelling": "Pravopis",
"spanTypePunctuation": "Interpunkcija",
"spanTypeStyle": "Stil",
"spanTypeFluency": "Tečnost",
"spanTypeAccents": "Akcenti",
"spanTypeCapitalization": "Velika slova",
"spanTypeCorrection": "Ispravka",
"spanFeedbackTitle": "Prijavi problem sa ispravkom",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-05 10:10:13.035755",
"@@last_modified": "2026-02-09 15:32:24.746342",
"about": "Om",
"@about": {
"type": "String",
@ -11204,8 +11204,6 @@
"congratulations": "Grattis!",
"anotherRound": "En runda till",
"noActivityRequest": "Ingen aktuell aktivitetsförfrågan.",
"quit": "Avsluta",
"congratulationsYouveCompletedPractice": "Grattis! Du har slutfört övningssessionen.",
"mustHave10Words": "Du måste ha minst 10 ord för att öva dem. Försök att prata med en vän eller Pangea Bot för att upptäcka mer!",
"botSettings": "Botinställningar",
"activitySettingsOverrideWarning": "Språk och språknivå bestäms av aktivitetsplanen",
@ -11254,14 +11252,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11437,6 +11427,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Om mig",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Idiom",
"grammarCopyPOSphrasalv": "Phrasverb",
"grammarCopyPOScompn": "Sammansatt",
@ -11451,5 +11446,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Perfekt träning!",
"greatPractice": "Bra träning!",
"usedNoHints": "Bra jobbat utan att använda några ledtrådar!",
"youveCompletedPractice": "Du har slutfört träningen, fortsätt så för att bli bättre!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Ändra e-postadress",
"withTheseAddressesDescription": "Med dessa e-postadresser kan du logga in, återställa ditt lösenord och hantera prenumerationer.",
"noAddressDescription": "Du har ännu inte lagt till några e-postadresser.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Grammatik",
"spanTypeWordChoice": "Orval",
"spanTypeSpelling": "Stavning",
"spanTypePunctuation": "Interpunktion",
"spanTypeStyle": "Stil",
"spanTypeFluency": "Flyt",
"spanTypeAccents": "Accenter",
"spanTypeCapitalization": "Versalisering",
"spanTypeCorrection": "Korrigering",
"spanFeedbackTitle": "Rapportera korrigeringsproblem",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2021-08-14 12:41:09.826673",
"@@last_modified": "2026-02-09 15:32:06.841503",
"acceptedTheInvitation": "👍 {username} அழைப்பை ஏற்றுக்கொண்டது",
"@acceptedTheInvitation": {
"type": "String",
@ -10722,8 +10722,6 @@
"congratulations": "வாழ்த்துகள்!",
"anotherRound": "மற்றொரு சுற்று",
"noActivityRequest": "தற்போதைய செயல்பாட்டுக்கான கோரிக்கை இல்லை.",
"quit": "விலகுங்கள்",
"congratulationsYouveCompletedPractice": "வாழ்த்துகள்! நீங்கள் பயிற்சி அமர்வை முடித்துவிட்டீர்கள்.",
"mustHave10Words": "நீங்கள் பயிற்சிக்காக குறைந்தது 10 சொற்களை வைத்திருக்க வேண்டும். மேலும் கண்டுபிடிக்க நண்பருடன் அல்லது பாஙோ பாட்டுடன் பேச முயற்சிக்கவும்!",
"botSettings": "பாடல் அமைப்புகள்",
"activitySettingsOverrideWarning": "செயல்பாட்டு திட்டத்தால் நிர்ணயிக்கப்பட்ட மொழி மற்றும் மொழி நிலை",
@ -10772,14 +10770,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -10955,6 +10945,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "என்னைப் பற்றி",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "விளக்கம்",
"grammarCopyPOSphrasalv": "பொருள் வினை",
"grammarCopyPOScompn": "சேர்க்கை",
@ -10969,5 +10964,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "சரியான பயிற்சி!",
"greatPractice": "மிகவும் நல்ல பயிற்சி!",
"usedNoHints": "எந்த உதவியையும் பயன்படுத்தாததற்கு நல்ல வேலை!",
"youveCompletedPractice": "நீங்கள் பயிற்சியை முடித்துவிட்டீர்கள், மேலும் மேம்பட தொடர்ந்து முயற்சிக்கவும்!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "மின்னஞ்சலை மாற்றவும்",
"withTheseAddressesDescription": "இந்த மின்னஞ்சல் முகவரிகளுடன் நீங்கள் உள்நுழைந்து, உங்கள் கடவுச்சொல்லை மீட்டெடுக்கவும், சந்தாக்களை நிர்வகிக்கவும் முடியும்.",
"noAddressDescription": "நீங்கள் இன்னும் எந்த மின்னஞ்சல் முகவரிகளையும் சேர்க்கவில்லை.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "இயல்பியல்",
"spanTypeWordChoice": "சொல் தேர்வு",
"spanTypeSpelling": "எழுத்துப்பிழை",
"spanTypePunctuation": "இணைச்சொல்",
"spanTypeStyle": "அழகு",
"spanTypeFluency": "தரிசனம்",
"spanTypeAccents": "உயர்த்தல்கள்",
"spanTypeCapitalization": "முதலெழுத்து",
"spanTypeCorrection": "திருத்தம்",
"spanFeedbackTitle": "திருத்தப் பிரச்சினையைப் புகாரளிக்கவும்",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1916,7 +1916,7 @@
"playWithAI": "ఇప్పుడే AI తో ఆడండి",
"courseStartDesc": "పాంజియా బాట్ ఎప్పుడైనా సిద్ధంగా ఉంటుంది!\n\n...కానీ స్నేహితులతో నేర్చుకోవడం మెరుగైనది!",
"@@locale": "te",
"@@last_modified": "2026-02-05 10:09:59.064928",
"@@last_modified": "2026-02-09 15:31:58.761810",
"@setCustomPermissionLevel": {
"type": "String",
"placeholders": {}
@ -11815,8 +11815,6 @@
"congratulations": "అభినందనలు!",
"anotherRound": "మరొక రౌండ్",
"noActivityRequest": "ప్రస్తుతం ఎలాంటి కార్యకలాపం అభ్యర్థన లేదు.",
"quit": "విడుదల",
"congratulationsYouveCompletedPractice": "అభినందనలు! మీరు అభ్యాస సెషన్‌ను పూర్తి చేశారు.",
"mustHave10Words": "మీరు వాటిని అభ్యాసం చేయడానికి కనీసం 10 పదాలను కలిగి ఉండాలి. మరింత తెలుసుకోవడానికి మీ స్నేహితుడితో లేదా పాంజియా బాట్‌తో మాట్లాడండి!",
"botSettings": "బాట్ సెట్టింగులు",
"activitySettingsOverrideWarning": "కార్యకలాపం ప్రణాళిక ద్వారా నిర్ణయించబడిన భాష మరియు భాష స్థాయి",
@ -11865,14 +11863,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -12048,6 +12038,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "నా గురించి",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "సామెత",
"grammarCopyPOSphrasalv": "పదబంధ క్రియ",
"grammarCopyPOScompn": "సంకలనం",
@ -12062,5 +12057,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "సంపూర్ణ అభ్యాసం!",
"greatPractice": "మంచి అభ్యాసం!",
"usedNoHints": "ఏ సూచనలు ఉపయోగించకపోవడం మంచి పని!",
"youveCompletedPractice": "మీరు అభ్యాసం పూర్తి చేసారు, మెరుగుపడటానికి ఇలాగే కొనసాగించండి!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "ఇమెయిల్ మార్చండి",
"withTheseAddressesDescription": "ఈ ఇమెయిల్ చిరునామాలతో మీరు లాగిన్ అవ్వవచ్చు, మీ పాస్వర్డ్‌ను పునరుద్ధరించవచ్చు మరియు సబ్‌స్క్రిప్షన్లను నిర్వహించవచ్చు.",
"noAddressDescription": "మీరు ఇంకా ఎలాంటి ఇమెయిల్ చిరునామాలను జోడించలేదు.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "వ్యాకరణం",
"spanTypeWordChoice": "పద ఎంపిక",
"spanTypeSpelling": "అక్షరరూపం",
"spanTypePunctuation": "పంక్తి చిహ్నాలు",
"spanTypeStyle": "శైలి",
"spanTypeFluency": "ప్రవాహం",
"spanTypeAccents": "ఉచ్చారణలు",
"spanTypeCapitalization": "పెద్ద అక్షరాలు",
"spanTypeCorrection": "సరిదిద్దు",
"spanFeedbackTitle": "సరిదిద్దు సమస్యను నివేదించండి",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -3999,7 +3999,7 @@
"playWithAI": "เล่นกับ AI ชั่วคราว",
"courseStartDesc": "Pangea Bot พร้อมที่จะเริ่มต้นได้ทุกเมื่อ!\n\n...แต่การเรียนรู้ดีกว่ากับเพื่อน!",
"@@locale": "th",
"@@last_modified": "2026-02-05 10:09:49.236652",
"@@last_modified": "2026-02-09 15:31:39.902769",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -11339,8 +11339,6 @@
"congratulations": "ขอแสดงความยินดี!",
"anotherRound": "อีกหนึ่งรอบ",
"noActivityRequest": "ไม่มีคำขอทำกิจกรรมในขณะนี้",
"quit": "ออก",
"congratulationsYouveCompletedPractice": "ขอแสดงความยินดี! คุณได้เสร็จสิ้นการฝึกฝนแล้ว",
"mustHave10Words": "คุณต้องมีคำศัพท์อย่างน้อย 10 คำเพื่อฝึกฝน ลองพูดคุยกับเพื่อนหรือ Pangea Bot เพื่อค้นพบเพิ่มเติม!",
"botSettings": "การตั้งค่า Bot",
"activitySettingsOverrideWarning": "ภาษาและระดับภาษาที่กำหนดโดยแผนกิจกรรม",
@ -11389,14 +11387,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11572,6 +11562,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "เกี่ยวกับฉัน",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "สำนวน",
"grammarCopyPOSphrasalv": "กริยาวลี",
"grammarCopyPOScompn": "คำผสม",
@ -11586,5 +11581,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "การฝึกฝนที่สมบูรณ์แบบ!",
"greatPractice": "การฝึกฝนที่ยอดเยี่ยม!",
"usedNoHints": "ทำได้ดีที่ไม่ใช้คำใบ้ใด ๆ!",
"youveCompletedPractice": "คุณได้ทำการฝึกฝนเสร็จสิ้นแล้ว ทำต่อไปเพื่อให้ดีขึ้น!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "เปลี่ยนอีเมล",
"withTheseAddressesDescription": "ด้วยที่อยู่อีเมลเหล่านี้ คุณสามารถเข้าสู่ระบบ กู้คืนรหัสผ่าน และจัดการการสมัครสมาชิกได้",
"noAddressDescription": "คุณยังไม่ได้เพิ่มที่อยู่อีเมลใด ๆ",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "ไวยากรณ์",
"spanTypeWordChoice": "การเลือกคำ",
"spanTypeSpelling": "การสะกด",
"spanTypePunctuation": "เครื่องหมายวรรคตอน",
"spanTypeStyle": "สไตล์",
"spanTypeFluency": "ความคล่องแคล่ว",
"spanTypeAccents": "สำเนียง",
"spanTypeCapitalization": "การใช้ตัวพิมพ์ใหญ่",
"spanTypeCorrection": "การแก้ไข",
"spanFeedbackTitle": "รายงานปัญหาการแก้ไข",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "tr",
"@@last_modified": "2021-08-14 12:41:09.803728",
"@@last_modified": "2026-02-09 15:31:56.191986",
"about": "Hakkında",
"@about": {
"type": "String",
@ -10932,8 +10932,6 @@
"congratulations": "Tebrikler!",
"anotherRound": "Bir tur daha",
"noActivityRequest": "Şu anda etkinlik talebi yok.",
"quit": "Çık",
"congratulationsYouveCompletedPractice": "Tebrikler! Pratik oturumunu tamamladınız.",
"mustHave10Words": "Pratik yapmak için en az 10 kelimeye sahip olmalısınız. Daha fazla keşfetmek için bir arkadaşınızla veya Pangea Bot ile konuşmayı deneyin!",
"botSettings": "Bot Ayarları",
"activitySettingsOverrideWarning": "Etkinlik planı tarafından belirlenen dil ve dil seviyesi",
@ -10982,14 +10980,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11165,6 +11155,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Hakkımda",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Deyim",
"grammarCopyPOSphrasalv": "Deyim Fiili",
"grammarCopyPOScompn": "Bileşik",
@ -11179,5 +11174,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Mükemmel pratik!",
"greatPractice": "Harika pratik!",
"usedNoHints": "Hiç ipucu kullanmadığın için iyi iş çıkardın!",
"youveCompletedPractice": "Pratiği tamamladın, daha iyi olmak için devam et!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "E-posta değiştir",
"withTheseAddressesDescription": "Bu e-posta adresleriyle oturum açabilir, şifrenizi kurtarabilir ve aboneliklerinizi yönetebilirsiniz.",
"noAddressDescription": "Henüz herhangi bir e-posta adresi eklemediniz.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Dilbilgisi",
"spanTypeWordChoice": "Kelime Seçimi",
"spanTypeSpelling": "Yazım",
"spanTypePunctuation": "Noktalama",
"spanTypeStyle": "Üslup",
"spanTypeFluency": "Akıcılık",
"spanTypeAccents": "Aksanlar",
"spanTypeCapitalization": "Büyük Harf Kullanımı",
"spanTypeCorrection": "Düzeltme",
"spanFeedbackTitle": "Düzeltme sorununu bildir",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "uk",
"@@last_modified": "2021-08-14 12:41:09.790615",
"@@last_modified": "2026-02-09 15:31:25.978266",
"about": "Про застосунок",
"@about": {
"type": "String",
@ -10845,8 +10845,6 @@
"congratulations": "Вітаємо!",
"anotherRound": "Ще один раунд",
"noActivityRequest": "Немає поточного запиту на активність.",
"quit": "Вийти",
"congratulationsYouveCompletedPractice": "Вітаємо! Ви завершили практичну сесію.",
"mustHave10Words": "Вам потрібно мати принаймні 10 слів для практики. Спробуйте поговорити з другом або Pangea Bot, щоб дізнатися більше!",
"botSettings": "Налаштування бота",
"activitySettingsOverrideWarning": "Мова та рівень мови визначаються планом активності",
@ -10895,14 +10893,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11078,6 +11068,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Про мене",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Ідіома",
"grammarCopyPOSphrasalv": "Фразове дієслово",
"grammarCopyPOScompn": "Складене",
@ -11092,5 +11087,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Ідеальна практика!",
"greatPractice": "Чудова практика!",
"usedNoHints": "Чудова робота, що не використовували підказки!",
"youveCompletedPractice": "Ви завершили практику, продовжуйте в тому ж дусі, щоб покращитися!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Змінити електронну пошту",
"withTheseAddressesDescription": "З цими електронними адресами ви можете увійти, відновити свій пароль і керувати підписками.",
"noAddressDescription": "Ви ще не додали жодних електронних адрес.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Граматика",
"spanTypeWordChoice": "Вибір слів",
"spanTypeSpelling": "Орфографія",
"spanTypePunctuation": "Пунктуація",
"spanTypeStyle": "Стиль",
"spanTypeFluency": "Вільність",
"spanTypeAccents": "Акценти",
"spanTypeCapitalization": "Великі літери",
"spanTypeCorrection": "Виправлення",
"spanFeedbackTitle": "Повідомити про проблему з виправленням",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2021-08-14 12:41:09.781172",
"@@last_modified": "2026-02-09 15:32:04.137171",
"about": "Giới thiệu",
"@about": {
"type": "String",
@ -6309,8 +6309,6 @@
"congratulations": "Chúc mừng!",
"anotherRound": "Một vòng nữa",
"noActivityRequest": "Không có yêu cầu hoạt động nào hiện tại.",
"quit": "Thoát",
"congratulationsYouveCompletedPractice": "Chúc mừng! Bạn đã hoàn thành buổi thực hành.",
"mustHave10Words": "Bạn phải có ít nhất 10 từ vựng để thực hành. Hãy thử nói chuyện với một người bạn hoặc Pangea Bot để khám phá thêm!",
"botSettings": "Cài đặt Bot",
"activitySettingsOverrideWarning": "Ngôn ngữ và cấp độ ngôn ngữ được xác định bởi kế hoạch hoạt động",
@ -6359,14 +6357,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -6542,6 +6532,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "Về tôi",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "Thành ngữ",
"grammarCopyPOSphrasalv": "Động từ cụm",
"grammarCopyPOScompn": "Hợp chất",
@ -6556,5 +6551,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "Thực hành hoàn hảo!",
"greatPractice": "Thực hành tuyệt vời!",
"usedNoHints": "Làm tốt lắm khi không sử dụng bất kỳ gợi ý nào!",
"youveCompletedPractice": "Bạn đã hoàn thành thực hành, hãy tiếp tục để cải thiện!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "Thay đổi email",
"withTheseAddressesDescription": "Với những địa chỉ email này, bạn có thể đăng nhập, khôi phục mật khẩu và quản lý đăng ký.",
"noAddressDescription": "Bạn chưa thêm địa chỉ email nào.",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Ngữ pháp",
"spanTypeWordChoice": "Lựa chọn từ",
"spanTypeSpelling": "Chính tả",
"spanTypePunctuation": "Dấu câu",
"spanTypeStyle": "Phong cách",
"spanTypeFluency": "Lưu loát",
"spanTypeAccents": "Giọng điệu",
"spanTypeCapitalization": "Chữ hoa",
"spanTypeCorrection": "Sửa lỗi",
"spanFeedbackTitle": "Báo cáo vấn đề sửa lỗi",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1852,7 +1852,7 @@
"selectAll": "全選",
"deselectAll": "取消全選",
"@@locale": "yue",
"@@last_modified": "2026-02-05 10:09:39.916672",
"@@last_modified": "2026-02-09 15:31:21.578854",
"@ignoreUser": {
"type": "String",
"placeholders": {}
@ -11817,8 +11817,6 @@
"congratulations": "恭喜!",
"anotherRound": "再來一輪",
"noActivityRequest": "目前沒有活動請求。",
"quit": "退出",
"congratulationsYouveCompletedPractice": "恭喜!你已完成練習課程。",
"mustHave10Words": "你必須至少有 10 個詞彙來練習它們。試著和朋友或 Pangea Bot 談談以發現更多!",
"botSettings": "機械人設置",
"activitySettingsOverrideWarning": "活動計劃決定的語言和語言水平",
@ -11867,14 +11865,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -12050,6 +12040,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "關於我",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "成語",
"grammarCopyPOSphrasalv": "短語動詞",
"grammarCopyPOScompn": "複合詞",
@ -12064,5 +12059,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "完美的練習!",
"greatPractice": "很棒的練習!",
"usedNoHints": "不使用任何提示,做得好!",
"youveCompletedPractice": "你已經完成了練習,繼續努力以變得更好!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "更改電郵",
"withTheseAddressesDescription": "使用這些電郵地址您可以登錄、恢復密碼和管理訂閱。",
"noAddressDescription": "您尚未添加任何電郵地址。",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "文法",
"spanTypeWordChoice": "用詞選擇",
"spanTypeSpelling": "拼寫",
"spanTypePunctuation": "標點符號",
"spanTypeStyle": "風格",
"spanTypeFluency": "流暢度",
"spanTypeAccents": "口音",
"spanTypeCapitalization": "大寫",
"spanTypeCorrection": "更正",
"spanFeedbackTitle": "報告更正問題",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "zh",
"@@last_modified": "2021-08-14 12:41:09.767805",
"@@last_modified": "2026-02-09 15:32:14.219030",
"about": "关于",
"@about": {
"type": "String",
@ -10845,8 +10845,6 @@
"congratulations": "恭喜!",
"anotherRound": "再来一轮",
"noActivityRequest": "当前没有活动请求。",
"quit": "退出",
"congratulationsYouveCompletedPractice": "恭喜!您已完成练习课程。",
"mustHave10Words": "您必须至少有 10 个词汇来进行练习。尝试与朋友或 Pangea Bot 交谈以发现更多!",
"botSettings": "机器人设置",
"activitySettingsOverrideWarning": "活动计划确定的语言和语言级别",
@ -10895,14 +10893,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -11078,6 +11068,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "关于我",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "成语",
"grammarCopyPOSphrasalv": "短语动词",
"grammarCopyPOScompn": "复合词",
@ -11092,5 +11087,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "完美的练习!",
"greatPractice": "很棒的练习!",
"usedNoHints": "很好,没使用任何提示!",
"youveCompletedPractice": "你已经完成了练习,继续努力以变得更好!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "更改电子邮件",
"withTheseAddressesDescription": "使用这些电子邮件地址,您可以登录、恢复密码和管理订阅。",
"noAddressDescription": "您尚未添加任何电子邮件地址。",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "语法",
"spanTypeWordChoice": "用词选择",
"spanTypeSpelling": "拼写",
"spanTypePunctuation": "标点",
"spanTypeStyle": "风格",
"spanTypeFluency": "流利度",
"spanTypeAccents": "口音",
"spanTypeCapitalization": "大写",
"spanTypeCorrection": "更正",
"spanFeedbackTitle": "报告更正问题",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2021-08-14 12:41:09.708353",
"@@last_modified": "2026-02-09 15:31:45.022584",
"about": "關於",
"@about": {
"type": "String",
@ -10726,8 +10726,6 @@
"congratulations": "恭喜!",
"anotherRound": "再來一輪",
"noActivityRequest": "目前沒有活動請求。",
"quit": "退出",
"congratulationsYouveCompletedPractice": "恭喜!您已完成練習課程。",
"mustHave10Words": "您必須至少有 10 個詞彙來進行練習。嘗試與朋友或 Pangea Bot 交談以發現更多!",
"botSettings": "機器人設定",
"activitySettingsOverrideWarning": "語言和語言級別由活動計劃決定",
@ -10776,14 +10774,6 @@
"type": "String",
"placeholders": {}
},
"@quit": {
"type": "String",
"placeholders": {}
},
"@congratulationsYouveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"@mustHave10Words": {
"type": "String",
"placeholders": {}
@ -10959,6 +10949,11 @@
"type": "String",
"placeholders": {}
},
"aboutMeHint": "關於我",
"@aboutMeHint": {
"type": "String",
"placeholders": {}
},
"grammarCopyPOSidiom": "成語",
"grammarCopyPOSphrasalv": "片語動詞",
"grammarCopyPOScompn": "合成詞",
@ -10973,5 +10968,90 @@
"@grammarCopyPOScompn": {
"type": "String",
"placeholders": {}
},
"perfectPractice": "完美的練習!",
"greatPractice": "很棒的練習!",
"usedNoHints": "不使用任何提示,做得好!",
"youveCompletedPractice": "你已完成練習,繼續努力以變得更好!",
"@perfectPractice": {
"type": "String",
"placeholders": {}
},
"@greatPractice": {
"type": "String",
"placeholders": {}
},
"@usedNoHints": {
"type": "String",
"placeholders": {}
},
"@youveCompletedPractice": {
"type": "String",
"placeholders": {}
},
"changeEmail": "更改電子郵件",
"withTheseAddressesDescription": "使用這些電子郵件地址,您可以登錄、恢復密碼和管理訂閱。",
"noAddressDescription": "您尚未添加任何電子郵件地址。",
"@changeEmail": {
"type": "String",
"placeholders": {}
},
"@withTheseAddressesDescription": {
"type": "String",
"placeholders": {}
},
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "文法",
"spanTypeWordChoice": "用詞選擇",
"spanTypeSpelling": "拼寫",
"spanTypePunctuation": "標點符號",
"spanTypeStyle": "風格",
"spanTypeFluency": "流暢度",
"spanTypeAccents": "重音",
"spanTypeCapitalization": "大寫",
"spanTypeCorrection": "修正",
"spanFeedbackTitle": "報告修正問題",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -2253,18 +2253,23 @@ class ChatController extends State<ChatPageWithRoom>
choreographer,
context,
showNextMatch,
(feedback) => onRequestWritingAssistance(feedback: feedback),
);
}
Future<void> onRequestWritingAssistance({
bool manual = false,
bool autosend = false,
String? feedback,
}) async {
if (shouldShowLanguageMismatchPopupByActivity) {
return showLanguageMismatchPopup(manual: manual);
}
await choreographer.requestWritingAssistance(manual: manual);
feedback == null
? await choreographer.requestWritingAssistance(manual: manual)
: await choreographer.rerunWithFeedback(feedback);
if (choreographer.assistanceState == AssistanceStateEnum.fetched) {
showNextMatch();
} else if (autosend) {

View file

@ -37,6 +37,7 @@ class InputBar extends StatelessWidget {
final PangeaTextController? controller;
final Choreographer choreographer;
final VoidCallback showNextMatch;
final Future Function(String) onFeedbackSubmitted;
// Pangea#
final InputDecoration decoration;
final ValueChanged<String>? onChanged;
@ -62,6 +63,7 @@ class InputBar extends StatelessWidget {
// #Pangea
required this.choreographer,
required this.showNextMatch,
required this.onFeedbackSubmitted,
// Pangea#
super.key,
});
@ -431,7 +433,13 @@ class InputBar extends StatelessWidget {
if (match.updatedMatch.isITStart) {
choreographer.itController.openIT(controller!.text);
} else {
OverlayUtil.showIGCMatch(match, choreographer, context, showNextMatch);
OverlayUtil.showIGCMatch(
match,
choreographer,
context,
showNextMatch,
onFeedbackSubmitted,
);
// rebuild the text field to highlight the newly selected match
choreographer.textController.setSystemText(
@ -475,9 +483,9 @@ class InputBar extends StatelessWidget {
optionsBuilder: getSuggestions,
// #Pangea
// fieldViewBuilder: (context, controller, focusNode, _) => TextField(
fieldViewBuilder: (context, _, focusNode, _) => ValueListenableBuilder(
valueListenable: choreographer.itController.open,
builder: (context, _, _) {
fieldViewBuilder: (context, _, focusNode, _) => ListenableBuilder(
listenable: choreographer,
builder: (context, _) {
return TextField(
// Pangea#
controller: controller,

View file

@ -20,7 +20,10 @@ class Settings3PidView extends StatelessWidget {
return Scaffold(
appBar: AppBar(
leading: const Center(child: BackButton()),
title: Text(L10n.of(context).passwordRecovery),
// #Pangea
// title: Text(L10n.of(context).passwordRecovery),
title: Text(L10n.of(context).changeEmail),
// Pangea#
actions: [
IconButton(
icon: const Icon(Icons.add_outlined),
@ -68,10 +71,14 @@ class Settings3PidView extends StatelessWidget {
),
title: Text(
identifier.isEmpty
? L10n.of(context).noPasswordRecoveryDescription
: L10n.of(
context,
).withTheseAddressesRecoveryDescription,
// #Pangea
// ? L10n.of(context).noPasswordRecoveryDescription
// : L10n.of(
// context,
// ).withTheseAddressesRecoveryDescription,
? L10n.of(context).noAddressDescription
: L10n.of(context).withTheseAddressesDescription,
// Pangea#
),
),
const Divider(),

View file

@ -57,9 +57,7 @@ class SettingsNotificationsView extends StatelessWidget {
child: snapshot.data != false
? const SizedBox()
: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
),
padding: const EdgeInsets.fromLTRB(16, 8, 28, 8),
child: ListTile(
tileColor: theme.colorScheme.primaryContainer,
leading: Icon(

View file

@ -1,7 +1,5 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/settings_password/settings_password.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
@ -75,11 +73,13 @@ class SettingsPasswordView extends StatelessWidget {
: Text(L10n.of(context).changePassword),
),
),
const SizedBox(height: 16),
TextButton(
child: Text(L10n.of(context).passwordRecoverySettings),
onPressed: () => context.go('/rooms/settings/security/3pid'),
),
// #Pangea
// const SizedBox(height: 16),
// TextButton(
// child: Text(L10n.of(context).passwordRecoverySettings),
// onPressed: () => context.go('/rooms/settings/security/3pid'),
// ),
// Pangea#
],
),
),

View file

@ -145,6 +145,16 @@ class SettingsSecurityView extends StatelessWidget {
style: const TextStyle(fontFamily: 'RobotoMono'),
),
),
// #Pangea
if (capabilities?.m3pidChanges?.enabled != false ||
error != null)
ListTile(
leading: const Icon(Icons.mail_outline_rounded),
trailing: const Icon(Icons.chevron_right_outlined),
title: Text(L10n.of(context).changeEmail),
onTap: () => context.go('/rooms/settings/security/3pid'),
),
// Pangea#
if (capabilities?.mChangePassword?.enabled != false ||
error != null)
ListTile(

View file

@ -241,7 +241,7 @@ class AnalyticsDataService {
int? count,
String? roomId,
DateTime? since,
ConstructUseTypeEnum? type,
List<ConstructUseTypeEnum>? types,
bool filterCapped = true,
}) async {
await _ensureInitialized();
@ -249,7 +249,7 @@ class AnalyticsDataService {
count: count,
roomId: roomId,
since: since,
type: type,
types: types,
);
final blocked = blockedConstructs;
@ -442,7 +442,12 @@ class AnalyticsDataService {
final offset = lowerLevelXP - newData.totalXP;
await MatrixState.pangeaController.userController.addXPOffset(offset);
await updateXPOffset(
MatrixState.pangeaController.userController.analyticsProfile!.xpOffset!,
MatrixState
.pangeaController
.userController
.publicProfile!
.analytics
.xpOffset!,
);
}

View file

@ -197,7 +197,7 @@ class AnalyticsDatabase with DatabaseFileStorage {
int? count,
String? roomId,
DateTime? since,
ConstructUseTypeEnum? type,
List<ConstructUseTypeEnum>? types,
}) async {
final stopwatch = Stopwatch()..start();
final results = <OneConstructUse>[];
@ -209,7 +209,7 @@ class AnalyticsDatabase with DatabaseFileStorage {
if (roomId != null && use.metadata.roomId != roomId) {
return true; // skip but continue
}
if (type != null && use.useType != type) {
if (types != null && !types.contains(use.useType)) {
return true; // skip but continue
}

View file

@ -8,9 +8,7 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_misc/learning_skills_enum.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
@ -24,10 +22,7 @@ class LemmaUseExampleMessages extends StatelessWidget {
Future<List<ExampleMessage>> _getExampleMessages() async {
final List<ExampleMessage> examples = [];
for (final OneConstructUse use in construct.cappedUses) {
if (use.useType.skillsEnumType != LearningSkillsEnum.writing ||
use.metadata.eventId == null ||
use.form == null ||
use.xp <= 0) {
if (use.metadata.eventId == null || use.form == null || use.xp <= 0) {
continue;
}

View file

@ -4,14 +4,93 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
/// Internal result class that holds all computed data from building an example message.
class _ExampleMessageResult {
final List<InlineSpan> displaySpans;
final List<PangeaToken> includedTokens;
final String text;
final int adjustedTargetIndex;
final String? eventId;
final String? roomId;
_ExampleMessageResult({
required this.displaySpans,
required this.includedTokens,
required this.text,
required this.adjustedTargetIndex,
this.eventId,
this.roomId,
});
List<InlineSpan> toSpans() => displaySpans;
AudioExampleMessage toAudioExampleMessage() => AudioExampleMessage(
tokens: includedTokens,
eventId: eventId,
roomId: roomId,
exampleMessage: ExampleMessageInfo(exampleMessage: displaySpans),
);
}
class ExampleMessageUtil {
static Future<List<InlineSpan>?> getExampleMessage(
ConstructUses construct,
Client client, {
String? form,
bool noBold = false,
}) async {
final result = await _getExampleMessageResult(
construct,
client,
form: form,
noBold: noBold,
);
return result?.toSpans();
}
static Future<AudioExampleMessage?> getAudioExampleMessage(
ConstructUses construct,
Client client, {
String? form,
bool noBold = false,
}) async {
final result = await _getExampleMessageResult(
construct,
client,
form: form,
noBold: noBold,
);
return result?.toAudioExampleMessage();
}
static Future<List<List<InlineSpan>>> getExampleMessages(
ConstructUses construct,
Client client,
int maxMessages, {
bool noBold = false,
}) async {
final List<List<InlineSpan>> allSpans = [];
for (final use in construct.cappedUses) {
if (allSpans.length >= maxMessages) break;
final event = await client.getEventByConstructUse(use);
if (event == null) continue;
final result = _buildExampleMessage(use.form, event, noBold: noBold);
if (result != null) {
allSpans.add(result.toSpans());
}
}
return allSpans;
}
static Future<_ExampleMessageResult?> _getExampleMessageResult(
ConstructUses construct,
Client client, {
String? form,
bool noBold = false,
}) async {
for (final use in construct.cappedUses) {
if (form != null && use.form != form) continue;
@ -19,36 +98,17 @@ class ExampleMessageUtil {
final event = await client.getEventByConstructUse(use);
if (event == null) continue;
final spans = _buildExampleMessage(use.form, event);
if (spans != null) return spans;
final result = _buildExampleMessage(use.form, event, noBold: noBold);
if (result != null) return result;
}
return null;
}
static Future<List<List<InlineSpan>>> getExampleMessages(
ConstructUses construct,
Client client,
int maxMessages,
) async {
final List<List<InlineSpan>> 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<InlineSpan>? _buildExampleMessage(
static _ExampleMessageResult? _buildExampleMessage(
String? form,
PangeaMessageEvent messageEvent,
) {
PangeaMessageEvent messageEvent, {
bool noBold = false,
}) {
String? text;
List<PangeaToken>? tokens;
int targetTokenIndex = -1;
@ -99,6 +159,7 @@ class ExampleMessageUtil {
// ---------- BEFORE ----------
int beforeStartOffset = 0;
bool trimmedBefore = false;
int firstIncludedTokenIndex = 0;
if (beforeAvailable > beforeBudget) {
final desiredStart = targetStart - beforeBudget;
@ -110,6 +171,7 @@ class ExampleMessageUtil {
if (tokenEnd > desiredStart) {
beforeStartOffset = token.text.offset;
firstIncludedTokenIndex = i;
trimmedBefore = true;
break;
}
@ -124,6 +186,7 @@ class ExampleMessageUtil {
// ---------- AFTER ----------
int afterEndOffset = totalChars;
bool trimmedAfter = false;
int lastIncludedTokenIndex = tokens.length - 1;
if (afterAvailable > afterBudget) {
final desiredEnd = targetEnd + afterBudget;
@ -132,6 +195,7 @@ class ExampleMessageUtil {
final token = tokens[i];
if (token.text.offset >= desiredEnd) {
afterEndOffset = token.text.offset;
lastIncludedTokenIndex = i - 1;
trimmedAfter = true;
break;
}
@ -144,15 +208,36 @@ class ExampleMessageUtil {
.toString()
.trimRight();
return [
final displaySpans = [
if (trimmedBefore) const TextSpan(text: ''),
TextSpan(text: before),
TextSpan(
text: targetToken.text.content,
style: const TextStyle(fontWeight: FontWeight.bold),
style: noBold ? null : const TextStyle(fontWeight: FontWeight.bold),
),
TextSpan(text: after),
if (trimmedAfter) const TextSpan(text: ''),
];
// Extract only the tokens that are included in the displayed text
final includedTokens = tokens.sublist(
firstIncludedTokenIndex,
lastIncludedTokenIndex + 1,
);
// Adjust target token index relative to the included tokens
final adjustedTargetIndex = targetTokenIndex - firstIncludedTokenIndex;
return _ExampleMessageResult(
displaySpans: displaySpans,
includedTokens: includedTokens,
text: text.characters
.skip(beforeStartOffset)
.take(afterEndOffset - beforeStartOffset)
.toString(),
adjustedTargetIndex: adjustedTargetIndex,
eventId: messageEvent.eventId,
roomId: messageEvent.room.id,
);
}
}

View file

@ -19,9 +19,11 @@ class LevelDisplayName extends StatelessWidget {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 2.0),
child: FutureBuilder(
future: MatrixState.pangeaController.userController
.getPublicAnalyticsProfile(userId),
future: MatrixState.pangeaController.userController.getPublicProfile(
userId,
),
builder: (context, snapshot) {
final analytics = snapshot.data?.analytics;
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
@ -39,11 +41,18 @@ class LevelDisplayName extends StatelessWidget {
else
Row(
children: [
if (snapshot.data?.baseLanguage != null &&
snapshot.data?.targetLanguage != null)
if (snapshot.data?.countryEmoji != null)
Padding(
padding: const EdgeInsets.only(right: 4.0),
child: Text(
snapshot.data!.countryEmoji!,
style: textStyle ?? const TextStyle(fontSize: 16.0),
),
),
if (analytics?.baseLanguage != null &&
analytics?.targetLanguage != null)
Text(
snapshot.data!.baseLanguage!.langCodeShort
.toUpperCase(),
analytics!.baseLanguage!.langCodeShort.toUpperCase(),
style:
textStyle ??
TextStyle(
@ -51,16 +60,15 @@ class LevelDisplayName extends StatelessWidget {
color: Theme.of(context).colorScheme.primary,
),
),
if (snapshot.data?.baseLanguage != null &&
snapshot.data?.targetLanguage != null)
if (analytics?.baseLanguage != null &&
analytics?.targetLanguage != null)
Icon(
Icons.chevron_right_outlined,
size: iconSize ?? 16.0,
),
if (snapshot.data?.targetLanguage != null)
if (analytics?.targetLanguage != null)
Text(
snapshot.data!.targetLanguage!.langCodeShort
.toUpperCase(),
analytics!.targetLanguage!.langCodeShort.toUpperCase(),
style:
textStyle ??
TextStyle(
@ -69,11 +77,10 @@ class LevelDisplayName extends StatelessWidget {
),
),
const SizedBox(width: 4.0),
if (snapshot.data?.level != null)
Text("", style: textStyle),
if (snapshot.data?.level != null)
if (analytics?.level != null) Text("", style: textStyle),
if (analytics?.level != null)
Text(
"${snapshot.data!.level!}",
"${analytics!.level!}",
style:
textStyle ??
TextStyle(

View file

@ -13,17 +13,21 @@ import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_misc/example_message_util.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_repo.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_view.dart';
import 'package:fluffychat/pangea/common/utils/async_state.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart';
import 'package:fluffychat/pangea/text_to_speech/tts_controller.dart';
import 'package:fluffychat/pangea/toolbar/message_practice/message_audio_card.dart';
import 'package:fluffychat/pangea/toolbar/message_practice/practice_record_controller.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -93,8 +97,16 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
final ValueNotifier<bool> hintPressedNotifier = ValueNotifier<bool>(false);
final Set<String> _selectedCorrectAnswers = {};
// Track if we're showing the completion message for audio activities
final ValueNotifier<bool> showingAudioCompletion = ValueNotifier<bool>(false);
final ValueNotifier<int> hintsUsedNotifier = ValueNotifier<int>(0);
static const int maxHints = 5;
final Map<String, Map<String, String>> _choiceTexts = {};
final Map<String, Map<String, String?>> _choiceEmojis = {};
final Map<String, PangeaAudioFile> _audioFiles = {};
StreamSubscription<void>? _languageStreamSubscription;
@ -121,6 +133,8 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
enableChoicesNotifier.dispose();
selectedMorphChoice.dispose();
hintPressedNotifier.dispose();
showingAudioCompletion.dispose();
hintsUsedNotifier.dispose();
super.dispose();
}
@ -207,8 +221,10 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
activityTarget.value = null;
selectedMorphChoice.value = null;
hintPressedNotifier.value = false;
hintsUsedNotifier.value = 0;
enableChoicesNotifier.value = true;
progressNotifier.value = 0.0;
showingAudioCompletion.value = false;
_queue.clear();
_choiceTexts.clear();
_choiceEmojis.clear();
@ -225,7 +241,11 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
void _playAudio() {
if (activityTarget.value == null) return;
if (widget.type != ConstructTypeEnum.vocab) return;
if (widget.type == ConstructTypeEnum.vocab &&
_currentActivity is VocabMeaningPracticeActivityModel) {
} else {
return;
}
TtsController.tryToSpeak(
activityTarget.value!.target.tokens.first.vocabConstructID.lemma,
langCode: MatrixState.pangeaController.userController.userL2!.langCode,
@ -315,6 +335,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
if (_continuing) return;
_continuing = true;
enableChoicesNotifier.value = true;
showingAudioCompletion.value = false;
try {
if (activityState.value
@ -326,6 +347,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
activityState.value = const AsyncState.loading();
selectedMorphChoice.value = null;
hintPressedNotifier.value = false;
_selectedCorrectAnswers.clear();
final nextActivityCompleter = _queue.removeFirst();
try {
@ -418,9 +440,57 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
await _fetchLemmaInfo(activityModel.storageKey, choices);
}
// Prefetch audio for audio activities before marking ready
if (activityModel is VocabAudioPracticeActivityModel) {
await _loadAudioForActivity(activityModel);
}
return activityModel;
}
Future<void> _loadAudioForActivity(
VocabAudioPracticeActivityModel activity,
) async {
final eventId = activity.eventId;
final roomId = activity.roomId;
if (eventId == null || roomId == null) {
throw L10n.of(context).oopsSomethingWentWrong;
}
final client = MatrixState.pangeaController.matrixState.client;
final room = client.getRoomById(roomId);
if (room == null) {
throw L10n.of(context).oopsSomethingWentWrong;
}
final event = await room.getEventById(eventId);
if (event == null) {
throw L10n.of(context).oopsSomethingWentWrong;
}
final pangeaEvent = PangeaMessageEvent(
event: event,
timeline: await room.getTimeline(),
ownMessage: event.senderId == client.userID,
);
// Prefetch the audio file
final audioFile = await pangeaEvent.requestTextToSpeech(
activity.langCode,
MatrixState.pangeaController.userController.voice,
);
// Store the audio file with the eventId as key
_audioFiles[eventId] = audioFile;
}
PangeaAudioFile? getAudioFile(String? eventId) {
if (eventId == null) return null;
return _audioFiles[eventId];
}
Future<void> _fetchLemmaInfo(
String requestKey,
List<String> choiceIds,
@ -468,7 +538,27 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
}
void onHintPressed() {
hintPressedNotifier.value = !hintPressedNotifier.value;
if (hintsUsedNotifier.value >= maxHints) return;
if (!hintPressedNotifier.value) {
hintsUsedNotifier.value++;
}
hintPressedNotifier.value = true;
}
Future<void> onAudioContinuePressed() async {
showingAudioCompletion.value = false;
//Mark this activity as completed, and either load the next or complete the session
_sessionLoader.value!.completeActivity();
progressNotifier.value = _sessionLoader.value!.progress;
if (_queue.isEmpty) {
await _completeSession();
} else if (_isComplete) {
await _completeSession();
} else {
await _continueSession();
}
}
Future<void> onSelectChoice(String choiceContent) async {
@ -482,8 +572,17 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
tag: choiceContent,
);
}
final isCorrect = activity.multipleChoiceContent.isCorrect(choiceContent);
if (isCorrect) {
final isAudioActivity =
activity.activityType == ActivityTypeEnum.lemmaAudio;
if (isAudioActivity && isCorrect) {
_selectedCorrectAnswers.add(choiceContent);
}
if (isCorrect && !isAudioActivity) {
// Non-audio activities disable choices after first correct answer
enableChoicesNotifier.value = false;
}
@ -501,7 +600,25 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
[use],
);
if (!activity.multipleChoiceContent.isCorrect(choiceContent)) return;
if (!isCorrect) return;
// For audio activities, check if all answers have been selected
if (isAudioActivity) {
final allAnswers = activity.multipleChoiceContent.answers;
final allSelected = allAnswers.every(
(answer) => _selectedCorrectAnswers.contains(answer),
);
if (!allSelected) {
return;
}
// All answers selected, disable choices and show completion message
enableChoicesNotifier.value = false;
await Future.delayed(const Duration(milliseconds: 1000));
showingAudioCompletion.value = true;
return;
}
_playAudio();
@ -529,7 +646,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
final construct = target.targetTokenConstructID(token);
if (widget.type == ConstructTypeEnum.morph) {
return activityRequest.morphExampleInfo?.exampleMessage;
return activityRequest.exampleMessage?.exampleMessage;
}
return ExampleMessageUtil.getExampleMessage(
@ -538,9 +655,48 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
);
}
List<InlineSpan>? getAudioExampleMessage() {
final activity = _currentActivity;
if (activity is VocabAudioPracticeActivityModel) {
return activity.exampleMessage.exampleMessage;
}
return null;
}
Future<DerivedAnalyticsDataModel> get derivedAnalyticsData =>
_analyticsService.derivedData;
/// Returns congratulations message based on performance
String getCompletionMessage(BuildContext context) {
final accuracy = _sessionLoader.value?.state.accuracy ?? 0;
final hasTimeBonus =
(_sessionLoader.value?.state.elapsedSeconds ?? 0) <=
AnalyticsPracticeConstants.timeForBonus;
final hintsUsed = hintsUsedNotifier.value;
final bool perfectAccuracy = accuracy == 100;
final bool noHintsUsed = hintsUsed == 0;
final bool hintsAvailable = widget.type == ConstructTypeEnum.morph;
//check how many conditions for bonuses the user met and return message accordingly
final conditionsMet = [
perfectAccuracy,
!hintsAvailable || noHintsUsed,
hasTimeBonus,
].where((c) => c).length;
if (conditionsMet == 3) {
return L10n.of(context).perfectPractice;
}
if (conditionsMet >= 2) {
return L10n.of(context).greatPractice;
}
if (hintsAvailable && noHintsUsed) {
return L10n.of(context).usedNoHints;
}
return L10n.of(context).youveCompletedPractice;
}
@override
Widget build(BuildContext context) => AnalyticsPracticeView(this);
}

View file

@ -3,13 +3,14 @@ import 'package:flutter/painting.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
class MorphExampleInfo {
class ExampleMessageInfo {
final List<InlineSpan> exampleMessage;
const MorphExampleInfo({required this.exampleMessage});
const ExampleMessageInfo({required this.exampleMessage});
Map<String, dynamic> toJson() {
final segments = <Map<String, dynamic>>[];
@ -26,7 +27,7 @@ class MorphExampleInfo {
return {'segments': segments};
}
factory MorphExampleInfo.fromJson(Map<String, dynamic> json) {
factory ExampleMessageInfo.fromJson(Map<String, dynamic> json) {
final segments = json['segments'] as List<dynamic>? ?? [];
final spans = <InlineSpan>[];
@ -42,25 +43,57 @@ class MorphExampleInfo {
);
}
return MorphExampleInfo(exampleMessage: spans);
return ExampleMessageInfo(exampleMessage: spans);
}
}
/// An extended example message that includes both formatted display spans and tokens to generate audio practice activities.
/// eventId/roomId are needed for audio playback.
class AudioExampleMessage {
final List<PangeaToken> tokens;
final String? eventId;
final String? roomId;
final ExampleMessageInfo exampleMessage;
const AudioExampleMessage({
required this.tokens,
this.eventId,
this.roomId,
required this.exampleMessage,
});
Map<String, dynamic> toJson() {
return {'eventId': eventId, 'roomId': roomId};
}
factory AudioExampleMessage.fromJson(Map<String, dynamic> json) {
return AudioExampleMessage(
tokens: const [],
eventId: json['eventId'] as String?,
roomId: json['roomId'] as String?,
exampleMessage: const ExampleMessageInfo(exampleMessage: []),
);
}
}
class AnalyticsActivityTarget {
final PracticeTarget target;
final GrammarErrorRequestInfo? grammarErrorInfo;
final MorphExampleInfo? morphExampleInfo;
final ExampleMessageInfo? exampleMessage;
final AudioExampleMessage? audioExampleMessage;
AnalyticsActivityTarget({
required this.target,
this.grammarErrorInfo,
this.morphExampleInfo,
this.exampleMessage,
this.audioExampleMessage,
});
Map<String, dynamic> toJson() => {
'target': target.toJson(),
'grammarErrorInfo': grammarErrorInfo?.toJson(),
'morphExampleInfo': morphExampleInfo?.toJson(),
'exampleMessage': exampleMessage?.toJson(),
'audioExampleMessage': audioExampleMessage?.toJson(),
};
factory AnalyticsActivityTarget.fromJson(Map<String, dynamic> json) =>
@ -69,8 +102,11 @@ class AnalyticsActivityTarget {
grammarErrorInfo: json['grammarErrorInfo'] != null
? GrammarErrorRequestInfo.fromJson(json['grammarErrorInfo'])
: null,
morphExampleInfo: json['morphExampleInfo'] != null
? MorphExampleInfo.fromJson(json['morphExampleInfo'])
exampleMessage: json['exampleMessage'] != null
? ExampleMessageInfo.fromJson(json['exampleMessage'])
: null,
audioExampleMessage: json['audioExampleMessage'] != null
? AudioExampleMessage.fromJson(json['audioExampleMessage'])
: null,
);
}
@ -133,7 +169,8 @@ class AnalyticsPracticeSessionModel {
activityQualityFeedback: null,
target: target.target,
grammarErrorInfo: target.grammarErrorInfo,
morphExampleInfo: target.morphExampleInfo,
exampleMessage: target.exampleMessage,
audioExampleMessage: target.audioExampleMessage,
);
}).toList();
}

View file

@ -34,29 +34,45 @@ class AnalyticsPracticeSessionRepo {
throw UnsubscribedException();
}
final r = Random();
final activityTypes = ActivityTypeEnum.analyticsPracticeTypes(type);
final types = List.generate(
AnalyticsPracticeConstants.practiceGroupSize +
AnalyticsPracticeConstants.errorBufferSize,
(_) => activityTypes[r.nextInt(activityTypes.length)],
);
final List<AnalyticsActivityTarget> targets = [];
if (type == ConstructTypeEnum.vocab) {
final constructs = await _fetchVocab();
final targetCount = min(constructs.length, types.length);
targets.addAll([
for (var i = 0; i < targetCount; i++)
const totalNeeded =
AnalyticsPracticeConstants.practiceGroupSize +
AnalyticsPracticeConstants.errorBufferSize;
final halfNeeded = (totalNeeded / 2).ceil();
// Fetch audio constructs (with example messages)
final audioMap = await _fetchAudio();
final audioCount = min(audioMap.length, halfNeeded);
// Fetch vocab constructs to fill the rest
final vocabNeeded = totalNeeded - audioCount;
final vocabConstructs = await _fetchVocab();
final vocabCount = min(vocabConstructs.length, vocabNeeded);
for (final entry in audioMap.entries.take(audioCount)) {
targets.add(
AnalyticsActivityTarget(
target: PracticeTarget(
tokens: [constructs[i].asToken],
activityType: types[i],
tokens: [entry.key.asToken],
activityType: ActivityTypeEnum.lemmaAudio,
),
audioExampleMessage: entry.value,
),
);
}
for (var i = 0; i < vocabCount; i++) {
targets.add(
AnalyticsActivityTarget(
target: PracticeTarget(
tokens: [vocabConstructs[i].asToken],
activityType: ActivityTypeEnum.lemmaMeaning,
),
),
]);
);
}
targets.shuffle();
} else {
final errorTargets = await _fetchErrors();
targets.addAll(errorTargets);
@ -78,7 +94,7 @@ class AnalyticsPracticeSessionRepo {
activityType: ActivityTypeEnum.grammarCategory,
morphFeature: entry.feature,
),
morphExampleInfo: MorphExampleInfo(
exampleMessage: ExampleMessageInfo(
exampleMessage: entry.exampleMessage,
),
),
@ -135,6 +151,64 @@ class AnalyticsPracticeSessionRepo {
return targets;
}
static Future<Map<ConstructIdentifier, AudioExampleMessage>>
_fetchAudio() async {
final constructs = await MatrixState
.pangeaController
.matrixState
.analyticsDataService
.getAggregatedConstructs(ConstructTypeEnum.vocab)
.then((map) => map.values.toList());
// sort by last used descending, nulls first
constructs.sort((a, b) {
final dateA = a.lastUsed;
final dateB = b.lastUsed;
if (dateA == null && dateB == null) return 0;
if (dateA == null) return -1;
if (dateB == null) return 1;
return dateA.compareTo(dateB);
});
final Set<String> seenLemmas = {};
final Set<String> seenEventIds = {};
final targets = <ConstructIdentifier, AudioExampleMessage>{};
for (final construct in constructs) {
if (targets.length >=
(AnalyticsPracticeConstants.practiceGroupSize +
AnalyticsPracticeConstants.errorBufferSize)) {
break;
}
if (seenLemmas.contains(construct.lemma)) continue;
// Try to get an audio example message with token data for this lemma
final audioExampleMessage =
await ExampleMessageUtil.getAudioExampleMessage(
await MatrixState.pangeaController.matrixState.analyticsDataService
.getConstructUse(construct.id),
MatrixState.pangeaController.matrixState.client,
noBold: true,
);
// Only add to targets if we found an example message AND its eventId hasn't been used
if (audioExampleMessage != null) {
final eventId = audioExampleMessage.eventId;
if (eventId != null && seenEventIds.contains(eventId)) {
continue;
}
seenLemmas.add(construct.lemma);
if (eventId != null) {
seenEventIds.add(eventId);
}
targets[construct.id] = audioExampleMessage;
}
}
return targets;
}
static Future<List<MorphPracticeTarget>> _fetchMorphs() async {
final constructs = await MatrixState
.pangeaController
@ -245,19 +319,26 @@ class AnalyticsPracticeSessionRepo {
}
static Future<List<AnalyticsActivityTarget>> _fetchErrors() async {
// Fetch all recent uses in one call (not filtering blocked constructs)
final allRecentUses = await MatrixState
.pangeaController
.matrixState
.analyticsDataService
.getUses(count: 200, filterCapped: false);
.getUses(
count: 300,
filterCapped: false,
types: [
ConstructUseTypeEnum.ga,
ConstructUseTypeEnum.corGE,
ConstructUseTypeEnum.incGE,
],
);
// Filter for grammar error uses
final grammarErrorUses = allRecentUses
.where((use) => use.useType == ConstructUseTypeEnum.ga)
.toList();
// Create list of recently used constructs
// Create list of recently practiced constructs (last 24 hours)
final cutoffTime = DateTime.now().subtract(const Duration(hours: 24));
final recentlyPracticedConstructs = allRecentUses
.where(

View file

@ -4,11 +4,11 @@ import 'package:fluffychat/config/app_config.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/audio_player.dart';
import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
import 'package:fluffychat/pangea/analytics_practice/choice_cards/audio_choice_card.dart';
import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart';
import 'package:fluffychat/pangea/analytics_practice/choice_cards/grammar_choice_card.dart';
import 'package:fluffychat/pangea/analytics_practice/choice_cards/meaning_choice_card.dart';
@ -114,48 +114,84 @@ class _AnalyticsActivityView extends StatelessWidget {
: Theme.of(context).textTheme.titleMedium;
titleStyle = titleStyle?.copyWith(fontWeight: FontWeight.bold);
return ListView(
return Column(
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),
Expanded(
child: ListView(
children: [
//Hints counter bar for grammar activities only
if (controller.widget.type == ConstructTypeEnum.morph)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: _HintsCounterBar(controller: controller),
),
//per-activity instructions, add switch statement once there are more types
const InstructionsInlineTooltip(
instructionsEnum: InstructionsEnum.selectMeaning,
padding: EdgeInsets.symmetric(vertical: 8.0),
),
SizedBox(
height: 75.0,
child: ValueListenableBuilder(
valueListenable: controller.activityTarget,
builder: (context, target, _) {
if (target == null) return const SizedBox.shrink();
final isAudioActivity =
target.target.activityType ==
ActivityTypeEnum.lemmaAudio;
final isVocabType =
controller.widget.type == ConstructTypeEnum.vocab;
return Column(
children: [
Text(
isAudioActivity && isVocabType
? L10n.of(context).selectAllWords
: target.promptText(context),
textAlign: TextAlign.center,
style: titleStyle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
)
: const SizedBox.shrink(),
if (isVocabType && !isAudioActivity)
PhoneticTranscriptionWidget(
text: target
.target
.tokens
.first
.vocabConstructID
.lemma,
textLanguage: MatrixState
.pangeaController
.userController
.userL2!,
style: const TextStyle(fontSize: 14.0),
),
],
);
},
),
),
const SizedBox(height: 16.0),
Center(
child: _AnalyticsPracticeCenterContent(controller: controller),
),
const SizedBox(height: 16.0),
(controller.widget.type == ConstructTypeEnum.morph)
? Center(child: _HintSection(controller: controller))
: const SizedBox.shrink(),
const SizedBox(height: 16.0),
_ActivityChoicesWidget(controller),
const SizedBox(height: 16.0),
_WrongAnswerFeedback(controller: controller),
],
),
),
const SizedBox(height: 16.0),
Center(child: _AnalyticsPracticeCenterContent(controller: controller)),
const SizedBox(height: 16.0),
_ActivityChoicesWidget(controller),
const SizedBox(height: 16.0),
_WrongAnswerFeedback(controller: controller),
Container(
alignment: Alignment.bottomCenter,
child: _AudioContinueButton(controller: controller),
),
],
);
}
@ -172,49 +208,60 @@ class _AnalyticsPracticeCenterContent extends StatelessWidget {
valueListenable: controller.activityTarget,
builder: (context, target, _) => switch (target?.target.activityType) {
null => const SizedBox(),
ActivityTypeEnum.grammarError => SizedBox(
height: 160.0,
child: SingleChildScrollView(
child: ValueListenableBuilder(
valueListenable: controller.activityState,
builder: (context, state, _) => switch (state) {
AsyncLoaded(
value: final GrammarErrorPracticeActivityModel activity,
) =>
Column(
mainAxisSize: MainAxisSize.min,
children: [
_ErrorBlankWidget(
key: ValueKey(
'${activity.eventID}_${activity.errorOffset}_${activity.errorLength}',
),
activity: activity,
),
const SizedBox(height: 12),
],
),
_ => const SizedBox(),
},
),
ActivityTypeEnum.grammarError => SingleChildScrollView(
child: ListenableBuilder(
listenable: Listenable.merge([
controller.activityState,
controller.hintPressedNotifier,
]),
builder: (context, _) {
final state = controller.activityState.value;
if (state is! AsyncLoaded<MultipleChoicePracticeActivityModel>) {
return const SizedBox();
}
final activity = state.value;
if (activity is! GrammarErrorPracticeActivityModel) {
return const SizedBox();
}
return _ErrorBlankWidget(
key: ValueKey(
'${activity.eventID}_${activity.errorOffset}_${activity.errorLength}',
),
activity: activity,
showTranslation: controller.hintPressedNotifier.value,
);
},
),
),
ActivityTypeEnum.grammarCategory => Center(
child: Column(
children: [
_CorrectAnswerHint(controller: controller),
_ExampleMessageWidget(controller.getExampleMessage(target!)),
const SizedBox(height: 12),
ValueListenableBuilder(
valueListenable: controller.hintPressedNotifier,
builder: (context, hintPressed, _) {
return HintButton(
depressed: hintPressed,
onPressed: controller.onHintPressed,
);
},
child: _ExampleMessageWidget(controller.getExampleMessage(target!)),
),
ActivityTypeEnum.lemmaAudio => ValueListenableBuilder(
valueListenable: controller.activityState,
builder: (context, state, _) => switch (state) {
AsyncLoaded(
value: final VocabAudioPracticeActivityModel activity,
) =>
SizedBox(
height: 100.0,
child: Center(
child: AudioPlayerWidget(
null,
color: Theme.of(context).colorScheme.primary,
linkColor: Theme.of(context).colorScheme.secondary,
fontSize:
AppSettings.fontSizeFactor.value *
AppConfig.messageFontSize,
eventId: '${activity.eventId}_practice',
roomId: activity.roomId!,
senderId: Matrix.of(context).client.userID!,
matrixFile: controller.getAudioFile(activity.eventId)!,
autoplay: true,
),
),
),
],
),
_ => const SizedBox(height: 100.0),
},
),
_ => SizedBox(
height: 100.0,
@ -227,6 +274,45 @@ class _AnalyticsPracticeCenterContent extends StatelessWidget {
}
}
class _AudioCompletionWidget extends StatelessWidget {
final AnalyticsPracticeState controller;
const _AudioCompletionWidget({super.key, required this.controller});
@override
Widget build(BuildContext context) {
final exampleMessage = controller.getAudioExampleMessage();
if (exampleMessage == null || exampleMessage.isEmpty) {
return const SizedBox(height: 100.0);
}
return Padding(
padding: const EdgeInsets.all(16.0),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Color.alphaBlend(
Colors.white.withAlpha(180),
ThemeData.dark().colorScheme.primary,
),
borderRadius: BorderRadius.circular(16),
),
child: RichText(
text: TextSpan(
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize:
AppSettings.fontSizeFactor.value * AppConfig.messageFontSize,
),
children: exampleMessage,
),
),
),
);
}
}
class _ExampleMessageWidget extends StatelessWidget {
final Future<List<InlineSpan>?> future;
@ -267,43 +353,95 @@ class _ExampleMessageWidget extends StatelessWidget {
}
}
class _CorrectAnswerHint extends StatelessWidget {
class _HintsCounterBar extends StatelessWidget {
final AnalyticsPracticeState controller;
const _CorrectAnswerHint({required this.controller});
const _HintsCounterBar({required this.controller});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: controller.hintPressedNotifier,
builder: (context, hintPressed, _) {
if (!hintPressed) {
valueListenable: controller.hintsUsedNotifier,
builder: (context, hintsUsed, _) {
return Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
AnalyticsPracticeState.maxHints,
(index) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Icon(
index < hintsUsed ? Icons.lightbulb : Icons.lightbulb_outline,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
),
),
),
);
},
);
}
}
class _HintSection extends StatelessWidget {
final AnalyticsPracticeState controller;
const _HintSection({required this.controller});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: Listenable.merge([
controller.activityState,
controller.hintPressedNotifier,
controller.hintsUsedNotifier,
]),
builder: (context, _) {
final state = controller.activityState.value;
if (state is! AsyncLoaded<MultipleChoicePracticeActivityModel>) {
return const SizedBox.shrink();
}
return ValueListenableBuilder(
valueListenable: controller.activityState,
builder: (context, state, _) {
if (state is! AsyncLoaded<MultipleChoicePracticeActivityModel>) {
return const SizedBox.shrink();
}
final activity = state.value;
final hintPressed = controller.hintPressedNotifier.value;
final hintsUsed = controller.hintsUsedNotifier.value;
final maxHintsReached = hintsUsed >= AnalyticsPracticeState.maxHints;
final activity = state.value;
if (activity is! MorphPracticeActivityModel) {
return const SizedBox.shrink();
}
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 50.0),
child: Builder(
builder: (context) {
// For grammar category: fade out button and show hint content
if (activity is MorphPracticeActivityModel) {
return AnimatedCrossFade(
duration: const Duration(milliseconds: 200),
crossFadeState: hintPressed
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: HintButton(
onPressed: maxHintsReached
? () {}
: controller.onHintPressed,
depressed: maxHintsReached,
),
secondChild: MorphMeaningWidget(
feature: activity.morphFeature,
tag: activity.multipleChoiceContent.answers.first,
),
);
}
final correctAnswerTag =
activity.multipleChoiceContent.answers.first;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: MorphMeaningWidget(
feature: activity.morphFeature,
tag: correctAnswerTag,
),
);
},
// For grammar error: button stays pressed, hint shows in ErrorBlankWidget
return HintButton(
onPressed: (hintPressed || maxHintsReached)
? () {}
: controller.onHintPressed,
depressed: hintPressed || maxHintsReached,
);
},
),
);
},
);
@ -354,30 +492,21 @@ class _WrongAnswerFeedback extends StatelessWidget {
}
}
class _ErrorBlankWidget extends StatefulWidget {
class _ErrorBlankWidget extends StatelessWidget {
final GrammarErrorPracticeActivityModel activity;
final bool showTranslation;
const _ErrorBlankWidget({super.key, required this.activity});
@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;
});
}
const _ErrorBlankWidget({
super.key,
required this.activity,
required this.showTranslation,
});
@override
Widget build(BuildContext context) {
final text = widget.activity.text;
final errorOffset = widget.activity.errorOffset;
final errorLength = widget.activity.errorLength;
final text = activity.text;
final errorOffset = activity.errorOffset;
final errorLength = activity.errorLength;
const maxContextChars = 50;
@ -426,65 +555,72 @@ class _ErrorBlankWidgetState extends State<_ErrorBlankWidget> {
final after = chars.skip(errorEnd).take(afterEnd - errorEnd).toString();
return Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Color.alphaBlend(
Colors.white.withAlpha(180),
ThemeData.dark().colorScheme.primary,
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
RichText(
text: TextSpan(
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize:
AppSettings.fontSizeFactor.value *
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:
AppSettings.fontSizeFactor.value *
AppConfig.messageFontSize,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.left,
)
: const SizedBox.shrink(),
],
),
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Color.alphaBlend(
Colors.white.withAlpha(180),
ThemeData.dark().colorScheme.primary,
),
const SizedBox(height: 8),
HintButton(depressed: _showTranslation, onPressed: _toggleTranslation),
],
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RichText(
text: TextSpan(
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize:
AppSettings.fontSizeFactor.value *
AppConfig.messageFontSize,
),
children: [
if (trimmedBefore) const TextSpan(text: ''),
if (before.isNotEmpty) TextSpan(text: before),
WidgetSpan(
child: Container(
height: 4.0,
width: (errorLength * 8).toDouble(),
padding: const EdgeInsets.only(bottom: 2.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
),
),
),
if (after.isNotEmpty) TextSpan(text: after),
if (trimmedAfter) const TextSpan(text: ''),
],
),
),
AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: showTranslation
? Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Text(
activity.translation,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize:
AppSettings.fontSizeFactor.value *
AppConfig.messageFontSize,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
)
: const SizedBox.shrink(),
),
],
),
);
}
}
@ -565,6 +701,62 @@ class _ActivityChoicesWidget extends StatelessWidget {
valueListenable: controller.enableChoicesNotifier,
builder: (context, enabled, _) {
final choices = controller.filteredChoices(value);
final isAudioActivity =
value.activityType == ActivityTypeEnum.lemmaAudio;
if (isAudioActivity) {
// For audio activities, use AnimatedSwitcher to fade between choices and example message
return ValueListenableBuilder(
valueListenable: controller.showingAudioCompletion,
builder: (context, showingCompletion, _) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.topCenter,
children: <Widget>[
...previousChildren,
?currentChild,
],
);
},
child: showingCompletion
? _AudioCompletionWidget(
key: const ValueKey('completion'),
controller: controller,
)
: Padding(
key: const ValueKey('choices'),
padding: const EdgeInsets.all(16.0),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 8.0,
runSpacing: 8.0,
children: choices
.map(
(choice) => _ChoiceCard(
activity: value,
targetId: controller.choiceTargetId(
choice.choiceId,
),
choiceId: choice.choiceId,
onPressed: () => controller
.onSelectChoice(choice.choiceId),
cardHeight: 48.0,
choiceText: choice.choiceText,
choiceEmoji: choice.choiceEmoji,
enabled: enabled,
shrinkWrap: true,
),
)
.toList(),
),
),
);
},
);
}
return Column(
spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.center,
@ -596,6 +788,54 @@ class _ActivityChoicesWidget extends StatelessWidget {
}
}
class _AudioContinueButton extends StatelessWidget {
final AnalyticsPracticeState controller;
const _AudioContinueButton({required this.controller});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: controller.activityState,
builder: (context, state, _) {
// Only show for audio activities
if (state is! AsyncLoaded<MultipleChoicePracticeActivityModel>) {
return const SizedBox.shrink();
}
final activity = state.value;
if (activity.activityType != ActivityTypeEnum.lemmaAudio) {
return const SizedBox.shrink();
}
return ValueListenableBuilder(
valueListenable: controller.showingAudioCompletion,
builder: (context, showingCompletion, _) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton(
onPressed: showingCompletion
? controller.onAudioContinuePressed
: null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 48.0,
vertical: 16.0,
),
),
child: Text(
L10n.of(context).continueText,
style: const TextStyle(fontSize: 18.0),
),
),
);
},
);
},
);
}
}
class _ChoiceCard extends StatelessWidget {
final MultipleChoicePracticeActivityModel activity;
final String choiceId;
@ -606,6 +846,7 @@ class _ChoiceCard extends StatelessWidget {
final String choiceText;
final String? choiceEmoji;
final bool enabled;
final bool shrinkWrap;
const _ChoiceCard({
required this.activity,
@ -616,6 +857,7 @@ class _ChoiceCard extends StatelessWidget {
required this.choiceText,
required this.choiceEmoji,
this.enabled = true,
this.shrinkWrap = false,
});
@override
@ -641,16 +883,18 @@ class _ChoiceCard extends StatelessWidget {
);
case ActivityTypeEnum.lemmaAudio:
return AudioChoiceCard(
return GameChoiceCard(
key: ValueKey(
'${constructId.string}_${activityType.name}_audio_$choiceId',
),
text: choiceId,
shouldFlip: false,
targetId: targetId,
onPressed: onPressed,
isCorrect: isCorrect,
height: cardHeight,
isEnabled: enabled,
shrinkWrap: shrinkWrap,
child: Text(choiceText, textAlign: TextAlign.center),
);
case ActivityTypeEnum.grammarCategory:

View file

@ -14,6 +14,7 @@ class GameChoiceCard extends StatefulWidget {
final bool shouldFlip;
final String targetId;
final bool isEnabled;
final bool shrinkWrap;
const GameChoiceCard({
required this.child,
@ -24,6 +25,7 @@ class GameChoiceCard extends StatefulWidget {
this.height = 72.0,
this.shouldFlip = false,
this.isEnabled = true,
this.shrinkWrap = false,
super.key,
});
@ -90,7 +92,7 @@ class _GameChoiceCardState extends State<GameChoiceCard>
link: MatrixState.pAnyState.layerLinkAndKey(widget.targetId).link,
child: HoverBuilder(
builder: (context, hovered) => SizedBox(
width: double.infinity,
width: widget.shrinkWrap ? null : double.infinity,
height: widget.height,
child: GestureDetector(
onTap: _handleTap,
@ -109,6 +111,7 @@ class _GameChoiceCardState extends State<GameChoiceCard>
overlayColor: _revealed
? tintColor
: (hovered ? hoverColor : Colors.transparent),
shrinkWrap: widget.shrinkWrap,
child: Opacity(
opacity: showContent ? 1 : 0,
child: _revealed ? widget.altChild! : widget.child,
@ -123,6 +126,7 @@ class _GameChoiceCardState extends State<GameChoiceCard>
overlayColor: _clicked
? tintColor
: (hovered ? hoverColor : Colors.transparent),
shrinkWrap: widget.shrinkWrap,
child: widget.child,
),
),
@ -137,19 +141,24 @@ class _CardContainer extends StatelessWidget {
final Color baseColor;
final Color overlayColor;
final Widget child;
final bool shrinkWrap;
const _CardContainer({
required this.height,
required this.baseColor,
required this.overlayColor,
required this.child,
this.shrinkWrap = false,
});
@override
Widget build(BuildContext context) {
return Container(
height: height,
alignment: Alignment.center,
height: shrinkWrap ? null : height,
padding: shrinkWrap
? const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0)
: null,
alignment: shrinkWrap ? null : Alignment.center,
decoration: BoxDecoration(
color: baseColor,
borderRadius: BorderRadius.circular(16),

View file

@ -47,7 +47,7 @@ class CompletedActivitySessionView extends StatelessWidget {
child: Column(
children: [
Text(
L10n.of(context).congratulationsYouveCompletedPractice,
controller.getCompletionMessage(context),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
@ -163,7 +163,7 @@ class CompletedActivitySessionView extends StatelessWidget {
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [Text(L10n.of(context).quit)],
children: [Text(L10n.of(context).done)],
),
),
],

View file

@ -55,8 +55,8 @@ class MorphCategoryActivityGenerator {
choices: choices.toSet(),
answers: {morphTag},
),
morphExampleInfo:
req.morphExampleInfo ?? const MorphExampleInfo(exampleMessage: []),
exampleMessageInfo:
req.exampleMessage ?? const ExampleMessageInfo(exampleMessage: []),
),
);
}

View file

@ -1,3 +1,4 @@
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
import 'package:fluffychat/pangea/practice_activities/lemma_activity_generator.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart';
@ -6,21 +7,62 @@ import 'package:fluffychat/pangea/practice_activities/practice_activity_model.da
class VocabAudioActivityGenerator {
static Future<MessageActivityResponse> get(MessageActivityRequest req) async {
final token = req.target.tokens.first;
final audioExample = req.audioExampleMessage;
final Set<String> answers = {token.text.content.toLowerCase()};
final Set<String> wordsInMessage = {};
if (audioExample != null) {
for (final t in audioExample.tokens) {
wordsInMessage.add(t.text.content.toLowerCase());
}
// Extract up to 3 additional words as answers
final otherWords = audioExample.tokens
.where(
(t) =>
t.lemma.saveVocab &&
t.text.content.toLowerCase() !=
token.text.content.toLowerCase() &&
t.text.content.trim().isNotEmpty,
)
.take(3)
.map((t) => t.text.content.toLowerCase())
.toList();
answers.addAll(otherWords);
}
// Generate distractors, filtering out anything in the message or answers
final choices = await LemmaActivityGenerator.lemmaActivityDistractors(
token,
maxChoices: 20,
);
final choicesList = choices
.map((c) => c.lemma)
.where(
(lemma) =>
!answers.contains(lemma.toLowerCase()) &&
!wordsInMessage.contains(lemma.toLowerCase()),
)
.take(4)
.toList();
final choicesList = choices.map((c) => c.lemma).toList();
choicesList.shuffle();
final allChoices = [...choicesList, ...answers];
allChoices.shuffle();
return MessageActivityResponse(
activity: VocabAudioPracticeActivityModel(
tokens: req.target.tokens,
langCode: req.userL2,
multipleChoiceContent: MultipleChoiceActivity(
choices: choicesList.toSet(),
answers: {token.lemma.text},
choices: allChoices.toSet(),
answers: answers,
),
roomId: audioExample?.roomId,
eventId: audioExample?.eventId,
exampleMessage:
audioExample?.exampleMessage ??
const ExampleMessageInfo(exampleMessage: []),
),
);
}

View file

@ -277,6 +277,8 @@ class PangeaChatInputRow extends StatelessWidget {
onChanged: controller.onInputBarChanged,
choreographer: controller.choreographer,
showNextMatch: controller.showNextMatch,
onFeedbackSubmitted: (feedback) => controller
.onRequestWritingAssistance(feedback: feedback),
suggestionEmojis:
getDefaultEmojiLocale(
AppSettings

View file

@ -117,15 +117,16 @@ class ChatAccessTitle extends StatelessWidget {
final theme = Theme.of(context);
final isColumnMode = FluffyThemes.isColumnMode(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: isColumnMode ? 32.0 : 24.0),
SizedBox(width: isColumnMode ? 32.0 : 16.0),
Text(
title,
style: isColumnMode
? theme.textTheme.titleLarge
: theme.textTheme.titleMedium,
Flexible(
child: Text(
title,
style: isColumnMode
? theme.textTheme.titleLarge
: theme.textTheme.titleMedium,
),
),
],
);

View file

@ -281,11 +281,22 @@ class Choreographer extends ChangeNotifier {
}
_stopLoading();
if (!igcController.openMatches.any(
(match) => match.updatedMatch.isITStart,
)) {
igcController.fetchAllSpanDetails().catchError((e) => clearMatches(e));
}
/// Re-runs IGC with user feedback and updates the UI.
Future<bool> rerunWithFeedback(String feedbackText) async {
MatrixState.pAnyState.closeAllOverlays();
igcController.clearMatches();
igcController.clearCurrentText();
_startLoading();
final success = await igcController.rerunWithFeedback(feedbackText);
if (success && igcController.openAutomaticMatches.isNotEmpty) {
await igcController.acceptNormalizationMatches();
}
_stopLoading();
return success;
}
Future<PangeaMessageContentModel> getMessageContent(String message) async {

View file

@ -0,0 +1,97 @@
# Edit Type Auto-Apply Planning
## Current Behavior
The client currently auto-applies edits (without user interaction) based on a single condition:
- **Normalization errors**: Edits where the correction is the same as the original when normalized (punctuation, spacing, accents removed)
This is implemented in:
- [span_data_model.dart](igc/span_data_model.dart#L147) - `isNormalizationError()` method
- [igc_controller.dart](igc/igc_controller.dart#L43) - `openAutomaticMatches` getter
- [igc_controller.dart](igc/igc_controller.dart#L227) - `acceptNormalizationMatches()` method
Current `isNormalizationError()` logic:
```dart
bool isNormalizationError() {
final correctChoice = choices?.firstWhereOrNull((c) => c.isBestCorrection)?.value;
final l2Code = MatrixState.pangeaController.userController.userL2?.langCodeShort;
return correctChoice != null &&
l2Code != null &&
normalizeString(correctChoice, l2Code) == normalizeString(errorSpan, l2Code);
}
```
The `normalizeString` function (in [text_normalization_util.dart](igc/text_normalization_util.dart)):
- Converts to lowercase
- Removes diacritics (language-specific)
- Replaces hyphens with spaces
- Removes punctuation
- Normalizes whitespace
## Proposed Change
Split auto-apply behavior based on **edit type** instead of just normalization matching.
### Questions to Answer
1. **What edit types should we distinguish?**
- Punctuation-only edits
- Accent/diacritic-only edits
- Capitalization-only edits
- Spelling errors
- Grammar errors (conjugation, agreement, etc.)
- Word choice / vocabulary suggestions
- Code-switching corrections (L1 word replaced with L2)
2. **Which edit types should auto-apply?**
- Current: All "normalization" edits (punctuation + accent + case)
- Proposed: Make this configurable by type?
3. **Where does the edit type come from?**
- Currently from `SpanData.rule` (has `Rule.id`, `Rule.category`, etc.)
- Or from `SpanDataTypeEnum` (grammar, correction, etc.)
- May need choreo/backend to provide explicit type classification
4. **What user interaction modes exist?**
- Auto-apply (no interaction, edit applied silently)
- Notification (edit applied but user is informed)
- Selection (user must choose from options)
- Full interaction (span card with explanation)
## Files to Modify
### Client-side (this repo)
- `igc/span_data_model.dart` - Add edit type classification methods
- `igc/igc_controller.dart` - Update auto-apply logic based on type
- `choreographer.dart` - Handle different interaction modes
- Potentially new enum for edit categories
### Backend (choreo)
- May need to return explicit edit type/category in response
- See [2-step-choreographer next_steps.md](../../../../../2-step-choreographer/app/handlers/wa/next_steps.md)
## Current SpanData Structure
```dart
class SpanData {
final String? message;
final String? shortMessage;
final List<SpanChoice>? choices;
final int offset;
final int length;
final String fullText;
final SpanDataTypeEnum type; // grammar, correction, etc.
final Rule? rule; // has id, category, description
}
```
## Tasks
- [ ] Define edit type categories/enum
- [ ] Determine classification logic (client-side vs server-side)
- [ ] Design interaction mode mapping (type → mode)
- [ ] Implement type classification in SpanData
- [ ] Update IgcController to use type-based auto-apply
- [ ] Add user preference support (optional)
- [ ] Coordinate with choreo backend if needed

View file

@ -0,0 +1,269 @@
# Span Card UI Redesign - Finalized Plan
## Overview
Redesign the `SpanCard` widget to improve UX and add user feedback capabilities. This document consolidates all decisions from the design Q&A.
---
## New Layout
### Visual Structure
```
┌─────────────────────────────────────────┐
│ [X] 🤖 [🚩] │ <- Header: Close, BotFace, Flag
├─────────────────────────────────────────┤
│ Span Type Label │ <- Error category (e.g., "Grammar")
├─────────────────────────────────────────┤
│ [ Choice 1 ] [ Choice 2 ] [ ... ] │ <- ChoicesArray
├─────────────────────────────────────────┤
│ "Best choice feedback text..." │ <- Feedback shown on open
├─────────────────────────────────────────┤
│ [ Ignore ] [ Replace ] │ <- Action buttons
└─────────────────────────────────────────┘
```
### Header Row Details
Follow `WordZoomWidget` pattern exactly:
```dart
SizedBox(
height: 40.0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
color: Theme.of(context).iconTheme.color,
icon: const Icon(Icons.close),
onPressed: widget.showNextMatch,
),
Flexible(
child: Container(
constraints: const BoxConstraints(minHeight: 40.0),
alignment: Alignment.center,
child: BotFace(width: 40, expression: BotExpression.idle),
),
),
IconButton(
color: Theme.of(context).iconTheme.color,
icon: const Icon(Icons.flag_outlined),
onPressed: _onFlagPressed,
),
],
),
),
```
---
## Color Scheme
### Underline Colors by Type
Use `AppConfig.primaryColor` (#8560E0) for IT/auto-apply types.
| Category | Types | Color |
| ------------- | ------------------------------------------------ | ----------------------------------------- |
| IT/Auto-apply | `itStart`, `punct`, `diacritics`, `spell`, `cap` | `AppConfig.primaryColor.withOpacity(0.7)` |
| Grammar | All `grammarTypes` list | `AppConfig.warning.withOpacity(0.7)` |
| Word Choice | All `wordChoiceTypes` list | `Colors.blue.withOpacity(0.7)` |
| Style/Fluency | `style`, `fluency` | `Colors.teal.withOpacity(0.7)` |
| Other/Unknown | Everything else | `colorScheme.error.withOpacity(0.7)` |
### Implementation
Add to `replacement_type_enum.dart`:
```dart
extension ReplacementTypeEnumColors on ReplacementTypeEnum {
Color underlineColor(BuildContext context) {
if (this == ReplacementTypeEnum.itStart || isAutoApply) {
return AppConfig.primaryColor.withOpacity(0.7);
}
if (isGrammarType) {
return AppConfig.warning.withOpacity(0.7);
}
if (isWordChoiceType) {
return Colors.blue.withOpacity(0.7);
}
switch (this) {
case ReplacementTypeEnum.style:
case ReplacementTypeEnum.fluency:
return Colors.teal.withOpacity(0.7);
default:
return Theme.of(context).colorScheme.error.withOpacity(0.7);
}
}
}
```
---
## Feedback Flow
### Flag Button (Explicit Feedback)
1. User taps 🚩 flag icon
2. Show `FeedbackDialog` for user to enter feedback text
3. Close span card, show spinning IGC indicator
4. Call grammar_v2 endpoint with feedback attached via new `IgcController.rerunWithFeedback()` method
5. Clear existing matches, replace with new response
6. Display new span card if matches exist
### Ignore Button (Auto-Feedback)
1. User taps "Ignore"
2. Fire-and-forget call to grammar_v2 with auto-generated feedback:
```
"user ignored the correction ({old} -> {new}) without feedback. not sure why"
```
3. Don't wait for response, proceed to next match
4. Silent fail on errors (logged server-side for finetuning)
### Feedback Schema
Use existing `LLMFeedbackSchema` structure:
```dart
{
"feedback": "user's feedback text",
"content": { /* original IGCResponseModel as JSON */ }
}
```
### Implementation Details
1. **Store last response**: Add `IGCResponseModel? _lastResponse` field to `IgcController`
2. **New method**: Add `rerunWithFeedback(String feedbackText)` to `IgcController`
3. **Feedback always included**: No loading state needed - feedback text comes with initial response
4. **No snackbar initially**: Just use spinning IGC indicator (may add UX feedback later if too abrupt)
---
## Span Type Display
### Display Names
Add `displayName(context)` method to `ReplacementTypeEnum`:
| Type Category | Display String |
| ------------- | ---------------- |
| Grammar types | "Grammar" |
| Word choice | "Word Choice" |
| `spell` | "Spelling" |
| `punct` | "Punctuation" |
| `style` | "Style" |
| `fluency` | "Fluency" |
| `diacritics` | "Accents" |
| `cap` | "Capitalization" |
| Other | "Correction" |
---
## Choices Behavior
### Alts vs Distractors
- **Alts**: Equally valid alternatives (e.g., "él" vs "ella" when gender ambiguous)
- **Distractors**: Intentionally wrong options to test learner
These arrive as `SpanChoice` objects in `SpanData.choices`.
### Selection Behavior
| Choice Type | On Select |
| ----------- | ----------------------------------- |
| Best | Enable "Replace" button |
| Alt | Treat as accepted, apply that value |
| Distractor | Show "try again" feedback |
### Single Choice
If only one choice exists, show it without choice UI chrome.
---
## Button Behaviors
| Button | Action |
| ------- | -------------------------------------------------- |
| X | Close card, show next match (same as Ignore) |
| Replace | Apply selected choice, close card, show next match |
| Ignore | Auto-feedback (fire-and-forget), show next match |
| Flag | Open feedback dialog, re-run WA with user feedback |
---
## Files to Modify
| File | Changes |
| ----------------------------- | -------------------------------------------------------- |
| `replacement_type_enum.dart` | Add `underlineColor(context)` and `displayName(context)` |
| `pangea_text_controller.dart` | Update `_underlineColor` to use type-based colors |
| `span_card.dart` | Restructure layout per new design |
| `intl_en.arb` | Add new l10n strings |
---
## Localization Strings
Add to `intl_en.arb`:
```json
{
"spanFeedbackTitle": "Report correction issue",
"spanTypeGrammar": "Grammar",
"spanTypeWordChoice": "Word Choice",
"spanTypeSpelling": "Spelling",
"spanTypePunctuation": "Punctuation",
"spanTypeStyle": "Style",
"spanTypeFluency": "Fluency",
"spanTypeAccents": "Accents",
"spanTypeCapitalization": "Capitalization",
"spanTypeCorrection": "Correction"
}
```
**TODO**: Run translation script after adding strings.
---
## Implementation Order
1. [ ] Update `replacement_type_enum.dart`
- [ ] Add `underlineColor(context)` method
- [ ] Add `displayName(context)` method
2. [ ] Update `pangea_text_controller.dart`
- [ ] Change `_underlineColor` to use type-based colors
3. [ ] Add l10n strings to `intl_en.arb`
4. [ ] Update `igc_controller.dart`
- [ ] Add `IGCResponseModel? _lastResponse` field
- [ ] Store response in `getIGCTextData()` after fetch
- [ ] Add `rerunWithFeedback(String feedbackText)` method
- [ ] Add `sendAutoFeedback(PangeaMatch match)` method (fire-and-forget)
5. [ ] Restructure `span_card.dart`
- [ ] Add header row (X, BotFace, Flag)
- [ ] Add span type display row
- [ ] Show feedback on card open (no loading state needed)
- [ ] Wire up flag button to feedback flow
- [ ] Wire up ignore button to auto-feedback
6. [ ] Test full flow
---
## Testing Considerations
- Verify underline colors display correctly for each error type
- Test feedback dialog flow end-to-end
- Test auto-feedback on ignore (verify silent fail)
- Ensure span card closes properly after all actions
- Test with various span types to verify type labels
- Test distractor selection shows "try again"
- Test alt selection applies the alt value
## NEXT STEPS
- Figure out why feedback isn't displaying
- Considering migrating to using match message field instead of choice feedback

View file

@ -0,0 +1,574 @@
# Span Card UI Redesign Plan
## Overview
Redesign the `SpanCard` widget to improve UX and add user feedback capabilities. This document outlines the changes needed for the new layout and feedback flow.
## Decisions Made ✅
### 1. Feedback Endpoint Behavior ✅
**Decision**: Re-run WA analysis with feedback to get different/better correction
**Implementation notes**:
- Use `gpt_5_2` model on regeneration
- Use `prompt_version="verbose"`
- Pull at least 3 varied examples in
- Need choreo-side testing to verify feedback is included in re-run
### 2. Close Button (X) Behavior ✅
**Decision**: Close span card and show next match (same as current "Ignore")
### 3. Best Choice Feedback Display ✅
**Decision**: Show immediately on card open
> _Pedagogical note: Since the user has already seen their error highlighted and the choices displayed, the feedback explains "why" after they've had a chance to think about it. Hiding it behind a button adds friction without clear benefit._
### 4. Span Type Copy Format ✅
**Decision**: Use short labels defined in `replacement_type_enum.dart`
Will add `displayName(context)` method returning l10n strings like:
- "Grammar" / "Word Choice" / "Spelling" / "Punctuation" / "Style"
### 5. Color Scheme ✅
**Decision**: Use brand colors from `app_config.dart`:
- **Primary/Purple**: `AppConfig.primaryColor` (#8560E0) - for IT/auto-apply
- **Warning/Orange**: `AppConfig.warning` (rgba 210,124,12) - for grammar errors
- **Error/Red**: Use `colorScheme.error` - for unknown/other
- **Style/Teal**: Keep proposed teal for style/fluency
- **Word Choice**: Keep proposed blue
### 6. Auto-Feedback on Ignore ✅
**Decision**: Auto-submit feedback on ignore (fire-and-forget)
**Implementation**:
- Submit async, don't block UI
- Catch errors internally (silent fail)
- Default message: `"user ignored the correction ({old} -> {new}) without feedback. not sure why"`
### 7. Re-run Feedback Flow UX ✅
**Decision**: Close card → show spinning IGC indicator → snackbar with bot face "Got it! Recalculating..."
- Shows card again when complete if there are more matches
- Uses existing spinning indicator pattern in input bar
### 8. What if re-run returns no correction? ✅
**Decision**: Spinner turns green and it's done (no special message)
### 9. Choices Layout ✅
**Decision**: Include alts/distractors in initial WA call with expanded TSV format
---
## Discussion: Expanded TSV Format for Alts/Distractors
### Proposed Format
```
old new type reason alt_1 alt_2 distractor_1 distractor_2
```
### Your Pros
1. Include alts when subjective (gender ambiguity, etc.) - user can pick
2. Include distractors when it makes a good learning exercise
3. Single handler = less to finetune, less feedback loops to manage
### My Analysis on Complexity vs Quality
**Short answer**: I think it's fine, with some guardrails.
**Reasoning**:
1. **Column count is manageable** - 8 columns is still very parseable for the model. The current 4-column format works well; doubling it shouldn't degrade accuracy much.
2. **Optional columns help** - Making alt/distractor columns optional (empty when not applicable) keeps simple cases simple. The model only populates them when genuinely useful.
3. **Clear semantics matter most** - As long as the prompt clearly defines:
- `alt_1/alt_2`: Equally valid alternatives (subjective/ambiguous cases)
- `distractor_1/distractor_2`: Plausible-but-wrong options (learning exercise)
...the model should handle it fine.
4. **Finetuning examples will solidify it** - If your training examples show when to use alts vs not, the model will learn the pattern.
### Potential Risks
1. **Over-generation of alts** - Model might add alts when not needed. Mitigation: Prompt to say "only include alts if genuinely ambiguous"
2. **Distractor quality** - Bad distractors could confuse learners. Mitigation: Could validate distractors are grammatically plausible but semantically wrong
3. **Parsing edge cases** - Tab characters in reasons could break TSV. Already handled?
### My Recommendation
Go for it. The benefits (unified handler, better UX for ambiguous cases) outweigh the complexity cost. Just make sure:
- Clear prompt instructions on when to use each column
- Good training examples showing both populated and empty cases
- Keep distractor generation optional/conservative initially
---
## Follow-up Questions
### 10. Alts vs Distractors Distinction ✅
**Clarified**:
- **Alts**: All correct options given the context (e.g., "él" vs "ella" when gender unclear)
- **Distractors**: Intentionally wrong options to test the learner
### 11. What if user picks an alt? ✅
**Decision**: Treat as "accepted" - apply that alt as the correction
### 12. Distractor selection behavior ✅
**Decision**: Show "try again" feedback, don't apply the distractor
### 13. Empty alts/distractors ✅
**Decision**: Just show the single `new` choice (no choice UI for single option)
---
## All Questions Resolved ✅
No more open questions. Ready for implementation.
---
## Current Implementation
**File**: [span_card.dart](span_card.dart)
Current layout (top to bottom):
1. `ChoicesArray` - answer options
2. `_SpanCardFeedback` - feedback text with lightbulb button
3. `_SpanCardButtons` - Ignore / Replace buttons
## New Layout
### Visual Structure
```
┌─────────────────────────────────────────┐
│ [X] 🤖 [🚩] │ <- Row 1: Close, BotFace, Flag
├─────────────────────────────────────────┤
│ Span Type Copy │ <- Row 2: Error category label
├─────────────────────────────────────────┤
│ [ Choice 1 ] [ Choice 2 ] [ ... ] │ <- Row 3: ChoicesArray
├─────────────────────────────────────────┤
│ "Best choice feedback text..." │ <- Row 4: Best choice feedback
├─────────────────────────────────────────┤
│ [ Ignore ] [ Replace ] │ <- Row 5: Action buttons
└─────────────────────────────────────────┘
```
### Detailed Rows
1. **Top Row (Header)**
- Left: X button (close overlay) - `IconButton(Icons.close)`
- Center: Bot face - `BotFace(width: 40, expression: BotExpression.idle)`
- Right: Flag button (feedback) - `IconButton(Icons.flag_outlined)`
2. **Span Type Row**
- Display the error category from `match.updatedMatch.match.type`
- Use `ReplacementTypeEnum.defaultPrompt(context)` for human-readable text
- Consider adding l10n strings for each type's display name
3. **Choices Row**
- Keep existing `ChoicesArray` widget
- No changes needed here
4. **Best Choice Feedback Row**
- Display `bestChoice.feedback` text when available
- Show on card open (no button needed) since feedback is now always included
- Fall back to loading state if feedback needs fetching
5. **Action Buttons Row**
- Keep existing `_SpanCardButtons` widget
- No changes needed here
## Underline Color by Type
### Files to Modify
**File**: [pangea_text_controller.dart](../text_editing/pangea_text_controller.dart)
Current `_underlineColor` method uses `match.match.rule?.id` to determine color. Change to use `match.match.type` (ReplacementTypeEnum).
### Proposed Color Mapping
Add extension method to `ReplacementTypeEnum`:
```dart
// In replacement_type_enum.dart
extension ReplacementTypeEnumColors on ReplacementTypeEnum {
Color get underlineColor {
if (isAutoApply) {
return Colors.purple.withOpacity(0.7); // punct, diacritics, spell, cap
}
if (isGrammarType) {
return Colors.orange.withOpacity(0.7); // grammar errors
}
if (isWordChoiceType) {
return Colors.blue.withOpacity(0.7); // word choice issues
}
// Higher-level suggestions
switch (this) {
case ReplacementTypeEnum.style:
case ReplacementTypeEnum.fluency:
return Colors.teal.withOpacity(0.7);
case ReplacementTypeEnum.itStart:
return Colors.purple.withOpacity(0.7);
default:
return Colors.red.withOpacity(0.7); // other/unknown
}
}
}
```
Update `pangea_text_controller.dart`:
```dart
Color _underlineColor(PangeaMatch match) {
if (match.status == PangeaMatchStatusEnum.automatic) {
return const Color.fromARGB(187, 132, 96, 224);
}
// Use type-based coloring instead of rule ID
return match.match.type.underlineColor;
}
```
## Feedback Flag Flow
### Reference Implementation
See activity feedback flow in:
- [activity_sessions_start_view.dart](../../activity_sessions/activity_session_start/activity_sessions_start_view.dart#L83-L139)
- [feedback_dialog.dart](../../common/widgets/feedback_dialog.dart)
### Flow Steps
1. User taps flag icon in SpanCard header
2. Show `FeedbackDialog` with optional text input
3. On submit, call WA endpoint with feedback
4. Show `FeedbackResponseDialog` with response
5. Close span card
### New Files to Create
1. **`span_feedback_request.dart`** - Request model for WA feedback
2. **`span_feedback_repo.dart`** - Repository to call WA endpoint with feedback
### Endpoint Integration
The WA endpoint already supports feedback via `GrammarRequestV2.feedback` field.
**Choreo endpoint**: `POST /choreo/grammar_v2`
**Request with feedback**:
```json
{
"full_text": "original user text",
"user_l1": "en",
"user_l2": "es",
"feedback": [
{
"user_feedback": "This correction doesn't make sense",
"input": { ... original request ... },
"output": { ... original response ... }
}
]
}
```
### Implementation in SpanCard
```dart
// In SpanCardState
Future<void> _onFlagPressed() async {
final feedback = await showDialog<String?>(
context: context,
builder: (context) => FeedbackDialog(
title: L10n.of(context).spanFeedbackTitle,
onSubmit: (feedback) => Navigator.of(context).pop(feedback),
scrollable: false,
),
);
if (feedback == null || feedback.isEmpty) return;
final resp = await showFutureLoadingDialog(
context: context,
future: () => SpanFeedbackRepo.submitFeedback(
SpanFeedbackRequest(
span: widget.match.updatedMatch.match,
feedbackText: feedback,
userId: Matrix.of(context).client.userID!,
userL1: MatrixState.pangeaController.userController.userL1Code!,
userL2: MatrixState.pangeaController.userController.userL2Code!,
),
),
);
if (resp.isError) return;
await showDialog(
context: context,
builder: (context) => FeedbackResponseDialog(
title: L10n.of(context).feedbackTitle,
feedback: resp.result!.userFriendlyResponse,
description: L10n.of(context).feedbackRespDesc,
),
);
// Close the span card
widget.showNextMatch();
}
```
## Localization Strings Needed
Add to `intl_en.arb`:
```json
{
"spanFeedbackTitle": "Report correction issue",
"spanTypeGrammar": "Grammar",
"spanTypeWordChoice": "Word Choice",
"spanTypeSpelling": "Spelling",
"spanTypePunctuation": "Punctuation",
"spanTypeStyle": "Style",
"spanTypeFluency": "Fluency"
}
```
## Files to Modify
| File | Changes |
| ----------------------------- | ----------------------------------------------------------------- |
| `span_card.dart` | Restructure layout, add header row with X/BotFace/Flag |
| `replacement_type_enum.dart` | Add `underlineColor` extension, add `displayName(context)` method |
| `pangea_text_controller.dart` | Update `_underlineColor` to use type-based colors |
## New Files to Create
| File | Purpose |
| ---------------------------- | -------------------------------------- |
| `span_feedback_request.dart` | Request model for span feedback |
| `span_feedback_repo.dart` | API calls for submitting span feedback |
## Implementation Order
1. [ ] Update `replacement_type_enum.dart` with `underlineColor` and `displayName`
2. [ ] Update `pangea_text_controller.dart` to use type-based underline colors
3. [ ] Create `span_feedback_request.dart` and `span_feedback_repo.dart`
4. [ ] Restructure `span_card.dart` layout:
- [ ] Add header row (X, BotFace, Flag)
- [ ] Add span type display row
- [ ] Move ChoicesArray
- [ ] Show best choice feedback on open
- [ ] Implement flag button handler
5. [ ] Add localization strings
6. [ ] Test full flow
## Testing Considerations
- Verify underline colors display correctly for each error type
- Test feedback dialog flow end-to-end
- Ensure span card closes properly after feedback submission
- Test with various span types to verify type label displays correctly
---
## Pre-Implementation Questions
### 1. Re-run Feedback Flow Architecture
The plan says feedback triggers a re-run of WA analysis with `gpt_5_2` model and `prompt_version="verbose"`.
- **Q1a**: Does the choreo endpoint (`POST /choreo/grammar_v2`) already support the `prompt_version` parameter, or does this need to be added?
- **Q1b**: Where should the model override (`gpt_5_2`) be configured? On the request from the client, or hardcoded in the handler when feedback is present?
this will be handled server-side. the client just needs to send the feedback in the request object. you can remove the notes about the serverside implemention, those have been added to server doc wa/next_steps.md
### 2. Alts/Distractors in TSV Format
The plan mentions expanding TSV format to include `alt_1`, `alt_2`, `distractor_1`, `distractor_2` columns.
- **Q2a**: Is this TSV format expansion already implemented in choreo, or is this a future change we're planning for?
- **Q2b**: If not implemented yet, should the SpanCard redesign proceed without the alts/distractors UI, or should we stub it out?
don't worry about this. they'll be SpanChoices in SpanData
### 3. SpanFeedbackRepo Return Type
The plan shows `resp.result!.userFriendlyResponse` after submitting feedback (similar to activity feedback).
- **Q3**: For span feedback, what should the response contain? A new `GrammarResponseV2` with re-analyzed corrections? Or a simple acknowledgment with `userFriendlyResponse`?
the client will call grammar_v2 endpoint again with feedback. it'll return a new grammar response object.
### 4. Auto-Feedback on Ignore
The plan states auto-submit feedback on ignore with message: `"user ignored the correction ({old} -> {new}) without feedback"`.
- **Q4**: Should this auto-feedback actually trigger a re-run of WA analysis (like explicit feedback does), or should it just be logged/stored for finetuning purposes without re-analysis?
it should send it via the grammar_v2 endpoint again but not worry about the result. the choreo will audit and store.
### 5. Localization
The plan lists new l10n strings to add.
- **Q5**: Should I add these to `intl_en.arb` only, or are there other language files that need the new keys as well?
some may be existing, others will be new. just add to intl_en.arb. make a TODO about them to run the translation script.
### 6. Color Implementation
The plan uses `Colors.purple.withOpacity(0.7)` for auto-apply types, but references `AppConfig.primaryColor` (#8560E0) in the decisions section.
- **Q6**: Should I use the literal `Colors.purple.withOpacity(0.7)` or the app's `AppConfig.primaryColor` with opacity? (They're slightly different shades)
use the primary with opacity. it's what itStart uses.
---
## Follow-up Questions (Round 2)
### 7. Feedback Request Structure
The existing `GrammarRequestV2` has a `feedback` field of type `List<LLMFeedbackSchema>`. Looking at the activity feedback flow, it uses a separate `ActivityFeedbackRequest` model.
- **Q7a**: Should the span feedback flow modify the existing `IGCController` to re-call the grammar endpoint with feedback attached to the request, or create a separate `SpanFeedbackRepo` that wraps the grammar call?
reuse the existing flow unless you spot complications with that.
- **Q7b**: What fields should be included in the feedback object? Just `user_feedback` text, or also the original `input`/`output` as shown in the plan's JSON example?
LLMFeedbackSchema calls for the response object plus the user_feedback text
### 8. UI Flow on Re-run
The plan says: "Close card → show spinning IGC indicator → snackbar with bot face 'Got it! Recalculating...'"
- **Q8a**: After the re-run completes, if the same span now has a different correction (or no correction), how should we update the existing match state? Replace in-place, or clear all matches and re-process?
it will return all new matches. clear the IGC match data and replace it with new
- **Q8b**: Should the snackbar be shown, or is the spinning IGC indicator sufficient feedback?
sure, let's skip the snackbar for now and see.
### 9. SpanCard Header Layout
The plan shows `[X] 🤖 [🚩]` in the header.
- **Q9**: Should the X button and Flag button be the same size for visual symmetry, or should flag be smaller/less prominent?
same size and color for visual symmetry. see the word card for example and follow that exactly
---
## All Questions Resolved ✅
Ready to implement. Summary of key decisions from Q&A:
1. **Feedback flow**: Reuse existing `IGCController` to call grammar endpoint with feedback attached
2. **Feedback schema**: `LLMFeedbackSchema` with `feedback` (user text) + `content` (original response object)
3. **Re-run result**: Clear existing IGC matches and replace with new response
4. **No snackbar**: Just use spinning IGC indicator
5. **Header layout**: Follow `WordZoomWidget` exactly - both buttons same size/color using `IconButton` with `Theme.of(context).iconTheme.color`
6. **Colors**: Use `AppConfig.primaryColor` with opacity for IT/auto-apply
7. **Localization**: Add to `intl_en.arb` only, TODO for translation script
---
## Follow-up Questions (Round 3)
### 10. Best Choice Feedback Loading State
The original plan mentioned "Fall back to loading state if feedback needs fetching."
**Q10**: Is feedback always included in the initial WA response now (so no fetching needed), or should we still handle a loading state for feedback?
yes, both feedback and choices will always be included
### 11. FeedbackResponseDialog After Flag
The original plan showed a `FeedbackResponseDialog` after submitting feedback, but Q8b decided "skip the snackbar" and Q3 clarified the response is a new `GrammarResponseV2` (not a `userFriendlyResponse` string).
**Q11**: After flag feedback re-run, should we:
- (A) Just close card → spinner → show new matches (no dialog), or
- (B) Show a dialog acknowledging the feedback before showing new results?
i think we probably need something. just closing the card would be abrupt. that's why i was thinking the snackbar. let's start without it and see though.
### 12. Re-run Trigger Method
The feedback calls grammar_v2 which returns a new response.
**Q12**: How does the SpanCard trigger the re-run through IGCController? Should it:
- (A) Call a new method like `igcController.rerunWithFeedback(feedback, originalResponse)`, or
- (B) Call the existing flow but with feedback attached to the request somehow?
probably a new method is a good idea so we can add in any logic needed for this
### 13. Original Response Reference
`LLMFeedbackSchema` needs the original response (`content` field).
yes. send the contested grammar response
**Q13**: Where is the original `GrammarResponseV2` stored that we can reference when building the feedback object? Is it on `IGCController` or `Choreographer`?
i'm not sure exactly. actually, i think it should be accessible via igc_repo.dart which will have cached it
---
## All Round 3 Questions Answered ✅
Summary of Round 3 decisions:
10. **Feedback always included**: No loading state needed for feedback text
11. **Post-feedback UX**: Start without snackbar/dialog, may add later if too abrupt
12. **New method needed**: Create `igcController.rerunWithFeedback(feedback, originalResponse)`
13. **Original response location**: Access via `IgcRepo` cache (keyed by request hashcode)
---
## Follow-up Questions (Round 4)
### 14. IgcRepo Cache Access
Looking at `igc_repo.dart`, the cache is keyed by `IGCRequestModel.hashCode.toString()` and stores `Future<IGCResponseModel>`. The cache is private (`_igcCache`).
**Q14**: To access the cached response for feedback, should I:
- (A) Add a public method to `IgcRepo` like `getLastResponse()` or `getCachedResponse(request)`, or
- (B) Store the response on `IgcController` after fetch completes (simpler)?
not sure. use your judgment.

View file

@ -7,11 +7,11 @@ import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/choreographer/igc/igc_repo.dart';
import 'package:fluffychat/pangea/choreographer/igc/igc_request_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/igc_response_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart';
import 'package:fluffychat/pangea/choreographer/igc/span_data_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/span_data_repo.dart';
import 'package:fluffychat/pangea/choreographer/igc/span_data_request.dart';
import 'package:fluffychat/pangea/common/models/llm_feedback_model.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -24,6 +24,12 @@ class IgcController {
bool _isFetching = false;
String? _currentText;
/// Last request made - stored for feedback rerun
IGCRequestModel? _lastRequest;
/// Last response received - stored for feedback rerun
IGCResponseModel? _lastResponse;
final List<PangeaMatchState> _openMatches = [];
final List<PangeaMatchState> _closedMatches = [];
@ -70,21 +76,11 @@ class IgcController {
) => IGCRequestModel(
fullText: text,
userId: MatrixState.pangeaController.userController.client.userID!,
userL1: MatrixState.pangeaController.userController.userL1Code!,
userL2: MatrixState.pangeaController.userController.userL2Code!,
enableIGC: true,
enableIT: true,
prevMessages: prevMessages,
);
SpanDetailsRequest _spanDetailsRequest(SpanData span) => SpanDetailsRequest(
userL1: MatrixState.pangeaController.userController.userL1Code!,
userL2: MatrixState.pangeaController.userController.userL2Code!,
enableIGC: true,
enableIT: true,
span: span,
);
void dispose() {
matchUpdateStream.close();
}
@ -92,6 +88,8 @@ class IgcController {
void clear() {
_isFetching = false;
_currentText = null;
_lastRequest = null;
_lastResponse = null;
_openMatches.clear();
_closedMatches.clear();
MatrixState.pAnyState.closeAllOverlays();
@ -102,6 +100,8 @@ class IgcController {
_closedMatches.clear();
}
void clearCurrentText() => _currentText = null;
void _filterPreviouslyIgnoredMatches() {
for (final match in _openMatches) {
if (IgcRepo.isIgnored(match.updatedMatch)) {
@ -287,28 +287,89 @@ class IgcController {
) async {
if (text.isEmpty) return clear();
if (_isFetching) return;
final request = _igcRequest(text, prevMessages);
await _fetchIGC(request);
}
/// Re-runs IGC with user feedback about the previous response.
/// Returns true if feedback was submitted, false if no previous data.
Future<bool> rerunWithFeedback(String feedbackText) async {
debugPrint('rerunWithFeedback called with: $feedbackText');
debugPrint('_lastRequest: $_lastRequest, _lastResponse: $_lastResponse');
if (_lastRequest == null || _lastResponse == null) {
ErrorHandler.logError(
e: StateError(
'rerunWithFeedback called without prior request/response',
),
data: {
'hasLastRequest': _lastRequest != null,
'hasLastResponse': _lastResponse != null,
'currentText': _currentText,
},
);
return false;
}
if (_isFetching) {
debugPrint('rerunWithFeedback: already fetching, returning false');
return false;
}
// Create feedback containing the original response
final feedback = LLMFeedbackModel<IGCResponseModel>(
feedback: feedbackText,
content: _lastResponse!,
contentToJson: (r) => r.toJson(),
);
// Clear existing matches and state
clearMatches();
// Create request with feedback attached
final requestWithFeedback = _lastRequest!.copyWithFeedback([feedback]);
debugPrint(
'requestWithFeedback.feedback.length: ${requestWithFeedback.feedback.length}',
);
debugPrint('requestWithFeedback.hashCode: ${requestWithFeedback.hashCode}');
debugPrint('_lastRequest.hashCode: ${_lastRequest!.hashCode}');
debugPrint('Calling IgcRepo.get...');
return _fetchIGC(requestWithFeedback);
}
Future<bool> _fetchIGC(IGCRequestModel request) async {
_isFetching = true;
_lastRequest = request;
final res =
await IgcRepo.get(
MatrixState.pangeaController.userController.accessToken,
_igcRequest(text, prevMessages),
request,
).timeout(
(const Duration(seconds: 10)),
const Duration(seconds: 10),
onTimeout: () {
return Result.error(TimeoutException('IGC request timed out'));
return Result.error(
TimeoutException(
request.feedback.isNotEmpty
? 'IGC feedback request timed out'
: 'IGC request timed out',
),
);
},
);
if (res.isError) {
debugPrint('IgcRepo.get error: ${res.asError}');
onError(res.asError!);
clear();
return;
} else {
onFetch();
return false;
}
if (!_isFetching) return;
debugPrint('IgcRepo.get success, calling onFetch');
onFetch();
if (!_isFetching) return false;
_lastResponse = res.result!;
_currentText = res.result!.originalInput;
for (final match in res.result!.matches) {
final matchState = PangeaMatchState(
@ -324,39 +385,6 @@ class IgcController {
}
_filterPreviouslyIgnoredMatches();
_isFetching = false;
}
Future<void> fetchSpanDetails({
required PangeaMatchState match,
bool force = false,
}) async {
final span = match.updatedMatch.match;
if (span.isNormalizationError() && !force) {
return;
}
final response =
await SpanDataRepo.get(
MatrixState.pangeaController.userController.accessToken,
request: _spanDetailsRequest(span),
).timeout(
(const Duration(seconds: 10)),
onTimeout: () {
return Result.error(
TimeoutException('Span details request timed out'),
);
},
);
if (response.isError) throw response.error!;
setSpanData(match, response.result!);
}
Future<void> fetchAllSpanDetails() async {
final fetches = <Future>[];
for (final match in _openMatches) {
fetches.add(fetchSpanDetails(match: match));
}
await Future.wait(fetches);
return true;
}
}

View file

@ -50,10 +50,15 @@ class IgcRepo {
String? accessToken,
IGCRequestModel igcRequest,
) {
debugPrint(
'[IgcRepo.get] called, request.hashCode: ${igcRequest.hashCode}',
);
final cached = _getCached(igcRequest);
if (cached != null) {
debugPrint('[IgcRepo.get] cache HIT');
return _getResult(igcRequest, cached);
}
debugPrint('[IgcRepo.get] cache MISS, fetching from server...');
final future = _fetch(accessToken, igcRequest: igcRequest);
_setCached(igcRequest, future);

View file

@ -1,37 +1,72 @@
import 'dart:convert';
import 'package:fluffychat/pangea/choreographer/igc/igc_response_model.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/common/models/base_request_model.dart';
import 'package:fluffychat/pangea/common/models/llm_feedback_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
class IGCRequestModel {
class IGCRequestModel with BaseRequestModel {
final String fullText;
final String userL1;
final String userL2;
final bool enableIT;
final bool enableIGC;
final String userId;
final List<PreviousMessage> prevMessages;
final List<LLMFeedbackModel<IGCResponseModel>> feedback;
@override
String get userCefr => MatrixState
.pangeaController
.userController
.profile
.userSettings
.cefrLevel
.string;
@override
String get userL1 => MatrixState.pangeaController.userController.userL1Code!;
@override
String get userL2 => MatrixState.pangeaController.userController.userL2Code!;
const IGCRequestModel({
required this.fullText,
required this.userL1,
required this.userL2,
required this.enableIGC,
required this.enableIT,
required this.userId,
required this.prevMessages,
this.feedback = const [],
});
Map<String, dynamic> toJson() => {
ModelKey.fullText: fullText,
ModelKey.userL1: userL1,
ModelKey.userL2: userL2,
ModelKey.enableIT: enableIT,
ModelKey.enableIGC: enableIGC,
ModelKey.userId: userId,
ModelKey.prevMessages: jsonEncode(
prevMessages.map((x) => x.toJson()).toList(),
),
};
/// Creates a copy of this request with optional feedback.
IGCRequestModel copyWithFeedback(
List<LLMFeedbackModel<IGCResponseModel>> newFeedback,
) => IGCRequestModel(
fullText: fullText,
enableIGC: enableIGC,
enableIT: enableIT,
userId: userId,
prevMessages: prevMessages,
feedback: newFeedback,
);
Map<String, dynamic> toJson() {
final json = {
ModelKey.fullText: fullText,
ModelKey.userL1: userL1,
ModelKey.userL2: userL2,
ModelKey.enableIT: enableIT,
ModelKey.enableIGC: enableIGC,
ModelKey.userId: userId,
ModelKey.prevMessages: jsonEncode(
prevMessages.map((x) => x.toJson()).toList(),
),
};
if (feedback.isNotEmpty) {
json[ModelKey.feedback] = feedback.map((f) => f.toJson()).toList();
}
return json;
}
@override
bool operator ==(Object other) {
@ -44,12 +79,24 @@ class IGCRequestModel {
userL1 == other.userL1 &&
userL2 == other.userL2 &&
enableIT == other.enableIT &&
userId == other.userId;
userId == other.userId &&
_feedbackHash == other._feedbackHash;
}
/// Hash of feedback content for cache differentiation
int get _feedbackHash =>
feedback.isEmpty ? 0 : Object.hashAll(feedback.map((f) => f.feedback));
@override
int get hashCode =>
Object.hash(fullText.trim(), userL1, userL2, enableIT, enableIGC, userId);
int get hashCode => Object.hash(
fullText.trim(),
userL1,
userL2,
enableIT,
enableIGC,
userId,
_feedbackHash,
);
}
/// Previous text/audio message sent in chat

View file

@ -7,7 +7,13 @@ class IGCResponseModel {
final List<PangeaMatch> matches;
final String userL1;
final String userL2;
/// Whether interactive translation is enabled.
/// Defaults to true for V2 responses which don't include this field.
final bool enableIT;
/// Whether in-context grammar is enabled.
/// Defaults to true for V2 responses which don't include this field.
final bool enableIGC;
IGCResponseModel({
@ -16,33 +22,39 @@ class IGCResponseModel {
required this.matches,
required this.userL1,
required this.userL2,
required this.enableIT,
required this.enableIGC,
this.enableIT = true,
this.enableIGC = true,
});
factory IGCResponseModel.fromJson(Map<String, dynamic> json) {
final String originalInput = json["original_input"];
return IGCResponseModel(
matches: json["matches"] != null
? (json["matches"] as Iterable)
.map<PangeaMatch>((e) {
return PangeaMatch.fromJson(e as Map<String, dynamic>);
})
.map<PangeaMatch>(
(e) => PangeaMatch.fromJson(
e as Map<String, dynamic>,
fullText: originalInput,
),
)
.toList()
.cast<PangeaMatch>()
: [],
originalInput: json["original_input"],
originalInput: originalInput,
fullTextCorrection: json["full_text_correction"],
userL1: json[ModelKey.userL1],
userL2: json[ModelKey.userL2],
enableIT: json[ModelKey.enableIT],
enableIGC: json[ModelKey.enableIGC],
// V2 responses don't include these fields; default to true
enableIT: json[ModelKey.enableIT] ?? true,
enableIGC: json[ModelKey.enableIGC] ?? true,
);
}
Map<String, dynamic> toJson() => {
"original_input": originalInput,
"full_text_correction": fullTextCorrection,
"matches": matches.map((e) => e.toJson()).toList(),
// Serialize as flat SpanData objects matching server's SpanDataV2 schema
"matches": matches.map((e) => e.match.toJson()).toList(),
ModelKey.userL1: userL1,
ModelKey.userL2: userL2,
ModelKey.enableIT: enableIT,

View file

@ -1,5 +1,5 @@
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart';
import 'package:fluffychat/pangea/choreographer/igc/span_data_type_enum.dart';
import 'package:fluffychat/pangea/choreographer/igc/replacement_type_enum.dart';
import 'match_rule_id_model.dart';
import 'span_data_model.dart';
@ -9,10 +9,26 @@ class PangeaMatch {
const PangeaMatch({required this.match, required this.status});
factory PangeaMatch.fromJson(Map<String, dynamic> json) {
/// Parse PangeaMatch from JSON.
///
/// Supports two formats:
/// - V1/Legacy: {"match": {...span_data...}, "status": "open"}
/// - V2: {...span_data...} (SpanData directly, status defaults to open)
///
/// [fullText] is passed to SpanData as fallback when the span JSON doesn't
/// contain full_text (e.g., when using original_input from parent response).
factory PangeaMatch.fromJson(Map<String, dynamic> json, {String? fullText}) {
// Check if this is V1 format (has "match" wrapper) or V2 format (flat SpanData)
final bool isV1Format = json[_matchKey] is Map<String, dynamic>;
final Map<String, dynamic> spanJson = isV1Format
? json[_matchKey] as Map<String, dynamic>
: json;
return PangeaMatch(
match: SpanData.fromJson(json[_matchKey] as Map<String, dynamic>),
status: json[_statusKey] != null
match: SpanData.fromJson(spanJson, parentFullText: fullText),
// V1 format may have status; V2 format always defaults to open
status: isV1Format && json[_statusKey] != null
? PangeaMatchStatusEnum.fromString(json[_statusKey] as String)
: PangeaMatchStatusEnum.open,
);
@ -28,10 +44,7 @@ class PangeaMatch {
bool get isITStart =>
match.rule?.id == MatchRuleIdModel.interactiveTranslation ||
[
SpanDataTypeEnum.itStart,
SpanDataTypeEnum.itStart.name,
].contains(match.type.typeName);
match.type == ReplacementTypeEnum.itStart;
bool get _needsTranslation => match.rule?.id != null
? [

View file

@ -0,0 +1,312 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/widgets/matrix.dart';
enum ReplacementTypeEnum {
// Client-specific types
definition,
practice,
itStart,
// === GRAMMAR CATEGORIES (granular for teacher analytics) ===
verbConjugation, // Wrong form for person/number/tense
verbTense, // Using wrong tense for context
verbMood, // Indicative vs subjunctive vs imperative
subjectVerbAgreement, // "he go" -> "he goes"
genderAgreement, // "la libro" -> "el libro"
numberAgreement, // Singular/plural mismatches
caseError, // For languages with grammatical cases
article, // Missing, wrong, or unnecessary articles
preposition, // Wrong preposition choice
pronoun, // Wrong form, reference, or missing
wordOrder, // Syntax/structure issues
negation, // Double negatives, wrong placement
questionFormation, // Incorrect question structure
relativeClause, // who/which/that errors
connector, // Conjunction usage (but/and/however)
possessive, // Apostrophe usage, possessive pronouns
comparative, // Comparative/superlative forms
passiveVoice, // Passive construction errors
conditional, // If clauses, would/could
infinitiveGerund, // Infinitive vs gerund usage
modal, // Modal verb usage (can/could/should/must)
// === SURFACE-LEVEL CORRECTIONS (auto-applied) ===
punct,
diacritics,
spell,
cap,
// === WORD CHOICE CATEGORIES (granular for teacher analytics) ===
falseCognate, // False friends (e.g., "embarazada" "embarrassed")
l1Interference, // L1 patterns bleeding through incorrectly
collocation, // Wrong word pairing (e.g., "do a mistake" "make a mistake")
semanticConfusion, // Similar meanings, wrong choice (e.g., "see/watch/look")
// === HIGHER-LEVEL SUGGESTIONS ===
transcription,
style,
fluency,
didYouMean,
translation,
other,
}
extension SpanDataTypeEnumExt on ReplacementTypeEnum {
/// Types that should be auto-applied without user interaction.
/// These are minor corrections like punctuation, spacing, accents, etc.
static const List<ReplacementTypeEnum> autoApplyTypes = [
ReplacementTypeEnum.punct,
ReplacementTypeEnum.diacritics,
ReplacementTypeEnum.spell,
ReplacementTypeEnum.cap,
];
/// Grammar types that require explanatory reasons for learning.
static const List<ReplacementTypeEnum> grammarTypes = [
ReplacementTypeEnum.verbConjugation,
ReplacementTypeEnum.verbTense,
ReplacementTypeEnum.verbMood,
ReplacementTypeEnum.subjectVerbAgreement,
ReplacementTypeEnum.genderAgreement,
ReplacementTypeEnum.numberAgreement,
ReplacementTypeEnum.caseError,
ReplacementTypeEnum.article,
ReplacementTypeEnum.preposition,
ReplacementTypeEnum.pronoun,
ReplacementTypeEnum.wordOrder,
ReplacementTypeEnum.negation,
ReplacementTypeEnum.questionFormation,
ReplacementTypeEnum.relativeClause,
ReplacementTypeEnum.connector,
ReplacementTypeEnum.possessive,
ReplacementTypeEnum.comparative,
ReplacementTypeEnum.passiveVoice,
ReplacementTypeEnum.conditional,
ReplacementTypeEnum.infinitiveGerund,
ReplacementTypeEnum.modal,
];
/// Word choice types that require explanatory reasons for learning.
static const List<ReplacementTypeEnum> wordChoiceTypes = [
ReplacementTypeEnum.falseCognate,
ReplacementTypeEnum.l1Interference,
ReplacementTypeEnum.collocation,
ReplacementTypeEnum.semanticConfusion,
];
/// Whether this type should be auto-applied without user interaction.
bool get isAutoApply => autoApplyTypes.contains(this);
/// Whether this is a grammar-related type (for analytics grouping).
bool get isGrammarType => grammarTypes.contains(this);
/// Whether this is a word-choice-related type (for analytics grouping).
bool get isWordChoiceType => wordChoiceTypes.contains(this);
/// Convert enum to snake_case string for JSON serialization.
String get name {
switch (this) {
// Client-specific types
case ReplacementTypeEnum.definition:
return "definition";
case ReplacementTypeEnum.practice:
return "practice";
case ReplacementTypeEnum.itStart:
return "itStart";
// Grammar types
case ReplacementTypeEnum.verbConjugation:
return "verb_conjugation";
case ReplacementTypeEnum.verbTense:
return "verb_tense";
case ReplacementTypeEnum.verbMood:
return "verb_mood";
case ReplacementTypeEnum.subjectVerbAgreement:
return "subject_verb_agreement";
case ReplacementTypeEnum.genderAgreement:
return "gender_agreement";
case ReplacementTypeEnum.numberAgreement:
return "number_agreement";
case ReplacementTypeEnum.caseError:
return "case_error";
case ReplacementTypeEnum.article:
return "article";
case ReplacementTypeEnum.preposition:
return "preposition";
case ReplacementTypeEnum.pronoun:
return "pronoun";
case ReplacementTypeEnum.wordOrder:
return "word_order";
case ReplacementTypeEnum.negation:
return "negation";
case ReplacementTypeEnum.questionFormation:
return "question_formation";
case ReplacementTypeEnum.relativeClause:
return "relative_clause";
case ReplacementTypeEnum.connector:
return "connector";
case ReplacementTypeEnum.possessive:
return "possessive";
case ReplacementTypeEnum.comparative:
return "comparative";
case ReplacementTypeEnum.passiveVoice:
return "passive_voice";
case ReplacementTypeEnum.conditional:
return "conditional";
case ReplacementTypeEnum.infinitiveGerund:
return "infinitive_gerund";
case ReplacementTypeEnum.modal:
return "modal";
// Surface-level corrections
case ReplacementTypeEnum.punct:
return "punct";
case ReplacementTypeEnum.diacritics:
return "diacritics";
case ReplacementTypeEnum.spell:
return "spell";
case ReplacementTypeEnum.cap:
return "cap";
// Word choice types
case ReplacementTypeEnum.falseCognate:
return "false_cognate";
case ReplacementTypeEnum.l1Interference:
return "l1_interference";
case ReplacementTypeEnum.collocation:
return "collocation";
case ReplacementTypeEnum.semanticConfusion:
return "semantic_confusion";
// Higher-level suggestions
case ReplacementTypeEnum.transcription:
return "transcription";
case ReplacementTypeEnum.style:
return "style";
case ReplacementTypeEnum.fluency:
return "fluency";
case ReplacementTypeEnum.didYouMean:
return "did_you_mean";
case ReplacementTypeEnum.translation:
return "translation";
case ReplacementTypeEnum.other:
return "other";
}
}
/// Parse type string from JSON, handling backward compatibility
/// for old saved data and snake_case to camelCase conversion.
static ReplacementTypeEnum? fromString(String? typeString) {
if (typeString == null) return null;
// Normalize snake_case to camelCase and handle backward compatibility
final normalized = switch (typeString) {
// Legacy mappings - grammar and word_choice were split into subtypes
'correction' => 'subjectVerbAgreement', // Legacy fallback
'grammar' => 'subjectVerbAgreement', // Legacy fallback
'word_choice' => 'semanticConfusion', // Legacy fallback
// Snake_case to camelCase conversions - grammar types
'did_you_mean' => 'didYouMean',
'verb_conjugation' => 'verbConjugation',
'verb_tense' => 'verbTense',
'verb_mood' => 'verbMood',
'subject_verb_agreement' => 'subjectVerbAgreement',
'gender_agreement' => 'genderAgreement',
'number_agreement' => 'numberAgreement',
'case_error' => 'caseError',
'word_order' => 'wordOrder',
'question_formation' => 'questionFormation',
'relative_clause' => 'relativeClause',
'passive_voice' => 'passiveVoice',
'infinitive_gerund' => 'infinitiveGerund',
// Snake_case to camelCase conversions - word choice types
'false_cognate' => 'falseCognate',
'l1_interference' => 'l1Interference',
'semantic_confusion' => 'semanticConfusion',
// 'collocation' is already single word, no conversion needed
// Already camelCase or single word - pass through
_ => typeString,
};
return ReplacementTypeEnum.values.firstWhereOrNull(
(e) => e.name == normalized || e.toString().split('.').last == normalized,
);
}
String defaultPrompt(BuildContext context) {
switch (this) {
case ReplacementTypeEnum.definition:
return L10n.of(context).definitionDefaultPrompt;
case ReplacementTypeEnum.practice:
return L10n.of(context).practiceDefaultPrompt;
case ReplacementTypeEnum.itStart:
return L10n.of(context).needsItMessage(
MatrixState.pangeaController.userController.userL2?.getDisplayName(
context,
) ??
L10n.of(context).targetLanguage,
);
// All grammar types and other corrections use the same default prompt
default:
return L10n.of(context).correctionDefaultPrompt;
}
}
/// Returns the underline color for this replacement type.
/// Used to visually distinguish different error categories in the text field.
Color underlineColor() {
// IT start and auto-apply types use primary color
if (this == ReplacementTypeEnum.itStart || isAutoApply) {
return AppConfig.primaryColor.withAlpha(180);
}
// Grammar errors use warning/orange
if (isGrammarType) {
return AppConfig.warning.withAlpha(180);
}
// Word choice uses blue
if (isWordChoiceType) {
return Colors.blue.withAlpha(180);
}
// Style and fluency use teal
switch (this) {
case ReplacementTypeEnum.style:
case ReplacementTypeEnum.fluency:
return Colors.teal.withAlpha(180);
default:
// Other/unknown use error color
return AppConfig.error.withAlpha(180);
}
}
/// Returns a human-readable display name for this replacement type.
/// Used in the SpanCard UI to show the error category.
String displayName(BuildContext context) {
if (isGrammarType) {
return L10n.of(context).spanTypeGrammar;
}
if (isWordChoiceType) {
return L10n.of(context).spanTypeWordChoice;
}
switch (this) {
case ReplacementTypeEnum.spell:
return L10n.of(context).spanTypeSpelling;
case ReplacementTypeEnum.punct:
return L10n.of(context).spanTypePunctuation;
case ReplacementTypeEnum.style:
return L10n.of(context).spanTypeStyle;
case ReplacementTypeEnum.fluency:
return L10n.of(context).spanTypeFluency;
case ReplacementTypeEnum.diacritics:
return L10n.of(context).spanTypeAccents;
case ReplacementTypeEnum.cap:
return L10n.of(context).spanTypeCapitalization;
default:
return L10n.of(context).spanTypeCorrection;
}
}
}

View file

@ -4,14 +4,15 @@ import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/bot/utils/bot_style.dart';
import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart';
import 'package:fluffychat/pangea/choreographer/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart';
import 'package:fluffychat/pangea/choreographer/igc/replacement_type_enum.dart';
import 'package:fluffychat/pangea/choreographer/igc/span_choice_type_enum.dart';
import 'package:fluffychat/pangea/choreographer/igc/span_data_model.dart';
import 'package:fluffychat/pangea/common/utils/async_state.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/common/widgets/feedback_dialog.dart';
import '../../../widgets/matrix.dart';
import '../../common/widgets/choice_array.dart';
@ -19,12 +20,14 @@ class SpanCard extends StatefulWidget {
final PangeaMatchState match;
final Choreographer choreographer;
final VoidCallback showNextMatch;
final Future Function(String) onFeedbackSubmitted;
const SpanCard({
super.key,
required this.match,
required this.choreographer,
required this.showNextMatch,
required this.onFeedbackSubmitted,
});
@override
@ -32,95 +35,24 @@ class SpanCard extends StatefulWidget {
}
class SpanCardState extends State<SpanCard> {
bool _loadingChoices = true;
final ValueNotifier<AsyncState<String>> _feedbackState =
ValueNotifier<AsyncState<String>>(const AsyncIdle<String>());
final ScrollController scrollController = ScrollController();
@override
void initState() {
super.initState();
_fetchChoices();
}
@override
void dispose() {
_feedbackState.dispose();
scrollController.dispose();
super.dispose();
}
List<SpanChoice>? get _choices => widget.match.updatedMatch.match.choices;
SpanChoice? get _selectedChoice =>
widget.match.updatedMatch.match.selectedChoice;
String? get _selectedFeedback => _selectedChoice?.feedback;
Future<void> _fetchChoices() async {
if (_choices != null && _choices!.length > 1) {
setState(() => _loadingChoices = false);
return;
}
setState(() => _loadingChoices = true);
try {
await widget.choreographer.igcController.fetchSpanDetails(
match: widget.match,
);
if (_choices == null || _choices!.isEmpty) {
widget.choreographer.clearMatches(
'No choices available for span ${widget.match.updatedMatch.match.message}',
);
}
} catch (e) {
widget.choreographer.clearMatches(e);
} finally {
if (mounted) {
setState(() => _loadingChoices = false);
}
}
}
Future<void> _fetchFeedback() async {
if (_selectedFeedback != null) {
_feedbackState.value = AsyncLoaded<String>(_selectedFeedback!);
return;
}
try {
_feedbackState.value = const AsyncLoading<String>();
await widget.choreographer.igcController.fetchSpanDetails(
match: widget.match,
force: true,
);
if (!mounted) return;
if (_selectedFeedback != null) {
_feedbackState.value = AsyncLoaded<String>(_selectedFeedback!);
} else {
_feedbackState.value = AsyncError<String>(
L10n.of(context).failedToLoadFeedback,
);
}
} catch (e) {
if (mounted) {
_feedbackState.value = AsyncError<String>(e);
}
}
}
void _onChoiceSelect(int index) {
final selected = _choices![index];
widget.match.selectChoice(index);
_feedbackState.value = selected.feedback != null
? AsyncLoaded<String>(selected.feedback!)
: const AsyncIdle<String>();
setState(() {});
}
@ -140,12 +72,51 @@ class SpanCardState extends State<SpanCard> {
}
}
Future<void> _showFeedbackDialog() async {
final resp = await showDialog(
context: context,
builder: (context) => FeedbackDialog(
title: L10n.of(context).spanFeedbackTitle,
onSubmit: (feedback) => Navigator.of(context).pop(feedback),
),
);
if (resp == null || resp.isEmpty) {
return;
}
await widget.onFeedbackSubmitted(resp);
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 300.0,
child: Column(
children: [
// Header row: Close, Type Label + BotFace, Flag
SizedBox(
height: 40.0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
icon: const Icon(Icons.close),
color: Theme.of(context).iconTheme.color,
onPressed: () => _updateMatch(PangeaMatchStatusEnum.ignored),
),
const Flexible(
child: Center(
child: BotFace(width: 32.0, expression: BotExpression.idle),
),
),
IconButton(
icon: const Icon(Icons.flag_outlined),
color: Theme.of(context).iconTheme.color,
onPressed: _showFeedbackDialog,
),
],
),
),
Expanded(
child: Scrollbar(
controller: scrollController,
@ -160,13 +131,13 @@ class SpanCardState extends State<SpanCard> {
spacing: 12.0,
children: [
ChoicesArray(
isLoading: _loadingChoices,
isLoading: false,
choices: widget.match.updatedMatch.match.choices
?.map(
(e) => Choice(
text: e.value,
color: e.selected ? e.type.color : null,
isGold: e.type.name == 'bestCorrection',
isGold: e.type.isSuggestion,
),
)
.toList(),
@ -180,11 +151,7 @@ class SpanCardState extends State<SpanCard> {
.userL2Code!,
),
const SizedBox(),
_SpanCardFeedback(
_selectedChoice != null,
_fetchFeedback,
_feedbackState,
),
_SpanCardFeedback(widget.match.updatedMatch.match),
],
),
),
@ -203,52 +170,30 @@ class SpanCardState extends State<SpanCard> {
}
class _SpanCardFeedback extends StatelessWidget {
final bool hasSelectedChoice;
final VoidCallback fetchFeedback;
final ValueNotifier<AsyncState<String>> feedbackState;
const _SpanCardFeedback(
this.hasSelectedChoice,
this.fetchFeedback,
this.feedbackState,
);
final SpanData? span;
const _SpanCardFeedback(this.span);
@override
Widget build(BuildContext context) {
String prompt = L10n.of(context).correctionDefaultPrompt;
if (span != null) {
prompt = span!.type.defaultPrompt(context);
}
final defaultContent = Text(
prompt,
style: BotStyle.text(context).copyWith(fontStyle: FontStyle.italic),
);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ValueListenableBuilder(
valueListenable: feedbackState,
builder: (context, state, _) {
return switch (state) {
AsyncIdle<String>() =>
hasSelectedChoice
? IconButton(
onPressed: fetchFeedback,
icon: const Icon(Icons.lightbulb_outline, size: 24),
)
: Text(
L10n.of(context).correctionDefaultPrompt,
style: BotStyle.text(
context,
).copyWith(fontStyle: FontStyle.italic),
),
AsyncLoading<String>() => const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(),
),
AsyncError<String>(:final error) => ErrorIndicator(
message: error.toString(),
),
AsyncLoaded<String>(:final value) => Text(
value,
span == null || span!.selectedChoice == null
? defaultContent
: Text(
span!.selectedChoice!.feedbackToDisplay(context),
style: BotStyle.text(context),
),
};
},
),
],
);
}

View file

@ -2,50 +2,65 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
enum SpanChoiceTypeEnum { bestCorrection, distractor, bestAnswer }
enum SpanChoiceTypeEnum {
suggestion,
alt,
distractor,
@Deprecated('Use suggestion instead')
bestCorrection,
@Deprecated('Use suggestion instead')
bestAnswer,
}
extension SpanChoiceExt on SpanChoiceTypeEnum {
String get name {
switch (this) {
case SpanChoiceTypeEnum.bestCorrection:
return "bestCorrection";
case SpanChoiceTypeEnum.distractor:
return "distractor";
case SpanChoiceTypeEnum.bestAnswer:
return "bestAnswer";
}
}
bool get isSuggestion =>
this == SpanChoiceTypeEnum.suggestion ||
// ignore: deprecated_member_use_from_same_package
this == SpanChoiceTypeEnum.bestCorrection ||
// ignore: deprecated_member_use_from_same_package
this == SpanChoiceTypeEnum.bestAnswer;
String defaultFeedback(BuildContext context) {
switch (this) {
case SpanChoiceTypeEnum.suggestion:
// ignore: deprecated_member_use_from_same_package
case SpanChoiceTypeEnum.bestCorrection:
return L10n.of(context).bestCorrectionFeedback;
case SpanChoiceTypeEnum.distractor:
return L10n.of(context).distractorFeedback;
case SpanChoiceTypeEnum.alt:
// ignore: deprecated_member_use_from_same_package
case SpanChoiceTypeEnum.bestAnswer:
return L10n.of(context).bestAnswerFeedback;
case SpanChoiceTypeEnum.distractor:
return L10n.of(context).distractorFeedback;
}
}
IconData get icon {
switch (this) {
case SpanChoiceTypeEnum.suggestion:
// ignore: deprecated_member_use_from_same_package
case SpanChoiceTypeEnum.bestCorrection:
case SpanChoiceTypeEnum.alt:
// ignore: deprecated_member_use_from_same_package
case SpanChoiceTypeEnum.bestAnswer:
return Icons.check_circle;
case SpanChoiceTypeEnum.distractor:
return Icons.cancel;
case SpanChoiceTypeEnum.bestAnswer:
return Icons.check_circle;
}
}
Color get color {
switch (this) {
case SpanChoiceTypeEnum.suggestion:
// ignore: deprecated_member_use_from_same_package
case SpanChoiceTypeEnum.bestCorrection:
return Colors.green;
case SpanChoiceTypeEnum.alt:
// ignore: deprecated_member_use_from_same_package
case SpanChoiceTypeEnum.bestAnswer:
return Colors.green;
case SpanChoiceTypeEnum.distractor:
return Colors.red;
case SpanChoiceTypeEnum.bestAnswer:
return Colors.green;
}
}
}

View file

@ -4,8 +4,8 @@ import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/choreographer/igc/text_normalization_util.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'replacement_type_enum.dart';
import 'span_choice_type_enum.dart';
import 'span_data_type_enum.dart';
class SpanData {
final String? message;
@ -14,7 +14,7 @@ class SpanData {
final int offset;
final int length;
final String fullText;
final SpanDataType type;
final ReplacementTypeEnum type;
final Rule? rule;
SpanData({
@ -35,7 +35,7 @@ class SpanData {
int? offset,
int? length,
String? fullText,
SpanDataType? type,
ReplacementTypeEnum? type,
Rule? rule,
}) {
return SpanData(
@ -50,8 +50,28 @@ class SpanData {
);
}
factory SpanData.fromJson(Map<String, dynamic> json) {
/// Parse SpanData from JSON.
///
/// [parentFullText] is used as fallback when the span JSON doesn't contain
/// full_text (e.g., when the server omits it to reduce payload size and
/// the full text is available at the response level as original_input).
factory SpanData.fromJson(
Map<String, dynamic> json, {
String? parentFullText,
}) {
final Iterable? choices = json['choices'] ?? json['replacements'];
final dynamic rawType =
json['type'] ?? json['type_name'] ?? json['typeName'];
final String? typeString = rawType is Map<String, dynamic>
? (rawType['type_name'] ?? rawType['type'] ?? rawType['typeName'])
as String?
: rawType as String?;
// Try to get fullText from span JSON, fall back to parent's original_input
final String? spanFullText =
json['sentence'] ?? json['full_text'] ?? json['fullText'];
final String fullText = spanFullText ?? parentFullText ?? '';
return SpanData(
message: json['message'],
shortMessage: json['shortMessage'] ?? json['short_message'],
@ -62,9 +82,10 @@ class SpanData {
.toList(),
offset: json['offset'] as int,
length: json['length'] as int,
fullText:
json['sentence'] ?? json['full_text'] ?? json['fullText'] as String,
type: SpanDataType.fromJson(json['type'] as Map<String, dynamic>),
fullText: fullText,
type:
SpanDataTypeEnumExt.fromString(typeString) ??
ReplacementTypeEnum.other,
rule: json['rule'] != null
? Rule.fromJson(json['rule'] as Map<String, dynamic>)
: null,
@ -76,7 +97,7 @@ class SpanData {
'offset': offset,
'length': length,
'full_text': fullText,
'type': type.toJson(),
'type': type.name,
};
if (message != null) {
@ -133,7 +154,17 @@ class SpanData {
String get errorSpan =>
fullText.characters.skip(offset).take(length).toString();
/// Whether this span is a minor correction that should be auto-applied.
/// Returns true if:
/// 1. The type is explicitly marked as auto-apply (e.g., punct, spell, cap, diacritics), OR
/// 2. For backwards compatibility with old data that lacks new types:
/// the type is NOT auto-apply AND the normalized strings match.
bool isNormalizationError() {
// New data with explicit auto-apply types
if (type.isAutoApply) {
return true;
}
final correctChoice = choices
?.firstWhereOrNull((c) => c.isBestCorrection)
?.value;
@ -223,8 +254,8 @@ class SpanChoice {
? SpanChoiceTypeEnum.values.firstWhereOrNull(
(element) => element.name == json['type'],
) ??
SpanChoiceTypeEnum.bestCorrection
: SpanChoiceTypeEnum.bestCorrection,
SpanChoiceTypeEnum.suggestion
: SpanChoiceTypeEnum.suggestion,
feedback: json['feedback'],
selected: json['selected'] ?? false,
timestamp: json['timestamp'] != null
@ -236,18 +267,15 @@ class SpanChoice {
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {'value': value, 'type': type.name};
if (selected) {
data['selected'] = selected;
// V2 format: use selected_at instead of separate selected + timestamp
if (selected && timestamp != null) {
data['selected_at'] = timestamp!.toIso8601String();
}
if (feedback != null) {
data['feedback'] = feedback;
}
if (timestamp != null) {
data['timestamp'] = timestamp!.toIso8601String();
}
return data;
}
@ -258,7 +286,7 @@ class SpanChoice {
return feedback!;
}
bool get isBestCorrection => type == SpanChoiceTypeEnum.bestCorrection;
bool get isBestCorrection => type.isSuggestion;
Color get color => type.color;
@ -307,36 +335,3 @@ class Rule {
return id.hashCode;
}
}
class SpanDataType {
final SpanDataTypeEnum typeName;
const SpanDataType({required this.typeName});
factory SpanDataType.fromJson(Map<String, dynamic> json) {
final String? type =
json['typeName'] ?? json['type'] ?? json['type_name'] as String?;
return SpanDataType(
typeName: type != null
? SpanDataTypeEnum.values.firstWhereOrNull(
(element) => element.name == type,
) ??
SpanDataTypeEnum.correction
: SpanDataTypeEnum.correction,
);
}
Map<String, dynamic> toJson() => {'type_name': typeName.name};
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! SpanDataType) return false;
return other.typeName == typeName;
}
@override
int get hashCode {
return typeName.hashCode;
}
}

View file

@ -1,39 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/widgets/matrix.dart';
enum SpanDataTypeEnum { definition, practice, correction, itStart }
extension SpanDataTypeEnumExt on SpanDataTypeEnum {
String get name {
switch (this) {
case SpanDataTypeEnum.definition:
return "definition";
case SpanDataTypeEnum.practice:
return "practice";
case SpanDataTypeEnum.correction:
return "correction";
case SpanDataTypeEnum.itStart:
return "itStart";
}
}
String defaultPrompt(BuildContext context) {
switch (this) {
case SpanDataTypeEnum.definition:
return L10n.of(context).definitionDefaultPrompt;
case SpanDataTypeEnum.practice:
return L10n.of(context).practiceDefaultPrompt;
case SpanDataTypeEnum.correction:
return L10n.of(context).correctionDefaultPrompt;
case SpanDataTypeEnum.itStart:
return L10n.of(context).needsItMessage(
MatrixState.pangeaController.userController.userL2?.getDisplayName(
context,
) ??
L10n.of(context).targetLanguage,
);
}
}
}

View file

@ -2,12 +2,13 @@ import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/choreographer/choreo_constants.dart';
import 'package:fluffychat/pangea/choreographer/igc/autocorrect_span.dart';
import 'package:fluffychat/pangea/choreographer/igc/match_rule_id_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart';
import 'package:fluffychat/pangea/choreographer/igc/replacement_type_enum.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -32,19 +33,13 @@ class PangeaTextController extends TextEditingController {
);
Color _underlineColor(PangeaMatch match) {
// Automatic corrections use primary color
if (match.status == PangeaMatchStatusEnum.automatic) {
return const Color.fromARGB(187, 132, 96, 224);
return AppConfig.primaryColor.withAlpha(180);
}
switch (match.match.rule?.id ?? "unknown") {
case MatchRuleIdModel.interactiveTranslation:
return const Color.fromARGB(187, 132, 96, 224);
case MatchRuleIdModel.tokenNeedsTranslation:
case MatchRuleIdModel.tokenSpanNeedsTranslation:
return const Color.fromARGB(186, 255, 132, 0);
default:
return const Color.fromARGB(149, 255, 17, 0);
}
// Use type-based coloring
return match.match.type.underlineColor();
}
TextStyle _textStyle(

View file

@ -9,6 +9,7 @@ class ModelKey {
static const String userDateOfBirth = 'date_of_birth';
static const String userSpeaks = 'speaks';
static const String userCountry = 'country';
static const String userAbout = 'about';
static const String hasJoinedHelpSpace = 'has_joined_help_space';
static const String userInterests = 'interests';
static const String publicProfile = 'public_profile';
@ -106,6 +107,8 @@ class ModelKey {
static const String currentText = "current";
static const String bestContinuance = "best_continuance";
static const String feedbackLang = "feedback_lang";
static const String feedback = "feedback";
static const String content = "content";
static const String transcription = "transcription";
static const String botTranscription = 'bot_transcription';

View file

@ -114,9 +114,12 @@ class PangeaController {
);
_settingsSubscription?.cancel();
_settingsSubscription = userController.settingsUpdateStream.stream.listen(
(update) => matrixState.client.updateBotOptions(update.userSettings),
);
_settingsSubscription = userController.settingsUpdateStream.stream.listen((
update,
) async {
await matrixState.client.updateBotOptions(update.userSettings);
await userController.updatePublicProfile();
});
_joinSpaceSubscription?.cancel();
_joinSpaceSubscription ??= matrixState.client.onSync.stream
@ -161,8 +164,11 @@ class PangeaController {
]);
}
_clearCache(exclude: exclude);
matrixState.client.updateBotOptions(userController.profile.userSettings);
await _clearCache(exclude: exclude);
await matrixState.client.updateBotOptions(
userController.profile.userSettings,
);
await userController.updatePublicProfile();
}
static final List<String> _storageKeys = [

View file

@ -0,0 +1,46 @@
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/learning_settings/gender_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
/// Base request schema matching the backend's BaseRequestSchema.
/// Common fields for all LLM-based requests.
mixin BaseRequestModel {
/// User's native language code (L1)
String get userL1;
/// User's target language code (L2)
String get userL2;
/// User's CEFR proficiency level (defaults to "pre_a1")
String get userCefr;
/// Convert to JSON map with common fields
Map<String, dynamic> toBaseJson() => {
ModelKey.userL1: userL1,
ModelKey.userL2: userL2,
ModelKey.cefrLevel: userCefr,
ModelKey.userGender: MatrixState
.pangeaController
.userController
.profile
.userSettings
.gender
.string,
};
/// Injects user context (CEFR level, gender) into a request body.
/// Safely handles cases where MatrixState is not yet initialized.
/// Does not overwrite existing values.
static Map<String, dynamic> injectUserContext(Map<dynamic, dynamic> body) {
final result = Map<String, dynamic>.from(body);
try {
final settings =
MatrixState.pangeaController.userController.profile.userSettings;
result[ModelKey.cefrLevel] ??= settings.cefrLevel.string;
result[ModelKey.userGender] ??= settings.gender.string;
} catch (_) {
// MatrixState not initialized - leave existing values or omit
}
return result;
}
}

View file

@ -0,0 +1,25 @@
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
/// Generic feedback schema matching the backend's LLMFeedbackSchema.
/// Used for providing user corrections to LLM-generated content.
class LLMFeedbackModel<T> {
/// User's feedback text describing the issue
final String feedback;
/// Original response that user is providing feedback on
final T content;
/// Function to serialize the content to JSON
final Map<String, dynamic> Function(T) contentToJson;
const LLMFeedbackModel({
required this.feedback,
required this.content,
required this.contentToJson,
});
Map<String, dynamic> toJson() => {
ModelKey.feedback: feedback,
ModelKey.content: contentToJson(content),
};
}

View file

@ -5,9 +5,7 @@ import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/learning_settings/gender_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/pangea/common/models/base_request_model.dart';
class Requests {
late String? accessToken;
@ -19,23 +17,10 @@ class Requests {
required String url,
required Map<dynamic, dynamic> body,
}) async {
body[ModelKey.cefrLevel] = MatrixState
.pangeaController
.userController
.profile
.userSettings
.cefrLevel
.string;
body[ModelKey.userGender] = MatrixState
.pangeaController
.userController
.profile
.userSettings
.gender
.string;
final enrichedBody = BaseRequestModel.injectUserContext(body);
dynamic encoded;
encoded = jsonEncode(body);
encoded = jsonEncode(enrichedBody);
final http.Response response = await http.post(
Uri.parse(url),

View file

@ -29,7 +29,7 @@ class PApiUrls {
static String languageDetection =
"${PApiUrls._choreoEndpoint}/language_detection";
static String igcLite = "${PApiUrls._choreoEndpoint}/grammar_lite";
static String igcLite = "${PApiUrls._choreoEndpoint}/grammar_v2";
static String spanDetails = "${PApiUrls._choreoEndpoint}/span_details";
static String simpleTranslation =

View file

@ -223,6 +223,7 @@ class OverlayUtil {
Choreographer choreographer,
BuildContext context,
VoidCallback showNextMatch,
Future Function(String) onFeedbackSubmitted,
) {
MatrixState.pAnyState.closeAllOverlays();
showPositionedCard(
@ -233,6 +234,7 @@ class OverlayUtil {
match: match,
choreographer: choreographer,
showNextMatch: showNextMatch,
onFeedbackSubmitted: onFeedbackSubmitted,
),
maxHeight: 325,
maxWidth: 325,

Some files were not shown because too many files have changed in this diff Show more