Writing assistance (#5598)

* feat: wa working full stack

* feat: writing assistance made anew

* docs: migrate copilot docs to .github/instructions/ format

- Create choreographer.instructions.md (applyTo: lib/pangea/choreographer/**)
- Create events-and-tokens.instructions.md (applyTo: lib/pangea/events/**,lib/pangea/extensions/**)
- Create modules.instructions.md (applyTo: lib/pangea/**) — full module map
- Track copilot-instructions.md (remove .gitignore rule)
- Add documentation reference table to copilot-instructions.md

Content sourced from docs/copilot/ on writing-assistance branch.

* docs: remove old docs/copilot/ (migrated to .github/instructions/)

* docs: update choreographer + modules docs for writing-assistance audit

- Mark IT (Interactive Translation) as deprecated throughout
- Document new ReplacementTypeEnum taxonomy (grammar, surface, word-choice categories)
- Add AssistanceStateEnum, AutocorrectPopup, feedback rerun flow
- Mark SpanDataRepo/span_details as dead code
- Mark SpanChoiceTypeEnum.bestCorrection/bestAnswer as deprecated
- Add new files to modules listing (autocorrect_popup, start_igc_button, etc.)
- Update API endpoints table with active/deprecated/dead status

* formatting, replace deprecated withOpacity calls

* fix linter issues from deprecated types

* use better error color

* move cloing of overlays into choreographer

* reduce duplicate code on igc_controller, update UI on feedback

* couple of adjustments

* display prompt in span card by type

* fix error in tests

* translations

* simplify span card feedback

---------

Co-authored-by: ggurdin <ggurdin@gmail.com>
This commit is contained in:
wcjord 2026-02-09 15:55:18 -05:00 committed by GitHub
parent 507fee84fe
commit dec473d579
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
86 changed files with 5514 additions and 392 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
@ -49,8 +50,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": "2026-02-09 11:09:32.801033",
"@@last_modified": "2026-02-09 15:31:53.731729",
"about": "حول",
"@about": {
"type": "String",
@ -11231,5 +11231,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "قواعد اللغة",
"spanTypeWordChoice": "اختيار الكلمات",
"spanTypeSpelling": "التهجئة",
"spanTypePunctuation": "علامات الترقيم",
"spanTypeStyle": "الأسلوب",
"spanTypeFluency": "الطلاقة",
"spanTypeAccents": "اللكنات",
"spanTypeCapitalization": "حروف الجر",
"spanTypeCorrection": "تصحيح",
"spanFeedbackTitle": "الإبلاغ عن مشكلة تصحيح",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1908,7 +1908,7 @@
"playWithAI": "Пакуль гуляйце з ШІ",
"courseStartDesc": "Pangea Bot гатовы да працы ў любы час!\n\n...але навучанне лепш з сябрамі!",
"@@locale": "be",
"@@last_modified": "2026-02-09 11:09:23.199652",
"@@last_modified": "2026-02-09 15:31:34.877959",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -12113,5 +12113,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Граматыка",
"spanTypeWordChoice": "Выбар слоў",
"spanTypeSpelling": "Арфаграфія",
"spanTypePunctuation": "Пунктуацыя",
"spanTypeStyle": "Стыль",
"spanTypeFluency": "Флюентнасць",
"spanTypeAccents": "Акцэнты",
"spanTypeCapitalization": "Капіталізацыя",
"spanTypeCorrection": "Карэкцыя",
"spanFeedbackTitle": "Паведаміць пра праблему з карэкцыяй",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:09:46.816075",
"@@last_modified": "2026-02-09 15:32:17.400141",
"about": "সম্পর্কে",
"@about": {
"type": "String",
@ -12118,5 +12118,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "ব্যাকরণ",
"spanTypeWordChoice": "শব্দ নির্বাচন",
"spanTypeSpelling": "বানান",
"spanTypePunctuation": "বিরাম চিহ্ন",
"spanTypeStyle": "শৈলী",
"spanTypeFluency": "প্রবাহ",
"spanTypeAccents": "উচ্চারণ",
"spanTypeCapitalization": "বড় হাতের অক্ষর",
"spanTypeCorrection": "সংশোধন",
"spanFeedbackTitle": "সংশোধন সমস্যা রিপোর্ট করুন",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -4276,7 +4276,7 @@
"joinPublicTrip": "མི་ཚེས་ལ་ལོག་འབད།",
"startOwnTrip": "ངེད་རང་གི་ལོག་ལ་སྦྱོར་བཅོས།",
"@@locale": "bo",
"@@last_modified": "2026-02-09 11:09:44.069009",
"@@last_modified": "2026-02-09 15:32:12.071200",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -10768,5 +10768,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Izbor reči",
"spanTypeSpelling": "Pravopis",
"spanTypePunctuation": "Interpunkcija",
"spanTypeStyle": "Stil",
"spanTypeFluency": "Tečnost",
"spanTypeAccents": "Akcenti",
"spanTypeCapitalization": "Velika slova",
"spanTypeCorrection": "Ispravka",
"spanFeedbackTitle": "Prijavi problem sa ispravkom",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:09:24.612806",
"@@last_modified": "2026-02-09 15:31:37.024931",
"about": "Quant a",
"@about": {
"type": "String",
@ -11038,5 +11038,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramàtica",
"spanTypeWordChoice": "Elecció de paraules",
"spanTypeSpelling": "Ortografia",
"spanTypePunctuation": "Puntuació",
"spanTypeStyle": "Estil",
"spanTypeFluency": "Fluïdesa",
"spanTypeAccents": "Accents",
"spanTypeCapitalization": "Majúscules",
"spanTypeCorrection": "Correcció",
"spanFeedbackTitle": "Informar d'un problema de correcció",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "cs",
"@@last_modified": "2026-02-09 11:09:20.002945",
"@@last_modified": "2026-02-09 15:31:28.643814",
"about": "O aplikaci",
"@about": {
"type": "String",
@ -11621,5 +11621,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Výběr slov",
"spanTypeSpelling": "Pravopis",
"spanTypePunctuation": "Interpunkce",
"spanTypeStyle": "Styl",
"spanTypeFluency": "Plynulost",
"spanTypeAccents": "Přízvuky",
"spanTypeCapitalization": "Velká písmena",
"spanTypeCorrection": "Oprava",
"spanFeedbackTitle": "Nahlásit problém s opravou",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1927,7 +1927,7 @@
"playWithAI": "Leg med AI for nu",
"courseStartDesc": "Pangea Bot er klar til at starte når som helst!\n\n...men læring er bedre med venner!",
"@@locale": "da",
"@@last_modified": "2026-02-09 11:08:45.510364",
"@@last_modified": "2026-02-09 15:30:41.802679",
"@aboutHomeserver": {
"type": "String",
"placeholders": {
@ -12075,5 +12075,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Grammatik",
"spanTypeWordChoice": "Ordvalg",
"spanTypeSpelling": "Stavning",
"spanTypePunctuation": "Interpunktion",
"spanTypeStyle": "Stil",
"spanTypeFluency": "Flydende",
"spanTypeAccents": "Accenter",
"spanTypeCapitalization": "Versalisering",
"spanTypeCorrection": "Korrektur",
"spanFeedbackTitle": "Rapporter korrektion problem",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "de",
"@@last_modified": "2026-02-09 11:09:13.137048",
"@@last_modified": "2026-02-09 15:31:16.861620",
"alwaysUse24HourFormat": "true",
"@alwaysUse24HourFormat": {
"description": "Set to true to always display time of day in 24 hour format."
@ -11021,5 +11021,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Grammatik",
"spanTypeWordChoice": "Wortwahl",
"spanTypeSpelling": "Rechtschreibung",
"spanTypePunctuation": "Interpunktion",
"spanTypeStyle": "Stil",
"spanTypeFluency": "Flüssigkeit",
"spanTypeAccents": "Akzente",
"spanTypeCapitalization": "Großschreibung",
"spanTypeCorrection": "Korrektur",
"spanFeedbackTitle": "Korrekturproblem melden",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -4453,7 +4453,7 @@
"playWithAI": "Παίξτε με την Τεχνητή Νοημοσύνη προς το παρόν",
"courseStartDesc": "Ο Pangea Bot είναι έτοιμος να ξεκινήσει οποιαδήποτε στιγμή!\n\n...αλλά η μάθηση είναι καλύτερη με φίλους!",
"@@locale": "el",
"@@last_modified": "2026-02-09 11:09:52.785391",
"@@last_modified": "2026-02-09 15:32:27.325286",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -12072,5 +12072,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Γραμματική",
"spanTypeWordChoice": "Επιλογή Λέξεων",
"spanTypeSpelling": "Ορθογραφία",
"spanTypePunctuation": "Σημεία στίξης",
"spanTypeStyle": "Στυλ",
"spanTypeFluency": "Ροή",
"spanTypeAccents": "Τονισμοί",
"spanTypeCapitalization": "Κεφαλαία",
"spanTypeCorrection": "Διόρθωση",
"spanFeedbackTitle": "Αναφορά προβλήματος διόρθωσης",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -5072,6 +5072,16 @@
"autoIGCToolName": "Enable writing assistance",
"autoIGCToolDescription": "Automatically run Pangea Chat tools to correct sent messages to target language.",
"emptyAudioError": "Recording failed. Please check your audio permissions and try again.",
"spanTypeGrammar": "Grammar",
"spanTypeWordChoice": "Word Choice",
"spanTypeSpelling": "Spelling",
"spanTypePunctuation": "Punctuation",
"spanTypeStyle": "Style",
"spanTypeFluency": "Fluency",
"spanTypeAccents": "Accents",
"spanTypeCapitalization": "Capitalization",
"spanTypeCorrection": "Correction",
"spanFeedbackTitle": "Report correction issue",
"selectAllWords": "Select all the words you hear in the audio",
"aboutMeHint": "About me",
"changeEmail": "Change email",

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:09:57.785827",
"@@last_modified": "2026-02-09 15:32:34.842172",
"about": "Prio",
"@about": {
"type": "String",
@ -12103,5 +12103,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatiko",
"spanTypeWordChoice": "Vortelekto",
"spanTypeSpelling": "Ortografio",
"spanTypePunctuation": "Punkto",
"spanTypeStyle": "Stilo",
"spanTypeFluency": "Flueco",
"spanTypeAccents": "Akcentoj",
"spanTypeCapitalization": "Kapitaligo",
"spanTypeCorrection": "Korektado",
"spanFeedbackTitle": "Raporti korektadon",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "es",
"@@last_modified": "2026-02-09 11:08:40.981454",
"@@last_modified": "2026-02-09 15:30:33.438936",
"about": "Acerca de",
"@about": {
"type": "String",
@ -8256,5 +8256,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramática",
"spanTypeWordChoice": "Elección de palabras",
"spanTypeSpelling": "Ortografía",
"spanTypePunctuation": "Puntuación",
"spanTypeStyle": "Estilo",
"spanTypeFluency": "Fluidez",
"spanTypeAccents": "Acentos",
"spanTypeCapitalization": "Capitalización",
"spanTypeCorrection": "Corrección",
"spanFeedbackTitle": "Informar problema de corrección",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "et",
"@@last_modified": "2026-02-09 11:09:05.357413",
"@@last_modified": "2026-02-09 15:31:13.687328",
"about": "Rakenduse teave",
"@about": {
"type": "String",
@ -11285,5 +11285,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Grammatika",
"spanTypeWordChoice": "Sõnavalik",
"spanTypeSpelling": "Õigekiri",
"spanTypePunctuation": "Interpunktsioon",
"spanTypeStyle": "Stiil",
"spanTypeFluency": "Sujuvus",
"spanTypeAccents": "Aksendid",
"spanTypeCapitalization": "Suurtähed",
"spanTypeCorrection": "Parandus",
"spanFeedbackTitle": "Teata paranduse probleemist",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "eu",
"@@last_modified": "2026-02-09 11:09:01.052613",
"@@last_modified": "2026-02-09 15:31:08.773071",
"about": "Honi buruz",
"@about": {
"type": "String",
@ -11014,5 +11014,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Hitz Aukera",
"spanTypeSpelling": "Ortografia",
"spanTypePunctuation": "Puntuazioa",
"spanTypeStyle": "Estiloa",
"spanTypeFluency": "Fluentzia",
"spanTypeAccents": "Azentuak",
"spanTypeCapitalization": "Kapitalizazioa",
"spanTypeCorrection": "Zuzenketa",
"spanFeedbackTitle": "Zuzenketa arazoa txostenatu",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:09:48.180617",
"@@last_modified": "2026-02-09 15:32:19.749220",
"repeatPassword": "تکرار رمزعبور",
"@repeatPassword": {},
"about": "درباره",
@ -11746,5 +11746,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "قواعد",
"spanTypeWordChoice": "انتخاب واژه",
"spanTypeSpelling": "هجی",
"spanTypePunctuation": "نقطه‌گذاری",
"spanTypeStyle": "سبک",
"spanTypeFluency": "روانی",
"spanTypeAccents": "لهجه‌ها",
"spanTypeCapitalization": "حروف بزرگ",
"spanTypeCorrection": "تصحیح",
"spanFeedbackTitle": "گزارش مشکل تصحیح",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -4006,7 +4006,7 @@
"playWithAI": "Leiki tekoälyn kanssa nyt",
"courseStartDesc": "Pangea Bot on valmis milloin tahansa!\n\n...mutta oppiminen on parempaa ystävien kanssa!",
"@@locale": "fi",
"@@last_modified": "2026-02-09 11:08:44.050102",
"@@last_modified": "2026-02-09 15:30:38.959801",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -11637,5 +11637,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Kielioppi",
"spanTypeWordChoice": "Sananvalinta",
"spanTypeSpelling": "Oikeinkirjoitus",
"spanTypePunctuation": "Välihuomautukset",
"spanTypeStyle": "Tyyli",
"spanTypeFluency": "Sujuvuus",
"spanTypeAccents": "Aksentit",
"spanTypeCapitalization": "Isot kirjaimet",
"spanTypeCorrection": "Korjaus",
"spanFeedbackTitle": "Ilmoita korjausongelmasta",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -2784,7 +2784,7 @@
"selectAll": "Piliin lahat",
"deselectAll": "Huwag piliin lahat",
"@@locale": "fil",
"@@last_modified": "2026-02-09 11:09:29.936599",
"@@last_modified": "2026-02-09 15:31:47.672143",
"@setCustomPermissionLevel": {
"type": "String",
"placeholders": {}
@ -11990,5 +11990,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Balarila",
"spanTypeWordChoice": "Pagpili ng Salita",
"spanTypeSpelling": "Pagbaybay",
"spanTypePunctuation": "Bantas",
"spanTypeStyle": "Estilo",
"spanTypeFluency": "Kadaluyan",
"spanTypeAccents": "Mga Tono",
"spanTypeCapitalization": "Pagkakapital",
"spanTypeCorrection": "Pagwawasto",
"spanFeedbackTitle": "Iulat ang isyu sa pagwawasto",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "fr",
"@@last_modified": "2026-02-09 11:10:07.003221",
"@@last_modified": "2026-02-09 15:32:46.273653",
"about": "À propos",
"@about": {
"type": "String",
@ -11338,5 +11338,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Grammaire",
"spanTypeWordChoice": "Choix des mots",
"spanTypeSpelling": "Orthographe",
"spanTypePunctuation": "Ponctuation",
"spanTypeStyle": "Style",
"spanTypeFluency": "Fluidité",
"spanTypeAccents": "Accents",
"spanTypeCapitalization": "Capitalisation",
"spanTypeCorrection": "Correction",
"spanFeedbackTitle": "Signaler un problème de correction",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -4514,7 +4514,7 @@
"playWithAI": "Imir le AI faoi láthair",
"courseStartDesc": "Tá Bot Pangea réidh chun dul am ar bith!\n\n...ach is fearr foghlaim le cairde!",
"@@locale": "ga",
"@@last_modified": "2026-02-09 11:10:05.570216",
"@@last_modified": "2026-02-09 15:32:44.231605",
"@customReaction": {
"type": "String",
"placeholders": {}
@ -11012,5 +11012,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramadach",
"spanTypeWordChoice": "Rogha Focal",
"spanTypeSpelling": "Litriú",
"spanTypePunctuation": "Póntú",
"spanTypeStyle": "Stíl",
"spanTypeFluency": "Sreabhadh",
"spanTypeAccents": "Guthanna",
"spanTypeCapitalization": "Caipitleadh",
"spanTypeCorrection": "Córas",
"spanFeedbackTitle": "Tuairisc a dhéanamh ar fhadhb le ceartú",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "gl",
"@@last_modified": "2026-02-09 11:08:42.508158",
"@@last_modified": "2026-02-09 15:30:36.091824",
"about": "Acerca de",
"@about": {
"type": "String",
@ -11011,5 +11011,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramática",
"spanTypeWordChoice": "Escolma de Palabras",
"spanTypeSpelling": "Ortografía",
"spanTypePunctuation": "Pontuación",
"spanTypeStyle": "Estilo",
"spanTypeFluency": "Fluidez",
"spanTypeAccents": "Acentos",
"spanTypeCapitalization": "Capitalización",
"spanTypeCorrection": "Corrección",
"spanFeedbackTitle": "Informar de problemas de corrección",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:08:56.858820",
"@@last_modified": "2026-02-09 15:31:01.183623",
"about": "אודות",
"@about": {
"type": "String",
@ -12063,5 +12063,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "דקדוק",
"spanTypeWordChoice": "בחירת מילים",
"spanTypeSpelling": "איות",
"spanTypePunctuation": "פיסוק",
"spanTypeStyle": "סגנון",
"spanTypeFluency": "שפה רהוטה",
"spanTypeAccents": "הטעמה",
"spanTypeCapitalization": "הגדלת אותיות",
"spanTypeCorrection": "תיקון",
"spanFeedbackTitle": "דווח על בעיית תיקון",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -4480,7 +4480,7 @@
"playWithAI": "अभी के लिए एआई के साथ खेलें",
"courseStartDesc": "पैंजिया बॉट कभी भी जाने के लिए तैयार है!\n\n...लेकिन दोस्तों के साथ सीखना बेहतर है!",
"@@locale": "hi",
"@@last_modified": "2026-02-09 11:09:56.145817",
"@@last_modified": "2026-02-09 15:32:32.199640",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -12099,5 +12099,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "व्याकरण",
"spanTypeWordChoice": "शब्द चयन",
"spanTypeSpelling": "वर्तनी",
"spanTypePunctuation": "विराम चिह्न",
"spanTypeStyle": "शैली",
"spanTypeFluency": "धाराप्रवाहता",
"spanTypeAccents": "उच्चारण",
"spanTypeCapitalization": "बड़े अक्षर",
"spanTypeCorrection": "सुधार",
"spanFeedbackTitle": "सुधार समस्या की रिपोर्ट करें",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "hr",
"@@last_modified": "2026-02-09 11:08:55.423013",
"@@last_modified": "2026-02-09 15:30:58.744003",
"about": "Informacije",
"@about": {
"type": "String",
@ -11386,5 +11386,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Odabir riječi",
"spanTypeSpelling": "Pravopis",
"spanTypePunctuation": "Interpunkcija",
"spanTypeStyle": "Stil",
"spanTypeFluency": "Tečnost",
"spanTypeAccents": "Naglasci",
"spanTypeCapitalization": "Velika slova",
"spanTypeCorrection": "Ispravak",
"spanFeedbackTitle": "Prijavi problem s ispravkom",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "hu",
"@@last_modified": "2026-02-09 11:08:47.162355",
"@@last_modified": "2026-02-09 15:30:44.809310",
"about": "Névjegy",
"@about": {
"type": "String",
@ -11015,5 +11015,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Nyelvtan",
"spanTypeWordChoice": "Szóválasztás",
"spanTypeSpelling": "Helyesírás",
"spanTypePunctuation": "Írásjelek",
"spanTypeStyle": "Stílus",
"spanTypeFluency": "Folyékonyság",
"spanTypeAccents": "Akcentusok",
"spanTypeCapitalization": "Nagybetűs írás",
"spanTypeCorrection": "Javítás",
"spanFeedbackTitle": "Javítási probléma jelentése",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1955,7 +1955,7 @@
"playWithAI": "Joca con le IA pro ora",
"courseStartDesc": "Pangea Bot es preste a comenzar a qualunque momento!\n\n...ma apprender es melior con amicos!",
"@@locale": "ia",
"@@last_modified": "2026-02-09 11:08:57.966375",
"@@last_modified": "2026-02-09 15:31:03.556297",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -12092,5 +12092,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Escolha de Palavras",
"spanTypeSpelling": "Ortografia",
"spanTypePunctuation": "Pontuação",
"spanTypeStyle": "Estilo",
"spanTypeFluency": "Fluência",
"spanTypeAccents": "Acentos",
"spanTypeCapitalization": "Capitalização",
"spanTypeCorrection": "Correção",
"spanFeedbackTitle": "Relatar problema de correção",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:08:48.564116",
"@@last_modified": "2026-02-09 15:30:47.056945",
"setAsCanonicalAlias": "Atur sebagai alias utama",
"@setAsCanonicalAlias": {
"type": "String",
@ -11005,5 +11005,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Tata Bahasa",
"spanTypeWordChoice": "Pilihan Kata",
"spanTypeSpelling": "Ejaan",
"spanTypePunctuation": "Tanda Baca",
"spanTypeStyle": "Gaya",
"spanTypeFluency": "Kefasihan",
"spanTypeAccents": "Aksen",
"spanTypeCapitalization": "Kapitalisasi",
"spanTypeCorrection": "Koreksi",
"spanFeedbackTitle": "Laporkan masalah koreksi",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -4369,7 +4369,7 @@
"playWithAI": "Joca con AI pro ora",
"courseStartDesc": "Pangea Bot es preste a partir a qualunque momento!\n\n...ma apprender es melior con amicos!",
"@@locale": "ie",
"@@last_modified": "2026-02-09 11:08:53.872484",
"@@last_modified": "2026-02-09 15:30:55.872849",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -11988,5 +11988,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatica",
"spanTypeWordChoice": "Elección de palabras",
"spanTypeSpelling": "Ortografía",
"spanTypePunctuation": "Puntuación",
"spanTypeStyle": "Estilo",
"spanTypeFluency": "Fluidez",
"spanTypeAccents": "Acentos",
"spanTypeCapitalization": "Capitalización",
"spanTypeCorrection": "Corrección",
"spanFeedbackTitle": "Informar problema de corrección",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:09:17.051102",
"@@last_modified": "2026-02-09 15:31:23.943569",
"about": "Informazioni",
"@about": {
"type": "String",
@ -11017,5 +11017,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Grammatica",
"spanTypeWordChoice": "Scelta delle parole",
"spanTypeSpelling": "Ortografia",
"spanTypePunctuation": "Punteggiatura",
"spanTypeStyle": "Stile",
"spanTypeFluency": "Fluidità",
"spanTypeAccents": "Accenti",
"spanTypeCapitalization": "Capitalizzazione",
"spanTypeCorrection": "Correzione",
"spanFeedbackTitle": "Segnala problema di correzione",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "ja",
"@@last_modified": "2026-02-09 11:09:54.188816",
"@@last_modified": "2026-02-09 15:32:29.891676",
"about": "このアプリについて",
"@about": {
"type": "String",
@ -11804,5 +11804,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "文法",
"spanTypeWordChoice": "単語の選択",
"spanTypeSpelling": "スペル",
"spanTypePunctuation": "句読点",
"spanTypeStyle": "スタイル",
"spanTypeFluency": "流暢さ",
"spanTypeAccents": "アクセント",
"spanTypeCapitalization": "大文字化",
"spanTypeCorrection": "修正",
"spanFeedbackTitle": "修正の問題を報告する",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -2591,7 +2591,7 @@
"playWithAI": "ამ დროისთვის ითამაშეთ AI-თან",
"courseStartDesc": "Pangea Bot მზადაა ნებისმიერ დროს გასასვლელად!\n\n...მაგრამ სწავლა უკეთესია მეგობრებთან ერთად!",
"@@locale": "ka",
"@@last_modified": "2026-02-09 11:10:02.292014",
"@@last_modified": "2026-02-09 15:32:39.485983",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -12044,5 +12044,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "გრამატიკა",
"spanTypeWordChoice": "სიტყვების არჩევანი",
"spanTypeSpelling": "წერა",
"spanTypePunctuation": "ნიშნის გამოყენება",
"spanTypeStyle": "სტილი",
"spanTypeFluency": "მიმდინარე",
"spanTypeAccents": "აქცენტები",
"spanTypeCapitalization": "დიდი ასოები",
"spanTypeCorrection": "კორექტირება",
"spanFeedbackTitle": "შეტყობინება კორექტირების პრობლემაზე",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:08:39.290513",
"@@last_modified": "2026-02-09 15:30:29.458374",
"about": "소개",
"@about": {
"type": "String",
@ -11122,5 +11122,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "문법",
"spanTypeWordChoice": "단어 선택",
"spanTypeSpelling": "철자",
"spanTypePunctuation": "구두점",
"spanTypeStyle": "스타일",
"spanTypeFluency": "유창성",
"spanTypeAccents": "억양",
"spanTypeCapitalization": "대문자 사용",
"spanTypeCorrection": "수정",
"spanFeedbackTitle": "수정 문제 보고",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -3858,7 +3858,7 @@
"playWithAI": "Žaiskite su dirbtiniu intelektu dabar",
"courseStartDesc": "Pangea botas pasiruošęs bet kada pradėti!\n\n...bet mokymasis yra geresnis su draugais!",
"@@locale": "lt",
"@@last_modified": "2026-02-09 11:09:38.541694",
"@@last_modified": "2026-02-09 15:32:01.402371",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -11819,5 +11819,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Žodžių pasirinkimas",
"spanTypeSpelling": "Rašyba",
"spanTypePunctuation": "Skyryba",
"spanTypeStyle": "Stilius",
"spanTypeFluency": "Sklandumas",
"spanTypeAccents": "Akcentai",
"spanTypeCapitalization": "Didžiųjų raidžių naudojimas",
"spanTypeCorrection": "Korekcija",
"spanFeedbackTitle": "Pranešti apie korekcijos problemą",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -4480,7 +4480,7 @@
"playWithAI": "Tagad spēlējiet ar AI",
"courseStartDesc": "Pangea bots ir gatavs jebkurā laikā!\n\n...bet mācīties ir labāk ar draugiem!",
"@@locale": "lv",
"@@last_modified": "2026-02-09 11:09:31.557778",
"@@last_modified": "2026-02-09 15:31:50.771177",
"analyticsInactiveTitle": "Pieprasījumi neaktīviem lietotājiem nevar tikt nosūtīti",
"analyticsInactiveDesc": "Neaktīvi lietotāji, kuri nav pieteikušies kopš šīs funkcijas ieviešanas, neredzēs jūsu pieprasījumu.\n\nPieprasījuma poga parādīsies, kad viņi atgriezīsies. Jūs varat atkārtoti nosūtīt pieprasījumu vēlāk, noklikšķinot uz pieprasījuma pogas viņu vārdā, kad tā būs pieejama.",
"accessRequestedTitle": "Pieprasījums piekļūt analītikai",
@ -11000,5 +11000,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Vārdu izvēle",
"spanTypeSpelling": "Ortogrāfija",
"spanTypePunctuation": "Interpunkcija",
"spanTypeStyle": "Stils",
"spanTypeFluency": "Plūdums",
"spanTypeAccents": "Akcenti",
"spanTypeCapitalization": "Lielie burti",
"spanTypeCorrection": "Korekcija",
"spanFeedbackTitle": "Ziņot par korekcijas problēmu",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:09:21.418237",
"@@last_modified": "2026-02-09 15:31:32.332245",
"about": "Om",
"@about": {
"type": "String",
@ -12107,5 +12107,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Grammatikk",
"spanTypeWordChoice": "Ordvalg",
"spanTypeSpelling": "Staving",
"spanTypePunctuation": "Tegnsetting",
"spanTypeStyle": "Stil",
"spanTypeFluency": "Flyt",
"spanTypeAccents": "Aksenter",
"spanTypeCapitalization": "Store bokstaver",
"spanTypeCorrection": "Korrigering",
"spanFeedbackTitle": "Rapporter korrigeringsproblem",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:09:42.630335",
"@@last_modified": "2026-02-09 15:32:09.504392",
"about": "Over ons",
"@about": {
"type": "String",
@ -11014,5 +11014,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Grammatica",
"spanTypeWordChoice": "Woordkeuze",
"spanTypeSpelling": "Spelling",
"spanTypePunctuation": "Interpunctie",
"spanTypeStyle": "Stijl",
"spanTypeFluency": "Vloeiendheid",
"spanTypeAccents": "Accenten",
"spanTypeCapitalization": "Hoofdletters",
"spanTypeCorrection": "Correctie",
"spanFeedbackTitle": "Rapporteer correctiefout",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "pl",
"@@last_modified": "2026-02-09 11:09:49.851439",
"@@last_modified": "2026-02-09 15:32:22.411163",
"about": "O aplikacji",
"@about": {
"type": "String",
@ -11012,5 +11012,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatyka",
"spanTypeWordChoice": "Wybór słów",
"spanTypeSpelling": "Ortografia",
"spanTypePunctuation": "Interpunkcja",
"spanTypeStyle": "Styl",
"spanTypeFluency": "Płynność",
"spanTypeAccents": "Akcenty",
"spanTypeCapitalization": "Kapitalizacja",
"spanTypeCorrection": "Korekta",
"spanFeedbackTitle": "Zgłoś problem z korektą",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:09:03.927804",
"@@last_modified": "2026-02-09 15:31:11.061688",
"copiedToClipboard": "Copiada para a área de transferência",
"@copiedToClipboard": {
"type": "String",
@ -12114,5 +12114,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramática",
"spanTypeWordChoice": "Escolha de Palavras",
"spanTypeSpelling": "Ortografia",
"spanTypePunctuation": "Pontuação",
"spanTypeStyle": "Estilo",
"spanTypeFluency": "Fluência",
"spanTypeAccents": "Acentos",
"spanTypeCapitalization": "Capitalização",
"spanTypeCorrection": "Correção",
"spanFeedbackTitle": "Relatar problema de correção",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:08:59.417144",
"@@last_modified": "2026-02-09 15:31:06.403516",
"about": "Sobre",
"@about": {
"type": "String",
@ -11372,5 +11372,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramática",
"spanTypeWordChoice": "Escolha de Palavras",
"spanTypeSpelling": "Ortografia",
"spanTypePunctuation": "Pontuação",
"spanTypeStyle": "Estilo",
"spanTypeFluency": "Fluência",
"spanTypeAccents": "Acentos",
"spanTypeCapitalization": "Capitalização",
"spanTypeCorrection": "Correção",
"spanFeedbackTitle": "Relatar problema de correção",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -3328,7 +3328,7 @@
"selectAll": "Selecionar tudo",
"deselectAll": "Desmarcar tudo",
"@@locale": "pt_PT",
"@@last_modified": "2026-02-09 11:09:27.266695",
"@@last_modified": "2026-02-09 15:31:42.773873",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -12043,5 +12043,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramática",
"spanTypeWordChoice": "Escolha de Palavras",
"spanTypeSpelling": "Ortografia",
"spanTypePunctuation": "Pontuação",
"spanTypeStyle": "Estilo",
"spanTypeFluency": "Fluência",
"spanTypeAccents": "Acentos",
"spanTypeCapitalization": "Capitalização",
"spanTypeCorrection": "Correção",
"spanFeedbackTitle": "Relatar problema de correção",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:08:50.792922",
"@@last_modified": "2026-02-09 15:30:49.927369",
"about": "Despre",
"@about": {
"type": "String",
@ -11749,5 +11749,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatica",
"spanTypeWordChoice": "Alegerea cuvintelor",
"spanTypeSpelling": "Ortografie",
"spanTypePunctuation": "Punctuație",
"spanTypeStyle": "Stil",
"spanTypeFluency": "Fluență",
"spanTypeAccents": "Accente",
"spanTypeCapitalization": "Capitalizare",
"spanTypeCorrection": "Corectare",
"spanFeedbackTitle": "Raportează problema de corectare",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "ru",
"@@last_modified": "2026-02-09 11:09:59.118469",
"@@last_modified": "2026-02-09 15:32:37.241817",
"about": "О проекте",
"@about": {
"type": "String",
@ -11122,5 +11122,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Грамматика",
"spanTypeWordChoice": "Выбор слов",
"spanTypeSpelling": "Орфография",
"spanTypePunctuation": "Пунктуация",
"spanTypeStyle": "Стиль",
"spanTypeFluency": "Связность",
"spanTypeAccents": "Акценты",
"spanTypeCapitalization": "Капитализация",
"spanTypeCorrection": "Коррекция",
"spanFeedbackTitle": "Сообщить о проблеме с коррекцией",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "sk",
"@@last_modified": "2026-02-09 11:08:52.351401",
"@@last_modified": "2026-02-09 15:30:52.656999",
"about": "O aplikácii",
"@about": {
"type": "String",
@ -12098,5 +12098,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Výber slov",
"spanTypeSpelling": "Pravopis",
"spanTypePunctuation": "Interpunkcia",
"spanTypeStyle": "Štýl",
"spanTypeFluency": "Plynulosť",
"spanTypeAccents": "Prízvuky",
"spanTypeCapitalization": "Veľké písmená",
"spanTypeCorrection": "Oprava",
"spanFeedbackTitle": "Nahlásiť problém s opravou",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -2461,7 +2461,7 @@
"playWithAI": "Za zdaj igrajte z AI-jem",
"courseStartDesc": "Pangea Bot je pripravljen kadarkoli!\n\n...ampak je bolje učiti se s prijatelji!",
"@@locale": "sl",
"@@last_modified": "2026-02-09 11:09:14.553223",
"@@last_modified": "2026-02-09 15:31:19.119268",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -12095,5 +12095,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Izbira besed",
"spanTypeSpelling": "Pravopis",
"spanTypePunctuation": "Interpunkcija",
"spanTypeStyle": "Slog",
"spanTypeFluency": "Sposobnost",
"spanTypeAccents": "Naglaski",
"spanTypeCapitalization": "Velike začetnice",
"spanTypeCorrection": "Popravek",
"spanFeedbackTitle": "Poročilo o težavi s popravkom",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:10:03.632765",
"@@last_modified": "2026-02-09 15:32:41.761528",
"about": "О програму",
"@about": {
"type": "String",
@ -12116,5 +12116,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Gramatika",
"spanTypeWordChoice": "Izbor reči",
"spanTypeSpelling": "Pravopis",
"spanTypePunctuation": "Interpunkcija",
"spanTypeStyle": "Stil",
"spanTypeFluency": "Tečnost",
"spanTypeAccents": "Akcenti",
"spanTypeCapitalization": "Velika slova",
"spanTypeCorrection": "Ispravka",
"spanFeedbackTitle": "Prijavi problem sa ispravkom",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:09:51.437329",
"@@last_modified": "2026-02-09 15:32:24.746342",
"about": "Om",
"@about": {
"type": "String",
@ -11492,5 +11492,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Grammatik",
"spanTypeWordChoice": "Orval",
"spanTypeSpelling": "Stavning",
"spanTypePunctuation": "Interpunktion",
"spanTypeStyle": "Stil",
"spanTypeFluency": "Flyt",
"spanTypeAccents": "Accenter",
"spanTypeCapitalization": "Versalisering",
"spanTypeCorrection": "Korrigering",
"spanFeedbackTitle": "Rapportera korrigeringsproblem",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:09:41.597668",
"@@last_modified": "2026-02-09 15:32:06.841503",
"acceptedTheInvitation": "👍 {username} அழைப்பை ஏற்றுக்கொண்டது",
"@acceptedTheInvitation": {
"type": "String",
@ -11238,5 +11238,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "இயல்பியல்",
"spanTypeWordChoice": "சொல் தேர்வு",
"spanTypeSpelling": "எழுத்துப்பிழை",
"spanTypePunctuation": "இணைச்சொல்",
"spanTypeStyle": "அழகு",
"spanTypeFluency": "தரிசனம்",
"spanTypeAccents": "உயர்த்தல்கள்",
"spanTypeCapitalization": "முதலெழுத்து",
"spanTypeCorrection": "திருத்தம்",
"spanFeedbackTitle": "திருத்தப் பிரச்சினையைப் புகாரளிக்கவும்",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1917,7 +1917,7 @@
"playWithAI": "ఇప్పుడే AI తో ఆడండి",
"courseStartDesc": "పాంజియా బాట్ ఎప్పుడైనా సిద్ధంగా ఉంటుంది!\n\n...కానీ స్నేహితులతో నేర్చుకోవడం మెరుగైనది!",
"@@locale": "te",
"@@last_modified": "2026-02-09 11:09:36.591690",
"@@last_modified": "2026-02-09 15:31:58.761810",
"@setCustomPermissionLevel": {
"type": "String",
"placeholders": {}
@ -12103,5 +12103,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "వ్యాకరణం",
"spanTypeWordChoice": "పద ఎంపిక",
"spanTypeSpelling": "అక్షరరూపం",
"spanTypePunctuation": "పంక్తి చిహ్నాలు",
"spanTypeStyle": "శైలి",
"spanTypeFluency": "ప్రవాహం",
"spanTypeAccents": "ఉచ్చారణలు",
"spanTypeCapitalization": "పెద్ద అక్షరాలు",
"spanTypeCorrection": "సరిదిద్దు",
"spanFeedbackTitle": "సరిదిద్దు సమస్యను నివేదించండి",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -4453,7 +4453,7 @@
"playWithAI": "เล่นกับ AI ชั่วคราว",
"courseStartDesc": "Pangea Bot พร้อมที่จะเริ่มต้นได้ทุกเมื่อ!\n\n...แต่การเรียนรู้ดีกว่ากับเพื่อน!",
"@@locale": "th",
"@@last_modified": "2026-02-09 11:09:25.941768",
"@@last_modified": "2026-02-09 15:31:39.902769",
"@alwaysUse24HourFormat": {
"type": "String",
"placeholders": {}
@ -12072,5 +12072,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "ไวยากรณ์",
"spanTypeWordChoice": "การเลือกคำ",
"spanTypeSpelling": "การสะกด",
"spanTypePunctuation": "เครื่องหมายวรรคตอน",
"spanTypeStyle": "สไตล์",
"spanTypeFluency": "ความคล่องแคล่ว",
"spanTypeAccents": "สำเนียง",
"spanTypeCapitalization": "การใช้ตัวพิมพ์ใหญ่",
"spanTypeCorrection": "การแก้ไข",
"spanFeedbackTitle": "รายงานปัญหาการแก้ไข",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "tr",
"@@last_modified": "2026-02-09 11:09:34.545567",
"@@last_modified": "2026-02-09 15:31:56.191986",
"about": "Hakkında",
"@about": {
"type": "String",
@ -11236,5 +11236,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Dilbilgisi",
"spanTypeWordChoice": "Kelime Seçimi",
"spanTypeSpelling": "Yazım",
"spanTypePunctuation": "Noktalama",
"spanTypeStyle": "Üslup",
"spanTypeFluency": "Akıcılık",
"spanTypeAccents": "Aksanlar",
"spanTypeCapitalization": "Büyük Harf Kullanımı",
"spanTypeCorrection": "Düzeltme",
"spanFeedbackTitle": "Düzeltme sorununu bildir",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "uk",
"@@last_modified": "2026-02-09 11:09:18.460030",
"@@last_modified": "2026-02-09 15:31:25.978266",
"about": "Про застосунок",
"@about": {
"type": "String",
@ -11008,5 +11008,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Граматика",
"spanTypeWordChoice": "Вибір слів",
"spanTypeSpelling": "Орфографія",
"spanTypePunctuation": "Пунктуація",
"spanTypeStyle": "Стиль",
"spanTypeFluency": "Вільність",
"spanTypeAccents": "Акценти",
"spanTypeCapitalization": "Великі літери",
"spanTypeCorrection": "Виправлення",
"spanFeedbackTitle": "Повідомити про проблему з виправленням",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:09:39.961686",
"@@last_modified": "2026-02-09 15:32:04.137171",
"about": "Giới thiệu",
"@about": {
"type": "String",
@ -6592,5 +6592,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "Ngữ pháp",
"spanTypeWordChoice": "Lựa chọn từ",
"spanTypeSpelling": "Chính tả",
"spanTypePunctuation": "Dấu câu",
"spanTypeStyle": "Phong cách",
"spanTypeFluency": "Lưu loát",
"spanTypeAccents": "Giọng điệu",
"spanTypeCapitalization": "Chữ hoa",
"spanTypeCorrection": "Sửa lỗi",
"spanFeedbackTitle": "Báo cáo vấn đề sửa lỗi",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1853,7 +1853,7 @@
"selectAll": "全選",
"deselectAll": "取消全選",
"@@locale": "yue",
"@@last_modified": "2026-02-09 11:09:15.805629",
"@@last_modified": "2026-02-09 15:31:21.578854",
"@ignoreUser": {
"type": "String",
"placeholders": {}
@ -12105,5 +12105,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "文法",
"spanTypeWordChoice": "用詞選擇",
"spanTypeSpelling": "拼寫",
"spanTypePunctuation": "標點符號",
"spanTypeStyle": "風格",
"spanTypeFluency": "流暢度",
"spanTypeAccents": "口音",
"spanTypeCapitalization": "大寫",
"spanTypeCorrection": "更正",
"spanFeedbackTitle": "報告更正問題",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,6 +1,6 @@
{
"@@locale": "zh",
"@@last_modified": "2026-02-09 11:09:45.374826",
"@@last_modified": "2026-02-09 15:32:14.219030",
"about": "关于",
"@about": {
"type": "String",
@ -11005,5 +11005,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "语法",
"spanTypeWordChoice": "用词选择",
"spanTypeSpelling": "拼写",
"spanTypePunctuation": "标点",
"spanTypeStyle": "风格",
"spanTypeFluency": "流利度",
"spanTypeAccents": "口音",
"spanTypeCapitalization": "大写",
"spanTypeCorrection": "更正",
"spanFeedbackTitle": "报告更正问题",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-09 11:09:28.599644",
"@@last_modified": "2026-02-09 15:31:45.022584",
"about": "關於",
"@about": {
"type": "String",
@ -11012,5 +11012,55 @@
"@noAddressDescription": {
"type": "String",
"placeholders": {}
},
"spanTypeGrammar": "文法",
"spanTypeWordChoice": "用詞選擇",
"spanTypeSpelling": "拼寫",
"spanTypePunctuation": "標點符號",
"spanTypeStyle": "風格",
"spanTypeFluency": "流暢度",
"spanTypeAccents": "重音",
"spanTypeCapitalization": "大寫",
"spanTypeCorrection": "修正",
"spanFeedbackTitle": "報告修正問題",
"@spanTypeGrammar": {
"type": "String",
"placeholders": {}
},
"@spanTypeWordChoice": {
"type": "String",
"placeholders": {}
},
"@spanTypeSpelling": {
"type": "String",
"placeholders": {}
},
"@spanTypePunctuation": {
"type": "String",
"placeholders": {}
},
"@spanTypeStyle": {
"type": "String",
"placeholders": {}
},
"@spanTypeFluency": {
"type": "String",
"placeholders": {}
},
"@spanTypeAccents": {
"type": "String",
"placeholders": {}
},
"@spanTypeCapitalization": {
"type": "String",
"placeholders": {}
},
"@spanTypeCorrection": {
"type": "String",
"placeholders": {}
},
"@spanFeedbackTitle": {
"type": "String",
"placeholders": {}
}
}

View file

@ -2240,18 +2240,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;
@ -60,6 +61,7 @@ class InputBar extends StatelessWidget {
// #Pangea
required this.choreographer,
required this.showNextMatch,
required this.onFeedbackSubmitted,
// Pangea#
super.key,
});
@ -426,6 +428,7 @@ class InputBar extends StatelessWidget {
choreographer,
context,
showNextMatch,
onFeedbackSubmitted,
);
// rebuild the text field to highlight the newly selected match
@ -468,9 +471,9 @@ class InputBar extends StatelessWidget {
focusNode: focusNode,
textEditingController: controller,
optionsBuilder: getSuggestions,
fieldViewBuilder: (context, __, focusNode, _) => ValueListenableBuilder(
valueListenable: choreographer.itController.open,
builder: (context, _, __) {
fieldViewBuilder: (context, __, focusNode, _) => ListenableBuilder(
listenable: choreographer,
builder: (context, _) {
return TextField(
controller: controller,
focusNode: focusNode,

View file

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

View file

@ -284,10 +284,22 @@ class Choreographer extends ChangeNotifier {
}
_stopLoading();
if (!igcController.openMatches
.any((match) => match.updatedMatch.isITStart)) {
igcController.fetchAllSpanDetails().catchError((e) => clearMatches(e));
}
/// Re-runs IGC with user feedback and updates the UI.
Future<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)) {
@ -304,29 +304,88 @@ class IgcController {
) async {
if (text.isEmpty) return clear();
if (_isFetching) return;
final request = _igcRequest(text, prevMessages);
await _fetchIGC(request);
}
/// Re-runs IGC with user feedback about the previous response.
/// Returns true if feedback was submitted, false if no previous data.
Future<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'),
TimeoutException(
request.feedback.isNotEmpty
? 'IGC feedback request timed out'
: 'IGC request timed out',
),
);
},
);
if (res.isError) {
debugPrint('IgcRepo.get error: ${res.asError}');
onError(res.asError!);
clear();
return;
} else {
onFetch();
return false;
}
if (!_isFetching) return;
debugPrint('IgcRepo.get success, calling onFetch');
onFetch();
if (!_isFetching) return false;
_lastResponse = res.result!;
_currentText = res.result!.originalInput;
for (final match in res.result!.matches) {
final matchState = PangeaMatchState(
@ -342,38 +401,6 @@ class IgcController {
}
_filterPreviouslyIgnoredMatches();
_isFetching = false;
}
Future<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

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

View file

@ -1,36 +1,67 @@
import 'dart:convert';
import 'package:fluffychat/pangea/choreographer/igc/igc_response_model.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/common/models/base_request_model.dart';
import 'package:fluffychat/pangea/common/models/llm_feedback_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
class IGCRequestModel {
class IGCRequestModel with BaseRequestModel {
final String fullText;
final String userL1;
final String userL2;
final bool enableIT;
final bool enableIGC;
final String userId;
final List<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) {
@ -43,9 +74,14 @@ class IGCRequestModel {
userL1 == other.userL1 &&
userL2 == other.userL2 &&
enableIT == other.enableIT &&
userId == other.userId;
userId == other.userId &&
_feedbackHash == other._feedbackHash;
}
/// Hash of feedback content for cache differentiation
int get _feedbackHash =>
feedback.isEmpty ? 0 : Object.hashAll(feedback.map((f) => f.feedback));
@override
int get hashCode => Object.hash(
fullText.trim(),
@ -54,6 +90,7 @@ class IGCRequestModel {
enableIT,
enableIGC,
userId,
_feedbackHash,
);
}

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,35 +22,39 @@ class IGCResponseModel {
required this.matches,
required this.userL1,
required this.userL2,
required this.enableIT,
required this.enableIGC,
this.enableIT = true,
this.enableIGC = true,
});
factory IGCResponseModel.fromJson(Map<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>);
},
(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';
@ -12,10 +12,31 @@ class PangeaMatch {
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,
);
@ -31,8 +52,7 @@ class PangeaMatch {
bool get isITStart =>
match.rule?.id == MatchRuleIdModel.interactiveTranslation ||
[SpanDataTypeEnum.itStart, SpanDataTypeEnum.itStart.name]
.contains(match.type.typeName);
match.type == ReplacementTypeEnum.itStart;
bool get _needsTranslation => match.rule?.id != null
? [

View file

@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/widgets/matrix.dart';
enum ReplacementTypeEnum {
// Client-specific types
definition,
practice,
itStart,
// === GRAMMAR CATEGORIES (granular for teacher analytics) ===
verbConjugation, // Wrong form for person/number/tense
verbTense, // Using wrong tense for context
verbMood, // Indicative vs subjunctive vs imperative
subjectVerbAgreement, // "he go" -> "he goes"
genderAgreement, // "la libro" -> "el libro"
numberAgreement, // Singular/plural mismatches
caseError, // For languages with grammatical cases
article, // Missing, wrong, or unnecessary articles
preposition, // Wrong preposition choice
pronoun, // Wrong form, reference, or missing
wordOrder, // Syntax/structure issues
negation, // Double negatives, wrong placement
questionFormation, // Incorrect question structure
relativeClause, // who/which/that errors
connector, // Conjunction usage (but/and/however)
possessive, // Apostrophe usage, possessive pronouns
comparative, // Comparative/superlative forms
passiveVoice, // Passive construction errors
conditional, // If clauses, would/could
infinitiveGerund, // Infinitive vs gerund usage
modal, // Modal verb usage (can/could/should/must)
// === SURFACE-LEVEL CORRECTIONS (auto-applied) ===
punct,
diacritics,
spell,
cap,
// === WORD CHOICE CATEGORIES (granular for teacher analytics) ===
falseCognate, // False friends (e.g., "embarazada" "embarrassed")
l1Interference, // L1 patterns bleeding through incorrectly
collocation, // Wrong word pairing (e.g., "do a mistake" "make a mistake")
semanticConfusion, // Similar meanings, wrong choice (e.g., "see/watch/look")
// === HIGHER-LEVEL SUGGESTIONS ===
transcription,
style,
fluency,
didYouMean,
translation,
other,
}
extension SpanDataTypeEnumExt on ReplacementTypeEnum {
/// Types that should be auto-applied without user interaction.
/// These are minor corrections like punctuation, spacing, accents, etc.
static const List<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(() {});
}
@ -145,12 +77,54 @@ 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,
@ -165,13 +139,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(),
@ -184,9 +158,7 @@ class SpanCardState extends State<SpanCard> {
),
const SizedBox(),
_SpanCardFeedback(
_selectedChoice != null,
_fetchFeedback,
_feedbackState,
widget.match.updatedMatch.match,
),
],
),
@ -206,48 +178,32 @@ 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, style: BotStyle.text(context)),
};
},
),
span == null || span!.selectedChoice == null
? defaultContent
: Text(
span!.selectedChoice!.feedbackToDisplay(context),
style: BotStyle.text(context),
),
],
);
}

View file

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

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,9 @@ class SpanData {
.toList(),
offset: json['offset'] as int,
length: json['length'] as int,
fullText:
json['sentence'] ?? json['full_text'] ?? json['fullText'] as String,
type: SpanDataType.fromJson(json['type'] as Map<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 +96,7 @@ class SpanData {
'offset': offset,
'length': length,
'full_text': fullText,
'type': type.toJson(),
'type': type.name,
};
if (message != null) {
@ -135,7 +155,17 @@ class SpanData {
String get errorSpan =>
fullText.characters.skip(offset).take(length).toString();
/// Whether this span is a minor correction that should be auto-applied.
/// Returns true if:
/// 1. The type is explicitly marked as auto-apply (e.g., punct, spell, cap, diacritics), OR
/// 2. For backwards compatibility with old data that lacks new types:
/// the type is NOT auto-apply AND the normalized strings match.
bool isNormalizationError() {
// New data with explicit auto-apply types
if (type.isAutoApply) {
return true;
}
final correctChoice = choices
?.firstWhereOrNull(
(c) => c.isBestCorrection,
@ -227,8 +257,8 @@ class SpanChoice {
? SpanChoiceTypeEnum.values.firstWhereOrNull(
(element) => element.name == json['type'],
) ??
SpanChoiceTypeEnum.bestCorrection
: SpanChoiceTypeEnum.bestCorrection,
SpanChoiceTypeEnum.suggestion
: SpanChoiceTypeEnum.suggestion,
feedback: json['feedback'],
selected: json['selected'] ?? false,
timestamp:
@ -242,18 +272,15 @@ class SpanChoice {
'type': type.name,
};
if (selected) {
data['selected'] = selected;
// V2 format: use selected_at instead of separate selected + timestamp
if (selected && timestamp != null) {
data['selected_at'] = timestamp!.toIso8601String();
}
if (feedback != null) {
data['feedback'] = feedback;
}
if (timestamp != null) {
data['timestamp'] = timestamp!.toIso8601String();
}
return data;
}
@ -264,7 +291,7 @@ class SpanChoice {
return feedback!;
}
bool get isBestCorrection => type == SpanChoiceTypeEnum.bestCorrection;
bool get isBestCorrection => type.isSuggestion;
Color get color => type.color;
@ -318,39 +345,3 @@ class Rule {
return id.hashCode;
}
}
class SpanDataType {
final SpanDataTypeEnum typeName;
const SpanDataType({
required this.typeName,
});
factory SpanDataType.fromJson(Map<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,43 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/widgets/matrix.dart';
enum SpanDataTypeEnum {
definition,
practice,
correction,
itStart,
}
extension SpanDataTypeEnumExt on SpanDataTypeEnum {
String get name {
switch (this) {
case SpanDataTypeEnum.definition:
return "definition";
case SpanDataTypeEnum.practice:
return "practice";
case SpanDataTypeEnum.correction:
return "correction";
case SpanDataTypeEnum.itStart:
return "itStart";
}
}
String defaultPrompt(BuildContext context) {
switch (this) {
case SpanDataTypeEnum.definition:
return L10n.of(context).definitionDefaultPrompt;
case SpanDataTypeEnum.practice:
return L10n.of(context).practiceDefaultPrompt;
case SpanDataTypeEnum.correction:
return L10n.of(context).correctionDefaultPrompt;
case SpanDataTypeEnum.itStart:
return L10n.of(context).needsItMessage(
MatrixState.pangeaController.userController.userL2
?.getDisplayName(context) ??
L10n.of(context).targetLanguage,
);
}
}
}

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';
@ -34,19 +35,13 @@ class PangeaTextController extends TextEditingController {
);
Color _underlineColor(PangeaMatch match) {
// Automatic corrections use primary color
if (match.status == PangeaMatchStatusEnum.automatic) {
return const Color.fromARGB(187, 132, 96, 224);
return AppConfig.primaryColor.withAlpha(180);
}
switch (match.match.rule?.id ?? "unknown") {
case MatchRuleIdModel.interactiveTranslation:
return const Color.fromARGB(187, 132, 96, 224);
case MatchRuleIdModel.tokenNeedsTranslation:
case MatchRuleIdModel.tokenSpanNeedsTranslation:
return const Color.fromARGB(186, 255, 132, 0);
default:
return const Color.fromARGB(149, 255, 17, 0);
}
// Use type-based coloring
return match.match.type.underlineColor();
}
TextStyle _textStyle(

View file

@ -107,6 +107,8 @@ class ModelKey {
static const String currentText = "current";
static const String bestContinuance = "best_continuance";
static const String feedbackLang = "feedback_lang";
static const String feedback = "feedback";
static const String content = "content";
static const String transcription = "transcription";
static const String botTranscription = 'bot_transcription';

View file

@ -0,0 +1,41 @@
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/learning_settings/gender_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
/// Base request schema matching the backend's BaseRequestSchema.
/// Common fields for all LLM-based requests.
mixin BaseRequestModel {
/// User's native language code (L1)
String get userL1;
/// User's target language code (L2)
String get userL2;
/// User's CEFR proficiency level (defaults to "pre_a1")
String get userCefr;
/// Convert to JSON map with common fields
Map<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;
@ -22,13 +20,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

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

View file

@ -0,0 +1,246 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fluffychat/pangea/choreographer/igc/igc_response_model.dart';
void main() {
group('IGCResponseModel.fromJson', () {
test('passes originalInput to matches as fullText fallback', () {
final Map<String, dynamic> jsonData = {
'original_input': 'I want to know the United States',
'full_text_correction': null,
'matches': [
{
'match': {
'message': 'Grammar error',
'short_message': 'grammar',
'choices': [
{'value': 'learn about', 'type': 'bestCorrection'},
],
'offset': 10,
'length': 4,
// Note: no full_text in match - should use original_input
'type': 'grammar',
},
'status': 'open',
},
],
'user_l1': 'en',
'user_l2': 'es',
'enable_it': true,
'enable_igc': true,
};
final IGCResponseModel response = IGCResponseModel.fromJson(jsonData);
expect(response.matches.length, 1);
expect(
response.matches[0].match.fullText,
'I want to know the United States',
);
});
test('match full_text takes precedence over originalInput', () {
final Map<String, dynamic> jsonData = {
'original_input': 'Original input text',
'full_text_correction': null,
'matches': [
{
'match': {
'message': 'Grammar error',
'short_message': 'grammar',
'choices': [
{'value': 'correction', 'type': 'bestCorrection'},
],
'offset': 0,
'length': 5,
'full_text': 'Full text from span', // This should take precedence
'type': 'grammar',
},
'status': 'open',
},
],
'user_l1': 'en',
'user_l2': 'es',
'enable_it': true,
'enable_igc': true,
};
final IGCResponseModel response = IGCResponseModel.fromJson(jsonData);
expect(response.matches.length, 1);
expect(response.matches[0].match.fullText, 'Full text from span');
});
test('handles empty matches array', () {
final Map<String, dynamic> jsonData = {
'original_input': 'Clean text with no errors',
'full_text_correction': null,
'matches': <dynamic>[],
'user_l1': 'en',
'user_l2': 'es',
'enable_it': true,
'enable_igc': true,
};
final IGCResponseModel response = IGCResponseModel.fromJson(jsonData);
expect(response.matches.length, 0);
expect(response.originalInput, 'Clean text with no errors');
});
test('handles null matches', () {
final Map<String, dynamic> jsonData = {
'original_input': 'Text',
'full_text_correction': null,
'matches': null,
'user_l1': 'en',
'user_l2': 'es',
'enable_it': true,
'enable_igc': true,
};
final IGCResponseModel response = IGCResponseModel.fromJson(jsonData);
expect(response.matches.length, 0);
});
});
group('IGCResponseModel V2 format compatibility', () {
test('parses V2 response without enable_it and enable_igc', () {
// V2 response format from /choreo/grammar_v2 endpoint
final Map<String, dynamic> jsonData = {
'original_input': 'Me gusta el café',
'matches': [
{
// V2 format: flat SpanData, no "match" wrapper
'choices': [
{
'value': 'Me encanta',
'type': 'bestCorrection',
'feedback': 'Use "encantar" for expressing love',
},
],
'offset': 0,
'length': 8,
'type': 'vocabulary',
},
],
'user_l1': 'en',
'user_l2': 'es',
// Note: no enable_it, enable_igc in V2 response
};
final IGCResponseModel response = IGCResponseModel.fromJson(jsonData);
expect(response.originalInput, 'Me gusta el café');
expect(response.userL1, 'en');
expect(response.userL2, 'es');
// Should default to true when not present
expect(response.enableIT, true);
expect(response.enableIGC, true);
expect(response.matches.length, 1);
expect(response.matches[0].match.offset, 0);
expect(response.matches[0].match.length, 8);
});
test('parses V2 response with empty matches', () {
final Map<String, dynamic> jsonData = {
'original_input': 'Perfect sentence with no errors',
'matches': <dynamic>[],
'user_l1': 'en',
'user_l2': 'fr',
};
final IGCResponseModel response = IGCResponseModel.fromJson(jsonData);
expect(response.matches.length, 0);
expect(response.enableIT, true);
expect(response.enableIGC, true);
expect(response.fullTextCorrection, isNull);
});
test('parses V2 response with multiple matches', () {
final Map<String, dynamic> jsonData = {
'original_input': 'Yo soy ir a la tienda',
'matches': [
{
'choices': [
{
'value': 'voy',
'type': 'bestCorrection',
'feedback': 'Use conjugated form',
},
],
'offset': 7,
'length': 2,
'type': 'grammar',
},
{
'choices': [
{'value': 'Voy', 'type': 'bestCorrection'},
],
'offset': 0,
'length': 6,
'type': 'grammar',
},
],
'user_l1': 'en',
'user_l2': 'es',
};
final IGCResponseModel response = IGCResponseModel.fromJson(jsonData);
expect(response.matches.length, 2);
expect(response.matches[0].match.offset, 7);
expect(response.matches[1].match.offset, 0);
});
test('V1 format with explicit enable_it=false still works', () {
final Map<String, dynamic> jsonData = {
'original_input': 'Test text',
'full_text_correction': 'Corrected text',
'matches': <dynamic>[],
'user_l1': 'en',
'user_l2': 'es',
'enable_it': false,
'enable_igc': false,
};
final IGCResponseModel response = IGCResponseModel.fromJson(jsonData);
expect(response.enableIT, false);
expect(response.enableIGC, false);
expect(response.fullTextCorrection, 'Corrected text');
});
test('V2 response choice includes feedback field', () {
final Map<String, dynamic> jsonData = {
'original_input': 'Je suis alle',
'matches': [
{
'choices': [
{
'value': 'allé',
'type': 'bestCorrection',
'feedback': 'Add accent to past participle',
},
],
'offset': 8,
'length': 4,
'type': 'diacritics',
},
],
'user_l1': 'en',
'user_l2': 'fr',
};
final IGCResponseModel response = IGCResponseModel.fromJson(jsonData);
expect(response.matches.length, 1);
expect(
response.matches[0].match.bestChoice?.feedback,
'Add accent to past participle',
);
});
});
}

View file

@ -0,0 +1,179 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart';
void main() {
group('PangeaMatch.fromJson', () {
group('V1 format (wrapped with match key)', () {
test('parses match wrapper correctly', () {
final Map<String, dynamic> jsonData = {
'match': {
'message': 'Grammar error',
'short_message': 'grammar',
'choices': [
{'value': 'correction', 'type': 'bestCorrection'},
],
'offset': 10,
'length': 4,
'full_text': 'Some full text',
'type': 'grammar',
},
'status': 'open',
};
final PangeaMatch match = PangeaMatch.fromJson(jsonData);
expect(match.match.offset, 10);
expect(match.match.length, 4);
expect(match.match.fullText, 'Some full text');
expect(match.status, PangeaMatchStatusEnum.open);
});
test('uses parentFullText as fallback when no full_text in match', () {
final Map<String, dynamic> jsonData = {
'match': {
'message': 'Error',
'choices': [
{'value': 'fix', 'type': 'bestCorrection'},
],
'offset': 5,
'length': 3,
'type': 'grammar',
},
'status': 'open',
};
final PangeaMatch match = PangeaMatch.fromJson(
jsonData,
fullText: 'Parent original input',
);
expect(match.match.fullText, 'Parent original input');
});
test('parses status from V1 format', () {
final Map<String, dynamic> jsonData = {
'match': {
'message': 'Error',
'choices': [],
'offset': 0,
'length': 1,
'type': 'grammar',
},
'status': 'accepted',
};
final PangeaMatch match = PangeaMatch.fromJson(jsonData);
expect(match.status, PangeaMatchStatusEnum.accepted);
});
});
group('V2 format (flat SpanData)', () {
test('parses flat SpanData correctly', () {
final Map<String, dynamic> jsonData = {
'message': 'Grammar error',
'short_message': 'grammar',
'choices': [
{'value': 'correction', 'type': 'bestCorrection'},
],
'offset': 10,
'length': 4,
'type': 'grammar',
};
final PangeaMatch match = PangeaMatch.fromJson(jsonData);
expect(match.match.offset, 10);
expect(match.match.length, 4);
expect(match.match.message, 'Grammar error');
// V2 format always defaults to open status
expect(match.status, PangeaMatchStatusEnum.open);
});
test('uses parentFullText when provided', () {
final Map<String, dynamic> jsonData = {
'message': 'Error',
'choices': [
{'value': 'fix', 'type': 'bestCorrection'},
],
'offset': 5,
'length': 3,
'type': 'vocabulary',
};
final PangeaMatch match = PangeaMatch.fromJson(
jsonData,
fullText: 'The original input text',
);
expect(match.match.fullText, 'The original input text');
});
test('parses type as string in V2 format', () {
final Map<String, dynamic> jsonData = {
'message': 'Out of target',
'choices': [],
'offset': 0,
'length': 5,
'type': 'itStart', // String type in V2
};
final PangeaMatch match = PangeaMatch.fromJson(jsonData);
expect(match.isITStart, true);
});
test('handles V2 format with string type grammar', () {
final Map<String, dynamic> jsonData = {
'message': 'Tense error',
'choices': [
{'value': 'went', 'type': 'bestCorrection'},
],
'offset': 2,
'length': 4,
'type': 'grammar', // String type in V2
};
final PangeaMatch match = PangeaMatch.fromJson(jsonData);
expect(match.isGrammarMatch, true);
expect(match.isITStart, false);
});
});
group('backward compatibility', () {
test('V1 format with type as object still works', () {
final Map<String, dynamic> jsonData = {
'match': {
'message': 'Error',
'choices': [],
'offset': 0,
'length': 1,
'type': {'type_name': 'grammar'}, // Old object format
},
'status': 'open',
};
final PangeaMatch match = PangeaMatch.fromJson(jsonData);
expect(match.isGrammarMatch, true);
});
test('V2 format with type as string works', () {
final Map<String, dynamic> jsonData = {
'message': 'Error',
'choices': [],
'offset': 0,
'length': 1,
'type': 'grammar', // New string format
};
final PangeaMatch match = PangeaMatch.fromJson(jsonData);
expect(match.isGrammarMatch, true);
});
});
});
}

View file

@ -0,0 +1,178 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fluffychat/pangea/choreographer/igc/replacement_type_enum.dart';
import 'package:fluffychat/pangea/choreographer/igc/span_data_model.dart';
void main() {
test('SpanData.fromJson handles legacy correction type (maps to grammar)',
() {
final Map<String, dynamic> legacyJson = {
'message': null,
'short_message': null,
'choices': <dynamic>[],
'offset': 0,
'length': 4,
'full_text': 'Test',
'type': {
'type_name': 'correction',
},
};
expect(() => SpanData.fromJson(legacyJson), returnsNormally);
final SpanData span = SpanData.fromJson(legacyJson);
// 'correction' is mapped to 'grammar' for backward compatibility
expect(span.type, ReplacementTypeEnum.subjectVerbAgreement);
});
test('SpanData.fromJson handles legacy typeName object', () {
final Map<String, dynamic> legacyJson = {
'message': null,
'short_message': null,
'choices': <dynamic>[],
'offset': 0,
'length': 4,
'full_text': 'Test',
'type': {
'typeName': 'itStart',
},
};
expect(() => SpanData.fromJson(legacyJson), returnsNormally);
final SpanData span = SpanData.fromJson(legacyJson);
expect(span.type, ReplacementTypeEnum.itStart);
});
test('SpanData.fromJson handles did_you_mean string', () {
final Map<String, dynamic> jsonData = {
'message': null,
'short_message': null,
'choices': <dynamic>[],
'offset': 0,
'length': 4,
'full_text': 'Test',
'type': 'did_you_mean',
};
expect(() => SpanData.fromJson(jsonData), returnsNormally);
final SpanData span = SpanData.fromJson(jsonData);
expect(span.type, ReplacementTypeEnum.didYouMean);
});
test('SpanData.fromJson handles legacy vocabulary type (maps to wordChoice)',
() {
final Map<String, dynamic> legacyJson = {
'message': null,
'short_message': null,
'choices': <dynamic>[],
'offset': 0,
'length': 4,
'full_text': 'Test',
'type': 'vocabulary',
};
expect(() => SpanData.fromJson(legacyJson), returnsNormally);
final SpanData span = SpanData.fromJson(legacyJson);
expect(span.type, ReplacementTypeEnum.other);
});
test('SpanData.fromJson handles new grammar type directly', () {
final Map<String, dynamic> jsonData = {
'message': null,
'short_message': null,
'choices': <dynamic>[],
'offset': 0,
'length': 4,
'full_text': 'Test',
'type': 'grammar',
};
expect(() => SpanData.fromJson(jsonData), returnsNormally);
final SpanData span = SpanData.fromJson(jsonData);
expect(span.type, ReplacementTypeEnum.subjectVerbAgreement);
});
test('SpanData.fromJson handles translation type', () {
final Map<String, dynamic> jsonData = {
'message': null,
'short_message': null,
'choices': <dynamic>[],
'offset': 0,
'length': 4,
'full_text': 'Test',
'type': 'translation',
};
expect(() => SpanData.fromJson(jsonData), returnsNormally);
final SpanData span = SpanData.fromJson(jsonData);
expect(span.type, ReplacementTypeEnum.translation);
});
group('SpanData.fromJson fullText fallback', () {
test('uses full_text from JSON when present', () {
final Map<String, dynamic> jsonData = {
'message': null,
'short_message': null,
'choices': <dynamic>[],
'offset': 0,
'length': 4,
'full_text': 'Text from span',
'type': 'grammar',
};
final SpanData span = SpanData.fromJson(
jsonData,
parentFullText: 'Text from parent',
);
expect(span.fullText, 'Text from span');
});
test('uses parentFullText when full_text not in JSON', () {
final Map<String, dynamic> jsonData = {
'message': null,
'short_message': null,
'choices': <dynamic>[],
'offset': 0,
'length': 4,
// Note: no full_text field
'type': 'grammar',
};
final SpanData span = SpanData.fromJson(
jsonData,
parentFullText: 'Text from parent',
);
expect(span.fullText, 'Text from parent');
});
test('uses empty string when neither full_text nor parentFullText present',
() {
final Map<String, dynamic> jsonData = {
'message': null,
'short_message': null,
'choices': <dynamic>[],
'offset': 0,
'length': 4,
'type': 'grammar',
};
final SpanData span = SpanData.fromJson(jsonData);
expect(span.fullText, '');
});
test('prefers sentence over full_text (legacy field name)', () {
final Map<String, dynamic> jsonData = {
'message': null,
'short_message': null,
'choices': <dynamic>[],
'offset': 0,
'length': 4,
'sentence': 'Text from sentence field',
'full_text': 'Text from full_text field',
'type': 'grammar',
};
final SpanData span = SpanData.fromJson(jsonData);
expect(span.fullText, 'Text from sentence field');
});
});
}