From dec473d579024e20eec6a472f8efa04fc15d0c4f Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:55:18 -0500 Subject: [PATCH] Writing assistance (#5598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: wa working full stack * feat: writing assistance made anew * docs: migrate copilot docs to .github/instructions/ format - Create choreographer.instructions.md (applyTo: lib/pangea/choreographer/**) - Create events-and-tokens.instructions.md (applyTo: lib/pangea/events/**,lib/pangea/extensions/**) - Create modules.instructions.md (applyTo: lib/pangea/**) — full module map - Track copilot-instructions.md (remove .gitignore rule) - Add documentation reference table to copilot-instructions.md Content sourced from docs/copilot/ on writing-assistance branch. * docs: remove old docs/copilot/ (migrated to .github/instructions/) * docs: update choreographer + modules docs for writing-assistance audit - Mark IT (Interactive Translation) as deprecated throughout - Document new ReplacementTypeEnum taxonomy (grammar, surface, word-choice categories) - Add AssistanceStateEnum, AutocorrectPopup, feedback rerun flow - Mark SpanDataRepo/span_details as dead code - Mark SpanChoiceTypeEnum.bestCorrection/bestAnswer as deprecated - Add new files to modules listing (autocorrect_popup, start_igc_button, etc.) - Update API endpoints table with active/deprecated/dead status * formatting, replace deprecated withOpacity calls * fix linter issues from deprecated types * use better error color * move cloing of overlays into choreographer * reduce duplicate code on igc_controller, update UI on feedback * couple of adjustments * display prompt in span card by type * fix error in tests * translations * simplify span card feedback --------- Co-authored-by: ggurdin --- .github/copilot-instructions.md | 47 ++ .../choreographer.instructions.md | 161 +++++ .../events-and-tokens.instructions.md | 174 ++++++ .github/instructions/modules.instructions.md | 127 ++++ .gitignore | 4 +- lib/l10n/intl_ar.arb | 52 +- lib/l10n/intl_be.arb | 52 +- lib/l10n/intl_bn.arb | 52 +- lib/l10n/intl_bo.arb | 52 +- lib/l10n/intl_ca.arb | 52 +- lib/l10n/intl_cs.arb | 52 +- lib/l10n/intl_da.arb | 52 +- lib/l10n/intl_de.arb | 52 +- lib/l10n/intl_el.arb | 52 +- lib/l10n/intl_en.arb | 10 + lib/l10n/intl_eo.arb | 52 +- lib/l10n/intl_es.arb | 52 +- lib/l10n/intl_et.arb | 52 +- lib/l10n/intl_eu.arb | 52 +- lib/l10n/intl_fa.arb | 52 +- lib/l10n/intl_fi.arb | 52 +- lib/l10n/intl_fil.arb | 52 +- lib/l10n/intl_fr.arb | 52 +- lib/l10n/intl_ga.arb | 52 +- lib/l10n/intl_gl.arb | 52 +- lib/l10n/intl_he.arb | 52 +- lib/l10n/intl_hi.arb | 52 +- lib/l10n/intl_hr.arb | 52 +- lib/l10n/intl_hu.arb | 52 +- lib/l10n/intl_ia.arb | 52 +- lib/l10n/intl_id.arb | 52 +- lib/l10n/intl_ie.arb | 52 +- lib/l10n/intl_it.arb | 52 +- lib/l10n/intl_ja.arb | 52 +- lib/l10n/intl_ka.arb | 52 +- lib/l10n/intl_ko.arb | 52 +- lib/l10n/intl_lt.arb | 52 +- lib/l10n/intl_lv.arb | 52 +- lib/l10n/intl_nb.arb | 52 +- lib/l10n/intl_nl.arb | 52 +- lib/l10n/intl_pl.arb | 52 +- lib/l10n/intl_pt.arb | 52 +- lib/l10n/intl_pt_BR.arb | 52 +- lib/l10n/intl_pt_PT.arb | 52 +- lib/l10n/intl_ro.arb | 52 +- lib/l10n/intl_ru.arb | 52 +- lib/l10n/intl_sk.arb | 52 +- lib/l10n/intl_sl.arb | 52 +- lib/l10n/intl_sr.arb | 52 +- lib/l10n/intl_sv.arb | 52 +- lib/l10n/intl_ta.arb | 52 +- lib/l10n/intl_te.arb | 52 +- lib/l10n/intl_th.arb | 52 +- lib/l10n/intl_tr.arb | 52 +- lib/l10n/intl_uk.arb | 52 +- lib/l10n/intl_vi.arb | 52 +- lib/l10n/intl_yue.arb | 52 +- lib/l10n/intl_zh.arb | 52 +- lib/l10n/intl_zh_Hant.arb | 52 +- lib/pages/chat/chat.dart | 7 +- lib/pages/chat/input_bar.dart | 9 +- .../chat/widgets/pangea_chat_input_row.dart | 2 + lib/pangea/choreographer/choreographer.dart | 18 +- .../choreographer/edit_type_auto_apply.md | 97 +++ .../igc/SPAN_CARD_REDESIGN_FINALIZED.md | 269 ++++++++ .../igc/SPAN_CARD_REDESIGN_Q_AND_A.md | 574 ++++++++++++++++++ .../choreographer/igc/igc_controller.dart | 131 ++-- lib/pangea/choreographer/igc/igc_repo.dart | 5 + .../choreographer/igc/igc_request_model.dart | 69 ++- .../choreographer/igc/igc_response_model.dart | 28 +- .../choreographer/igc/pangea_match_model.dart | 32 +- .../igc/replacement_type_enum.dart | 314 ++++++++++ lib/pangea/choreographer/igc/span_card.dart | 184 +++--- .../igc/span_choice_type_enum.dart | 45 +- .../choreographer/igc/span_data_model.dart | 97 ++- .../igc/span_data_type_enum.dart | 43 -- .../text_editing/pangea_text_controller.dart | 17 +- lib/pangea/common/constants/model_keys.dart | 2 + .../common/models/base_request_model.dart | 41 ++ .../common/models/llm_feedback_model.dart | 25 + lib/pangea/common/network/requests.dart | 11 +- lib/pangea/common/network/urls.dart | 2 +- lib/pangea/common/utils/overlay.dart | 2 + test/pangea/igc_response_model_test.dart | 246 ++++++++ test/pangea/pangea_match_model_test.dart | 179 ++++++ test/pangea/span_data_model_test.dart | 178 ++++++ 86 files changed, 5514 insertions(+), 392 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/instructions/choreographer.instructions.md create mode 100644 .github/instructions/events-and-tokens.instructions.md create mode 100644 .github/instructions/modules.instructions.md create mode 100644 lib/pangea/choreographer/edit_type_auto_apply.md create mode 100644 lib/pangea/choreographer/igc/SPAN_CARD_REDESIGN_FINALIZED.md create mode 100644 lib/pangea/choreographer/igc/SPAN_CARD_REDESIGN_Q_AND_A.md create mode 100644 lib/pangea/choreographer/igc/replacement_type_enum.dart delete mode 100644 lib/pangea/choreographer/igc/span_data_type_enum.dart create mode 100644 lib/pangea/common/models/base_request_model.dart create mode 100644 lib/pangea/common/models/llm_feedback_model.dart create mode 100644 test/pangea/igc_response_model_test.dart create mode 100644 test/pangea/pangea_match_model_test.dart create mode 100644 test/pangea/span_data_model_test.dart diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..174cd77c6 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,47 @@ +You own the docs. Three sources of truth must agree: **docs**, **code**, and **prior user guidance**. When they don't, resolve it. Update `.github/instructions/` docs when your changes shift conventions. Fix obvious factual errors (paths, class names) without asking. Flag ambiguity when sources contradict. + +# client - Flutter/Dart Language Learning Chat App + +## Tech Stack +- **Framework**: Flutter (SDK ≥3.0), Dart +- **Base**: Fork of FluffyChat (package name `fluffychat`) +- **Protocol**: Matrix Client-Server API via `matrix` Dart SDK +- **Subscriptions**: RevenueCat +- **Backend**: 2-step-choreographer (FastAPI) via `PApiUrls` +- **Error Tracking**: Sentry +- **Routing**: GoRouter + +## Quick Reference + +### Project Structure +- `lib/pages/`, `lib/widgets/`, `lib/utils/`, `lib/config/` — FluffyChat base code +- `lib/pangea/` — **All Pangea language-learning code** (~30 feature modules) +- `pangea_packages/` — Shared isolate packages +- Pangea modifications in FluffyChat files marked with `// #Pangea` ... `// Pangea#` + +### Key Files +- **Entry point**: `lib/main.dart` +- **Root state**: `lib/widgets/matrix.dart` (`MatrixState`) +- **Pangea controller**: `lib/pangea/common/controllers/pangea_controller.dart` +- **Routes**: `lib/config/routes.dart` +- **API URLs**: `lib/pangea/common/network/urls.dart` +- **HTTP client**: `lib/pangea/common/network/requests.dart` +- **Environment**: `lib/pangea/common/config/environment.dart` (reads `.env` / `config.sample.json`) +- **Event types**: `lib/pangea/events/constants/pangea_event_types.dart` +- **Choreographer**: `lib/pangea/choreographer/choreographer.dart` + +### Conventions +- Package imports use `package:fluffychat/...` +- Feature modules follow pattern: `models/`, `repo/` (API calls), `widgets/`, `utils/`, `constants/` +- API repo files pair with request/response models (e.g., `igc_repo.dart` + `igc_request_model.dart` + `igc_response_model.dart`) +- Controllers extend `ChangeNotifier` or use `BaseController` (stream-based) + +## Documentation + +Detailed guides auto-load from `.github/instructions/` when editing matching files: + +| File | Applies To | Content | +|------|-----------|---------| +| `modules.instructions.md` | `lib/pangea/**` | Full map of ~30 feature modules | +| `choreographer.instructions.md` | `lib/pangea/choreographer/**` | Writing assistance flow (IGC, IT, text editing) | +| `events-and-tokens.instructions.md` | `lib/pangea/events/**,lib/pangea/extensions/**` | Custom Matrix events, token model, event wrappers | \ No newline at end of file diff --git a/.github/instructions/choreographer.instructions.md b/.github/instructions/choreographer.instructions.md new file mode 100644 index 000000000..3dcebbf2f --- /dev/null +++ b/.github/instructions/choreographer.instructions.md @@ -0,0 +1,161 @@ +--- +applyTo: "lib/pangea/choreographer/**" +--- + +# Choreographer — Writing Assistance Flow + +The choreographer is the client-side orchestrator for real-time writing assistance. It coordinates user text input, API calls to `/grammar_v2`, match display, and the creation of choreo records saved with sent messages. + +> **⚠️ IT (Interactive Translation) is deprecated.** The `it/` directory, `ITController`, and `/it_initialstep` endpoint are still wired into the choreographer but are being phased out. IT will become just another match type returned by IGC. Do not add new IT functionality. + +## Architecture + +``` +Choreographer (ChangeNotifier) +├── PangeaTextController ← Extended TextEditingController (tracks edit types) +├── IgcController ← Grammar check matches (primary flow) +├── ITController ← ⚠️ DEPRECATED — Translation step-by-step flow +├── ChoreographerErrorController ← Error state + backoff +└── ChoreographerStateExtension ← AssistanceStateEnum derivation +``` + +### Key files + +| File | Purpose | +|---|---| +| `choreographer.dart` | Main orchestrator (ChangeNotifier) | +| `choreographer_state_extension.dart` | Derives `AssistanceStateEnum` from current state | +| `assistance_state_enum.dart` | UI states: noSub, noMessage, notFetched, fetching, fetched, complete, error | +| `choreo_mode_enum.dart` | `igc` (active) or `it` (⚠️ deprecated) | +| `choreo_record_model.dart` | Record of edits saved with message | +| `igc/igc_controller.dart` | IGC state management (437 lines) | +| `igc/replacement_type_enum.dart` | Granular match type taxonomy (grammar, surface, word-choice, etc.) | +| `igc/autocorrect_popup.dart` | Undo popup for auto-applied corrections | +| `text_editing/pangea_text_controller.dart` | Text controller with edit type tracking | +| `it/it_controller.dart` | ⚠️ DEPRECATED — IT state | + +## Flow + +### 1. User types → debounce → IGC request + +1. User types in chat input. `PangeaTextController` fires `_onChange`. +2. After debounce (`ChoreoConstants.msBeforeIGCStart`), `requestWritingAssistance()` is called. +3. `IgcController.getIGCTextData()` calls `/grammar_v2` via `IgcRepo`. +4. Response contains a list of `SpanData` (matches) — grammar errors, out-of-target markers, normalization fixes. +5. Auto-apply matches (punct, diacritics, spell, cap) are accepted automatically via `acceptNormalizationMatches()`. Grammar/word-choice matches become `openMatches`. + +### 2. Matches displayed → Span cards + +1. Open matches render as colored underlines in the text field (colors set by `ReplacementTypeEnum.underlineColor()`). +2. Tapping a match opens a **span card** overlay (`span_card.dart`) showing the error category (`ReplacementTypeEnum.displayName()`), choices, and the error message. +3. Auto-applied corrections show an `AutocorrectPopup` with undo capability. + +### 3. User resolves matches + +Each match goes through `PangeaMatchState` with status transitions: + +- `open` → `accepted` (user chose a replacement) +- `open` → `ignored` (user dismissed) +- `open` → `automatic` (auto-apply correction) +- Any → `undo` (user reverted) + +When a match is accepted/ignored, the `IgcController` fires `matchUpdateStream`. The `Choreographer` listens and: + +- Updates the text via `textController.setSystemText()` +- Records the step in `ChoreoRecordModel` + +### 4. Feedback rerun + +If the user is unsatisfied with results, `rerunWithFeedback(feedbackText)` re-calls IGC with user feedback and the previous request/response context (`_lastRequest`, `_lastResponse`). + +### 5. Sending + +On send, `Choreographer.getMessageContent()`: + +1. Calls `/tokenize` to get `PangeaToken` data for the final text (with exponential backoff on errors). +2. Builds `PangeaMessageContentModel` containing: + - The final message text + - `ChoreoRecordModel` (full editing history) + - `PangeaRepresentation` for original written text (if IT was used) + - `PangeaMessageTokens` (token/lemma/morph data) + +## AssistanceStateEnum + +Derived in `choreographer_state_extension.dart`. Drives the send-button color and UI hints: + +| State | Meaning | +|---|---| +| `noSub` | User has no active subscription | +| `noMessage` | Text field is empty | +| `notFetched` | Text entered but IGC hasn't run yet | +| `fetching` | IGC request in flight | +| `fetched` | Matches present — user needs to resolve them | +| `complete` | All matches resolved, ready to send | +| `error` | IGC error (backoff active) | + +## ReplacementTypeEnum — Match Type Taxonomy + +Defined in `igc/replacement_type_enum.dart`. Categories returned by `/grammar_v2`: + +| Category | Types | Behavior | +|---|---|---| +| **Client-only** | `definition`, `practice`, `itStart` | Not from server; `itStart` triggers deprecated IT flow | +| **Grammar** (~21 types) | `verbConjugation`, `verbTense`, `verbMood`, `subjectVerbAgreement`, `genderAgreement`, `numberAgreement`, `caseError`, `article`, `preposition`, `pronoun`, `wordOrder`, `negation`, `questionFormation`, `relativeClause`, `connector`, `possessive`, `comparative`, `passiveVoice`, `conditional`, `infinitiveGerund`, `modal` | Orange underline, user must accept/ignore | +| **Surface corrections** | `punct`, `diacritics`, `spell`, `cap` | Auto-applied (no user interaction), undo via `AutocorrectPopup` | +| **Word choice** | `falseCognate`, `l1Interference`, `collocation`, `semanticConfusion` | Blue underline, user must accept/ignore | +| **Higher-level** | `transcription`, `style`, `fluency`, `didYouMean`, `translation`, `other` | Teal (style/fluency) or error color | + +Key extension helpers: `isAutoApply`, `isGrammarType`, `isWordChoiceType`, `underlineColor()`, `displayName()`, `fromString()` (handles legacy snake_case and old type names like `grammar` → `subjectVerbAgreement`). + +## Key Models + +| Model | File | Purpose | +|---|---|---| +| `SpanData` | `igc/span_data_model.dart` | A match span (offset, length, choices, message, rule, `ReplacementTypeEnum`) | +| `PangeaMatch` | `igc/pangea_match_model.dart` | SpanData + status | +| `PangeaMatchState` | `igc/pangea_match_state_model.dart` | Mutable wrapper tracking original vs updated match state | +| `ChoreoRecordModel` | `choreo_record_model.dart` | Full editing history: steps, open matches, original text | +| `ChoreoRecordStepModel` | `choreo_edit_model.dart` | Single edit step (text before/after, accepted match) | +| `IGCRequestModel` | `igc/igc_request_model.dart` | Request to `/grammar_v2` | +| `IGCResponseModel` | `igc/igc_response_model.dart` | Response from `/grammar_v2` | +| `MatchRuleIdModel` | `igc/match_rule_id_model.dart` | Rule ID constants (⚠️ `tokenNeedsTranslation`, `tokenSpanNeedsTranslation`, `l1SpanAndGrammar` — not currently sent by server) | +| `AutocorrectPopup` | `igc/autocorrect_popup.dart` | Undo widget for auto-applied corrections | + +## API Endpoints + +| Endpoint | Repo File | Status | +|---|---|---| +| `/choreo/grammar_v2` | `igc/igc_repo.dart` | ✅ Active — primary IGC endpoint | +| `/choreo/tokenize` | `events/repo/tokens_repo.dart` | ✅ Active — tokenizes final text on send | +| `/choreo/span_details` | `igc/span_data_repo.dart` | ❌ Dead code — `SpanDataRepo` class is defined but never imported anywhere | +| `/choreo/it_initialstep` | `it/it_repo.dart` | ⚠️ Deprecated — IT flow | +| `/choreo/contextual_definition` | `contextual_definition_repo.dart` | ⚠️ Deprecated — only used by IT's `word_data_card.dart` | + +## Edit Types (`EditTypeEnum`) + +- `keyboard` — User typing +- `igc` — System applying IGC match +- `it` — ⚠️ Deprecated — System applying IT continuance +- `itDismissed` — ⚠️ Deprecated — IT dismissed, restoring source text + +## Deprecated: SpanChoiceTypeEnum + +In `igc/span_choice_type_enum.dart`: +- `bestCorrection` — `@Deprecated('Use suggestion instead')` +- `bestAnswer` — `@Deprecated('Use suggestion instead')` +- `suggestion` — Active replacement + +## Error Handling + +- IGC and token errors trigger exponential backoff (`_igcErrorBackoff *= 2`, `_tokenErrorBackoff *= 2`) +- Backoff resets on next successful request +- Errors surfaced via `ChoreographerErrorController` +- Error state exposed in `AssistanceStateEnum.error` + +## ⚠️ Deprecated: Interactive Translation (IT) + +> **Do not extend.** IT is being deprecated. Translation will become a match type within IGC. + +The `it/` directory still contains `ITController`, `ITRepo`, `ITStepModel`, `CompletedITStepModel`, `GoldRouteTrackerModel`, `it_bar.dart`, `it_feedback_card.dart`, and `word_data_card.dart`. The choreographer still wires up IT via `_onOpenIT()` / `_onCloseIT()` / `_onAcceptContinuance()`, triggered when an `itStart` match is found. The `it_bar.dart` widget is still imported by `chat_input_bar.dart`. + +This entire flow will be removed once testing confirms IT is no longer needed as a separate mode. diff --git a/.github/instructions/events-and-tokens.instructions.md b/.github/instructions/events-and-tokens.instructions.md new file mode 100644 index 000000000..95c0d0ed8 --- /dev/null +++ b/.github/instructions/events-and-tokens.instructions.md @@ -0,0 +1,174 @@ +--- +applyTo: "lib/pangea/events/**,lib/pangea/extensions/**" +--- + +# Events & Tokens — Matrix Event Data Model + +Messages in Pangea carry rich metadata stored as Matrix events related to the main message. This doc covers custom event types, the token model, event wrappers, and how they connect. + +## Event Hierarchy for a Message + +When a user sends a message, the client creates a tree of related Matrix events: + +``` +m.room.message (the chat message) +├── pangea.representation ← PangeaRepresentation (sent text + lang) +│ ├── pangea.tokens ← PangeaMessageTokens (tokenized text) +│ └── pangea.record ← ChoreoRecordModel (editing history) +├── pangea.representation ← (optional: L1 original if IT was used) +│ └── pangea.tokens +├── pangea.translation ← Full-text translation +├── pangea.activity_req ← Request to generate practice activities +├── pangea.activity_res ← Generated practice activity +├── pangea.activity_completion ← User's activity completion record +└── pangea.stt_translation ← Speech-to-text translation +``` + +## Custom Event Types (`PangeaEventTypes`) + +Defined in `lib/pangea/events/constants/pangea_event_types.dart`: + +### Message-related + +| Type | Constant | Purpose | +|---|---|---| +| `pangea.representation` | `representation` | A text representation with language code | +| `pangea.tokens` | `tokens` | Tokenized text (lemmas, POS, morphology) | +| `pangea.record` | `choreoRecord` | Choreographer editing history | +| `pangea.translation` | `translation` | Full-text translation | +| `pangea.stt_translation` | `sttTranslation` | Speech-to-text translation | + +### Activities + +| Type | Constant | Purpose | +|---|---|---| +| `pangea.activity_req` | `activityRequest` | Request server to generate activities | +| `pangea.activity_res` | `pangeaActivity` | A practice activity for a message | +| `pangea.activity_completion` | `activityRecord` | Per-user activity completion record | +| `pangea.activity_plan` | `activityPlan` | Activity plan definition | +| `pangea.activity_roles` | `activityRole` | Roles in a structured activity | +| `pangea.activity_summary` | `activitySummary` | Post-activity summary | + +### Analytics & Learning + +| Type | Constant | Purpose | +|---|---|---| +| `pangea.construct` | `construct` | A tracked learning construct | +| `pangea.construct_summary` | `constructSummary` | Aggregate construct data | +| `pangea.summaryAnalytics` | `summaryAnalytics` | Summary analytics data | +| `pangea.analytics_profile` | `profileAnalytics` | User analytics profile | +| `pangea.activities_profile` | `profileActivities` | User activities profile | +| `pangea.analytics_settings` | `analyticsSettings` | Analytics display settings | +| `p.user_lemma_info` | `userSetLemmaInfo` | User-customized lemma info | +| `p.emoji` | `userChosenEmoji` | User-chosen emoji for a word | + +### Room/Course Settings + +| Type | Constant | Purpose | +|---|---|---| +| `pangea.class` | `languageSettings` | Room language configuration | +| `p.rules` | `rules` | Room rules | +| `pangea.roomtopic` | `roomInfo` | Room topic info | +| `pangea.bot_options` | `botOptions` | Bot behavior configuration | +| `pangea.capacity` | `capacity` | Room capacity limit | +| `pangea.course_plan` | `coursePlan` | Course plan reference | +| `p.course_user` | `courseUser` | User's course enrollment | +| `pangea.teacher_mode` | `teacherMode` | Teacher mode toggle | +| `pangea.course_chat_list` | `courseChatList` | Course chat list | + +### Audio & Media + +| Type | Constant | Purpose | +|---|---|---| +| `p.audio` | `audio` | Audio attachment | +| `pangea.transcript` | `transcript` | Audio transcript | +| `p.rule.text_to_speech` | `textToSpeechRule` | TTS settings | + +### User & Misc + +| Type | Constant | Purpose | +|---|---|---| +| `pangea.user_age` | `userAge` | User age bracket | +| `m.report` | `report` | Content report | +| `p.rule.analytics_invite` | `analyticsInviteRule` | Analytics sharing rules | +| `p.analytics_request` | `analyticsInviteContent` | Analytics sharing request | +| `pangea.regeneration_request` | `regenerationRequest` | Content regeneration request | +| `pangea.activity_room_ids` | `activityRoomIds` | Activity room references | + +## Core Data Models + +### PangeaToken (`events/models/pangea_token_model.dart`) + +The fundamental unit of linguistic analysis. Each token represents one word/unit. + +``` +PangeaToken +├── text: PangeaTokenText ← {content: "running", offset: 5} +├── lemma: Lemma ← {text: "run", saveVocab: true, form: "run"} +├── pos: String ← "VERB" (Universal Dependencies POS tag) +└── morph: Map ← {Tense: "Pres", VerbForm: "Part"} +``` + +- POS tags follow [Universal Dependencies](https://universaldependencies.org/u/pos/) +- Morph features follow [Universal Dependencies features](https://universaldependencies.org/u/feat/) +- Lemma includes `saveVocab` flag for vocab tracking + +### PangeaMessageTokens (`events/models/tokens_event_content_model.dart`) + +Container for a tokenized message, stored as `pangea.tokens` event: + +- `tokens: List` — tokenized words +- `detections: List?` — per-span language detection + +### PangeaRepresentation (`events/models/representation_content_model.dart`) + +A text representation of a message, stored as `pangea.representation` event: + +- `text` — the text content +- `langCode` — detected language +- `originalSent` — true if this is the text that was actually sent +- `originalWritten` — true if this is what the user originally typed + +Interpretation matrix: + +| `originalSent` | `originalWritten` | Meaning | +|:-:|:-:|---| +| ✓ | ✗ | Text went through IGC/IT before sending | +| ✗ | ✗ | Added by another user (e.g., translation) | +| ✓ | ✓ | User wrote and sent as-is (L1 or perfect L2) | +| ✗ | ✓ | User's original L1 that was then translated via IT | + +## Event Wrappers + +### PangeaMessageEvent (`events/event_wrappers/pangea_message_event.dart`) + +Wraps a Matrix `Event` of type `m.room.message` and provides access to all Pangea child events (representations, tokens, choreo records, translations, activities, etc.). This is the primary object used by the toolbar and reading assistance. + +Key capabilities: + +- Access tokens for the message +- Get translations and representations +- Trigger TTS/STT +- Get associated practice activities + +### PangeaRepresentationEvent (`events/event_wrappers/pangea_representation_event.dart`) + +Wraps a `pangea.representation` event. Provides typed access to `PangeaRepresentation` content. + +### PangeaChoreoEvent (`events/event_wrappers/pangea_choreo_event.dart`) + +Wraps a `pangea.record` event. Provides typed access to `ChoreoRecordModel` (editing history). + +## Room Extensions for Events + +`lib/pangea/extensions/room_events_extension.dart` extends Matrix `Room` with methods to: + +- Query child events by type +- Find representations and tokens for a message +- Access pangea-specific event data + +## Token Flow: Writing → Saving → Reading + +1. **Writing**: Choreographer gets tokens from `/tokenize` on send +2. **Saving**: `PangeaMessageContentModel` bundles tokens + choreo record + representations → saved as Matrix child events +3. **Reading**: `PangeaMessageEvent` loads child events → toolbar uses `PangeaToken` list for word cards, practice activities, analytics diff --git a/.github/instructions/modules.instructions.md b/.github/instructions/modules.instructions.md new file mode 100644 index 000000000..d41444ba3 --- /dev/null +++ b/.github/instructions/modules.instructions.md @@ -0,0 +1,127 @@ +--- +applyTo: "lib/pangea/**" +--- + +# Pangea Feature Modules (`lib/pangea/`) + +Each subdirectory is a self-contained feature module. This doc provides the full map. + +## Core Infrastructure + +| Module | Purpose | Key Files | +|---|---|---| +| `common/controllers/` | Central controllers | `pangea_controller.dart` (owns UserController, SubscriptionController, PLanguageStore), `base_controller.dart` (stream-based generic controller) | +| `common/network/` | API communication | `urls.dart` (all choreo API URLs), `requests.dart` (HTTP client) | +| `common/config/` | Environment config | `environment.dart` (reads `.env` / `config.sample.json` for URLs, homeserver, etc.) | +| `common/constants/` | Shared constants | `local.key.dart` (storage keys), `model_keys.dart` | +| `common/models/` | Base models | `base_request_model.dart`, `llm_feedback_model.dart` | +| `common/utils/` | Shared utilities | `error_handler.dart`, `firebase_analytics.dart`, `overlay.dart`, `p_vguard.dart` (route guards) | +| `common/widgets/` | Shared widgets | `pressable_button.dart`, `overlay_container.dart`, `shimmer_background.dart`, ~20 others | +| `design_system/` | Design tokens | `tokens/` | +| `navigation/` | Navigation | `navigation_util.dart` | + +## Writing Assistance (Choreographer) + +| Module | Purpose | Key Files | +|---|---|---| +| `choreographer/` | Writing flow orchestrator | `choreographer.dart` (ChangeNotifier), `choreographer_state_extension.dart`, `assistance_state_enum.dart`, `choreo_record_model.dart`, `choreo_mode_enum.dart` | +| `choreographer/igc/` | Interactive Grammar Correction | `igc_controller.dart`, `igc_repo.dart`, `replacement_type_enum.dart`, `pangea_match_model.dart`, `span_card.dart`, `span_data_model.dart`, `autocorrect_popup.dart`, `autocorrect_span.dart`, `start_igc_button.dart`, `text_normalization_util.dart` | +| `choreographer/it/` | ⚠️ DEPRECATED — Interactive Translation | `it_controller.dart`, `it_repo.dart`, `it_step_model.dart`, `it_feedback_card.dart`, `word_data_card.dart` | +| `choreographer/text_editing/` | Text controller | `pangea_text_controller.dart`, `edit_type_enum.dart` | + +## Message Toolbar (Reading Assistance) + +| Module | Purpose | Key Files | +|---|---|---| +| `toolbar/layout/` | Overlay positioning | `message_selection_positioner.dart`, `over_message_overlay.dart`, `reading_assistance_mode_enum.dart` | +| `toolbar/reading_assistance/` | Token-level reading UX | `underline_text_widget.dart`, `token_rendering_util.dart`, `select_mode_controller.dart`, `new_word_overlay.dart` | +| `toolbar/word_card/` | Word detail card | `word_card_switcher.dart`, `reading_assistance_content.dart`, `lemma_meaning_display.dart`, `token_feedback_button.dart` | +| `toolbar/message_practice/` | In-message practice | `practice_controller.dart`, `practice_activity_card.dart`, `practice_match_card.dart`, `morph_selection.dart` | + +## Events & Data Model + +| Module | Purpose | Key Files | +|---|---|---| +| `events/constants/` | Event type strings | `pangea_event_types.dart` (~30 custom types) | +| `events/event_wrappers/` | Typed event wrappers | `pangea_message_event.dart`, `pangea_choreo_event.dart`, `pangea_representation_event.dart` | +| `events/models/` | Event content models | `pangea_token_model.dart`, `pangea_token_text_model.dart`, `tokens_event_content_model.dart`, `representation_content_model.dart` | +| `events/repo/` | Token/language API | `tokens_repo.dart`, `token_api_models.dart`, `language_detection_repo.dart` | +| `events/extensions/` | Event helpers | `pangea_event_extension.dart` | +| `extensions/` | Room extensions | `pangea_room_extension.dart`, `room_events_extension.dart`, `room_user_permissions_extension.dart`, etc. | + +## Language & Linguistics + +| Module | Purpose | Key Files | +|---|---|---| +| `languages/` | Language data | `language_model.dart`, `language_repo.dart`, `language_service.dart`, `p_language_store.dart`, `locale_provider.dart` | +| `lemmas/` | Lemma (dictionary form) | `lemma.dart`, `lemma_info_repo.dart`, `user_set_lemma_info.dart` | +| `morphs/` | Morphological analysis | `morph_models.dart`, `morph_repo.dart`, `parts_of_speech_enum.dart`, `morph_features_enum.dart` | +| `constructs/` | Grammar/vocab constructs | `construct_identifier.dart`, `construct_repo.dart`, `construct_form.dart` | +| `translation/` | Full-text translation | `full_text_translation_repo.dart` + request/response models | +| `phonetic_transcription/` | IPA transcriptions | repo + models | + +## Practice & Activities + +| Module | Purpose | Key Files | +|---|---|---| +| `practice_activities/` | Activity generation | `practice_activity_model.dart`, `practice_generation_repo.dart`, `multiple_choice_activity_model.dart`, type-specific generators | +| `activity_sessions/` | Session management | `activity_room_extension.dart`, `activity_session_chat/`, `activity_session_start/` | +| `activity_planner/` | Activity planning UI | `activity_plan_model.dart`, `activity_planner_page.dart` | +| `activity_generator/` | Activity creation | `activity_generator.dart`, `activity_plan_generation_repo.dart` | +| `activity_suggestions/` | Activity suggestions | `activity_suggestion_dialog.dart`, `activity_plan_search_repo.dart` | +| `activity_summary/` | Post-activity summary | `activity_summary_model.dart`, `activity_summary_repo.dart` | +| `activity_feedback/` | Activity feedback | `activity_feedback_repo.dart` + request/response | + +## Analytics + +| Module | Purpose | Key Files | +|---|---|---| +| `analytics_data/` | Local DB & sync | `analytics_data_service.dart`, `analytics_database.dart`, `analytics_sync_controller.dart` | +| `analytics_misc/` | Models & utilities | `construct_use_model.dart`, `constructs_model.dart`, `room_analytics_extension.dart`, `level_up/` | +| `analytics_page/` | Analytics UI | `activity_archive.dart` | +| `analytics_summary/` | Summary views | `level_analytics_details_content.dart` | +| `analytics_practice/` | Practice analytics | `analytics_practice_page.dart` | +| `analytics_details_popup/` | Detail popups | `analytics_details_popup.dart` | +| `analytics_settings/` | Analytics config | settings UI | +| `analytics_downloads/` | Analytics export | download utilities | +| `space_analytics/` | Course-level analytics | `space_analytics.dart` | + +## User & Auth + +| Module | Purpose | Key Files | +|---|---|---| +| `user/` | Profile & settings | `user_controller.dart`, `user_model.dart`, `analytics_profile_model.dart`, `style_settings_repo.dart` | +| `authentication/` | Login/logout | `p_login.dart`, `p_logout.dart` | +| `login/` | Signup flow pages | `pages/` — language selection, course code, signup, find course | +| `subscription/` | RevenueCat | `controllers/subscription_controller.dart`, `pages/`, `repo/` | + +## Courses & Spaces + +| Module | Purpose | Key Files | +|---|---|---| +| `spaces/` | Matrix Spaces extensions | `client_spaces_extension.dart`, `space_navigation_column.dart` | +| `course_creation/` | Browse/join courses | `public_course_preview.dart`, `selected_course_page.dart` | +| `course_plans/` | CMS course data | `courses/`, `course_topics/`, `course_activities/`, `course_locations/`, `course_media/` | +| `course_settings/` | Course config | `course_settings.dart`, `teacher_mode_model.dart` | +| `chat_settings/` | Room bot/language config | `models/bot_options_model.dart`, `utils/bot_client_extension.dart` | +| `chat_list/` | Chat list customization | custom chat list logic | +| `chat/` | In-chat customization | `constants/`, `extensions/`, `utils/`, `widgets/` | +| `join_codes/` | Room code invitations | `join_with_link_page.dart` | + +## Media & I/O + +| Module | Purpose | Key Files | +|---|---|---| +| `speech_to_text/` | STT | `speech_to_text_repo.dart` + models | +| `text_to_speech/` | TTS | `tts_controller.dart`, `text_to_speech_repo.dart` | +| `download/` | Room data export | `download_room_extension.dart`, `download_type_enum.dart` | +| `payload_client/` | CMS API client | `payload_client.dart`, `models/course_plan/` | + +## Misc + +| Module | Purpose | Key Files | +|---|---|---| +| `bot/` | Bot UI & utils | `utils/`, `widgets/` | +| `instructions/` | In-app tutorials | tutorial content | +| `token_info_feedback/` | Token feedback dialog | `token_info_feedback_dialog.dart`, `token_info_feedback_repo.dart` | +| `learning_settings/` | Learning preferences | `settings_learning.dart`, `tool_settings_enum.dart` | diff --git a/.gitignore b/.gitignore index 19794af85..ff817a6d8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ prime keys.json !/public/.env *.env.local_choreo +assets/.env.local_choreo *.env.prod envs.json # libolm package @@ -49,8 +50,7 @@ venv/ .fvm/ .fvmrc -# copilot-instructions.md -.github/copilot-instructions.md + # Web related docs/tailwind.css diff --git a/lib/l10n/intl_ar.arb b/lib/l10n/intl_ar.arb index 2ec0e9c04..2b5f3ca32 100644 --- a/lib/l10n/intl_ar.arb +++ b/lib/l10n/intl_ar.arb @@ -1,6 +1,6 @@ { "@@locale": "ar", - "@@last_modified": "2026-02-09 11:09:32.801033", + "@@last_modified": "2026-02-09 15:31:53.731729", "about": "حول", "@about": { "type": "String", @@ -11231,5 +11231,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "قواعد اللغة", + "spanTypeWordChoice": "اختيار الكلمات", + "spanTypeSpelling": "التهجئة", + "spanTypePunctuation": "علامات الترقيم", + "spanTypeStyle": "الأسلوب", + "spanTypeFluency": "الطلاقة", + "spanTypeAccents": "اللكنات", + "spanTypeCapitalization": "حروف الجر", + "spanTypeCorrection": "تصحيح", + "spanFeedbackTitle": "الإبلاغ عن مشكلة تصحيح", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_be.arb b/lib/l10n/intl_be.arb index 2754a34fb..6e79bd96a 100644 --- a/lib/l10n/intl_be.arb +++ b/lib/l10n/intl_be.arb @@ -1908,7 +1908,7 @@ "playWithAI": "Пакуль гуляйце з ШІ", "courseStartDesc": "Pangea Bot гатовы да працы ў любы час!\n\n...але навучанне лепш з сябрамі!", "@@locale": "be", - "@@last_modified": "2026-02-09 11:09:23.199652", + "@@last_modified": "2026-02-09 15:31:34.877959", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12113,5 +12113,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Граматыка", + "spanTypeWordChoice": "Выбар слоў", + "spanTypeSpelling": "Арфаграфія", + "spanTypePunctuation": "Пунктуацыя", + "spanTypeStyle": "Стыль", + "spanTypeFluency": "Флюентнасць", + "spanTypeAccents": "Акцэнты", + "spanTypeCapitalization": "Капіталізацыя", + "spanTypeCorrection": "Карэкцыя", + "spanFeedbackTitle": "Паведаміць пра праблему з карэкцыяй", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_bn.arb b/lib/l10n/intl_bn.arb index 61e5ab945..d941e4704 100644 --- a/lib/l10n/intl_bn.arb +++ b/lib/l10n/intl_bn.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:46.816075", + "@@last_modified": "2026-02-09 15:32:17.400141", "about": "সম্পর্কে", "@about": { "type": "String", @@ -12118,5 +12118,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "ব্যাকরণ", + "spanTypeWordChoice": "শব্দ নির্বাচন", + "spanTypeSpelling": "বানান", + "spanTypePunctuation": "বিরাম চিহ্ন", + "spanTypeStyle": "শৈলী", + "spanTypeFluency": "প্রবাহ", + "spanTypeAccents": "উচ্চারণ", + "spanTypeCapitalization": "বড় হাতের অক্ষর", + "spanTypeCorrection": "সংশোধন", + "spanFeedbackTitle": "সংশোধন সমস্যা রিপোর্ট করুন", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_bo.arb b/lib/l10n/intl_bo.arb index 4b558844a..d44cbfa2b 100644 --- a/lib/l10n/intl_bo.arb +++ b/lib/l10n/intl_bo.arb @@ -4276,7 +4276,7 @@ "joinPublicTrip": "མི་ཚེས་ལ་ལོག་འབད།", "startOwnTrip": "ངེད་རང་གི་ལོག་ལ་སྦྱོར་བཅོས།", "@@locale": "bo", - "@@last_modified": "2026-02-09 11:09:44.069009", + "@@last_modified": "2026-02-09 15:32:12.071200", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -10768,5 +10768,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Izbor reči", + "spanTypeSpelling": "Pravopis", + "spanTypePunctuation": "Interpunkcija", + "spanTypeStyle": "Stil", + "spanTypeFluency": "Tečnost", + "spanTypeAccents": "Akcenti", + "spanTypeCapitalization": "Velika slova", + "spanTypeCorrection": "Ispravka", + "spanFeedbackTitle": "Prijavi problem sa ispravkom", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ca.arb b/lib/l10n/intl_ca.arb index 9670f68b1..c06caf15f 100644 --- a/lib/l10n/intl_ca.arb +++ b/lib/l10n/intl_ca.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:24.612806", + "@@last_modified": "2026-02-09 15:31:37.024931", "about": "Quant a", "@about": { "type": "String", @@ -11038,5 +11038,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramàtica", + "spanTypeWordChoice": "Elecció de paraules", + "spanTypeSpelling": "Ortografia", + "spanTypePunctuation": "Puntuació", + "spanTypeStyle": "Estil", + "spanTypeFluency": "Fluïdesa", + "spanTypeAccents": "Accents", + "spanTypeCapitalization": "Majúscules", + "spanTypeCorrection": "Correcció", + "spanFeedbackTitle": "Informar d'un problema de correcció", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_cs.arb b/lib/l10n/intl_cs.arb index 67c47b042..8e963cf26 100644 --- a/lib/l10n/intl_cs.arb +++ b/lib/l10n/intl_cs.arb @@ -1,6 +1,6 @@ { "@@locale": "cs", - "@@last_modified": "2026-02-09 11:09:20.002945", + "@@last_modified": "2026-02-09 15:31:28.643814", "about": "O aplikaci", "@about": { "type": "String", @@ -11621,5 +11621,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Výběr slov", + "spanTypeSpelling": "Pravopis", + "spanTypePunctuation": "Interpunkce", + "spanTypeStyle": "Styl", + "spanTypeFluency": "Plynulost", + "spanTypeAccents": "Přízvuky", + "spanTypeCapitalization": "Velká písmena", + "spanTypeCorrection": "Oprava", + "spanFeedbackTitle": "Nahlásit problém s opravou", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_da.arb b/lib/l10n/intl_da.arb index c0c309d49..edd3ab152 100644 --- a/lib/l10n/intl_da.arb +++ b/lib/l10n/intl_da.arb @@ -1927,7 +1927,7 @@ "playWithAI": "Leg med AI for nu", "courseStartDesc": "Pangea Bot er klar til at starte når som helst!\n\n...men læring er bedre med venner!", "@@locale": "da", - "@@last_modified": "2026-02-09 11:08:45.510364", + "@@last_modified": "2026-02-09 15:30:41.802679", "@aboutHomeserver": { "type": "String", "placeholders": { @@ -12075,5 +12075,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Grammatik", + "spanTypeWordChoice": "Ordvalg", + "spanTypeSpelling": "Stavning", + "spanTypePunctuation": "Interpunktion", + "spanTypeStyle": "Stil", + "spanTypeFluency": "Flydende", + "spanTypeAccents": "Accenter", + "spanTypeCapitalization": "Versalisering", + "spanTypeCorrection": "Korrektur", + "spanFeedbackTitle": "Rapporter korrektion problem", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 32b58e7cf..009a38d51 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1,6 +1,6 @@ { "@@locale": "de", - "@@last_modified": "2026-02-09 11:09:13.137048", + "@@last_modified": "2026-02-09 15:31:16.861620", "alwaysUse24HourFormat": "true", "@alwaysUse24HourFormat": { "description": "Set to true to always display time of day in 24 hour format." @@ -11021,5 +11021,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Grammatik", + "spanTypeWordChoice": "Wortwahl", + "spanTypeSpelling": "Rechtschreibung", + "spanTypePunctuation": "Interpunktion", + "spanTypeStyle": "Stil", + "spanTypeFluency": "Flüssigkeit", + "spanTypeAccents": "Akzente", + "spanTypeCapitalization": "Großschreibung", + "spanTypeCorrection": "Korrektur", + "spanFeedbackTitle": "Korrekturproblem melden", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_el.arb b/lib/l10n/intl_el.arb index 4ea104e49..fe8f0e20e 100644 --- a/lib/l10n/intl_el.arb +++ b/lib/l10n/intl_el.arb @@ -4453,7 +4453,7 @@ "playWithAI": "Παίξτε με την Τεχνητή Νοημοσύνη προς το παρόν", "courseStartDesc": "Ο Pangea Bot είναι έτοιμος να ξεκινήσει οποιαδήποτε στιγμή!\n\n...αλλά η μάθηση είναι καλύτερη με φίλους!", "@@locale": "el", - "@@last_modified": "2026-02-09 11:09:52.785391", + "@@last_modified": "2026-02-09 15:32:27.325286", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12072,5 +12072,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Γραμματική", + "spanTypeWordChoice": "Επιλογή Λέξεων", + "spanTypeSpelling": "Ορθογραφία", + "spanTypePunctuation": "Σημεία στίξης", + "spanTypeStyle": "Στυλ", + "spanTypeFluency": "Ροή", + "spanTypeAccents": "Τονισμοί", + "spanTypeCapitalization": "Κεφαλαία", + "spanTypeCorrection": "Διόρθωση", + "spanFeedbackTitle": "Αναφορά προβλήματος διόρθωσης", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 433c92433..0940c23c6 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5072,6 +5072,16 @@ "autoIGCToolName": "Enable writing assistance", "autoIGCToolDescription": "Automatically run Pangea Chat tools to correct sent messages to target language.", "emptyAudioError": "Recording failed. Please check your audio permissions and try again.", + "spanTypeGrammar": "Grammar", + "spanTypeWordChoice": "Word Choice", + "spanTypeSpelling": "Spelling", + "spanTypePunctuation": "Punctuation", + "spanTypeStyle": "Style", + "spanTypeFluency": "Fluency", + "spanTypeAccents": "Accents", + "spanTypeCapitalization": "Capitalization", + "spanTypeCorrection": "Correction", + "spanFeedbackTitle": "Report correction issue", "selectAllWords": "Select all the words you hear in the audio", "aboutMeHint": "About me", "changeEmail": "Change email", diff --git a/lib/l10n/intl_eo.arb b/lib/l10n/intl_eo.arb index 946d9ffc6..670765db1 100644 --- a/lib/l10n/intl_eo.arb +++ b/lib/l10n/intl_eo.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:57.785827", + "@@last_modified": "2026-02-09 15:32:34.842172", "about": "Prio", "@about": { "type": "String", @@ -12103,5 +12103,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatiko", + "spanTypeWordChoice": "Vortelekto", + "spanTypeSpelling": "Ortografio", + "spanTypePunctuation": "Punkto", + "spanTypeStyle": "Stilo", + "spanTypeFluency": "Flueco", + "spanTypeAccents": "Akcentoj", + "spanTypeCapitalization": "Kapitaligo", + "spanTypeCorrection": "Korektado", + "spanFeedbackTitle": "Raporti korektadon", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 5040071ef..f9f00d551 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1,6 +1,6 @@ { "@@locale": "es", - "@@last_modified": "2026-02-09 11:08:40.981454", + "@@last_modified": "2026-02-09 15:30:33.438936", "about": "Acerca de", "@about": { "type": "String", @@ -8256,5 +8256,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramática", + "spanTypeWordChoice": "Elección de palabras", + "spanTypeSpelling": "Ortografía", + "spanTypePunctuation": "Puntuación", + "spanTypeStyle": "Estilo", + "spanTypeFluency": "Fluidez", + "spanTypeAccents": "Acentos", + "spanTypeCapitalization": "Capitalización", + "spanTypeCorrection": "Corrección", + "spanFeedbackTitle": "Informar problema de corrección", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_et.arb b/lib/l10n/intl_et.arb index c621e5a6d..8d1fd2966 100644 --- a/lib/l10n/intl_et.arb +++ b/lib/l10n/intl_et.arb @@ -1,6 +1,6 @@ { "@@locale": "et", - "@@last_modified": "2026-02-09 11:09:05.357413", + "@@last_modified": "2026-02-09 15:31:13.687328", "about": "Rakenduse teave", "@about": { "type": "String", @@ -11285,5 +11285,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Grammatika", + "spanTypeWordChoice": "Sõnavalik", + "spanTypeSpelling": "Õigekiri", + "spanTypePunctuation": "Interpunktsioon", + "spanTypeStyle": "Stiil", + "spanTypeFluency": "Sujuvus", + "spanTypeAccents": "Aksendid", + "spanTypeCapitalization": "Suurtähed", + "spanTypeCorrection": "Parandus", + "spanFeedbackTitle": "Teata paranduse probleemist", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_eu.arb b/lib/l10n/intl_eu.arb index 568a778ed..bdec034ad 100644 --- a/lib/l10n/intl_eu.arb +++ b/lib/l10n/intl_eu.arb @@ -1,6 +1,6 @@ { "@@locale": "eu", - "@@last_modified": "2026-02-09 11:09:01.052613", + "@@last_modified": "2026-02-09 15:31:08.773071", "about": "Honi buruz", "@about": { "type": "String", @@ -11014,5 +11014,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Hitz Aukera", + "spanTypeSpelling": "Ortografia", + "spanTypePunctuation": "Puntuazioa", + "spanTypeStyle": "Estiloa", + "spanTypeFluency": "Fluentzia", + "spanTypeAccents": "Azentuak", + "spanTypeCapitalization": "Kapitalizazioa", + "spanTypeCorrection": "Zuzenketa", + "spanFeedbackTitle": "Zuzenketa arazoa txostenatu", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fa.arb b/lib/l10n/intl_fa.arb index 75cd90f6d..f83c4aad3 100644 --- a/lib/l10n/intl_fa.arb +++ b/lib/l10n/intl_fa.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:48.180617", + "@@last_modified": "2026-02-09 15:32:19.749220", "repeatPassword": "تکرار رمزعبور", "@repeatPassword": {}, "about": "درباره", @@ -11746,5 +11746,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "قواعد", + "spanTypeWordChoice": "انتخاب واژه", + "spanTypeSpelling": "هجی", + "spanTypePunctuation": "نقطه‌گذاری", + "spanTypeStyle": "سبک", + "spanTypeFluency": "روانی", + "spanTypeAccents": "لهجه‌ها", + "spanTypeCapitalization": "حروف بزرگ", + "spanTypeCorrection": "تصحیح", + "spanFeedbackTitle": "گزارش مشکل تصحیح", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fi.arb b/lib/l10n/intl_fi.arb index 6d16ae0bf..18002cb55 100644 --- a/lib/l10n/intl_fi.arb +++ b/lib/l10n/intl_fi.arb @@ -4006,7 +4006,7 @@ "playWithAI": "Leiki tekoälyn kanssa nyt", "courseStartDesc": "Pangea Bot on valmis milloin tahansa!\n\n...mutta oppiminen on parempaa ystävien kanssa!", "@@locale": "fi", - "@@last_modified": "2026-02-09 11:08:44.050102", + "@@last_modified": "2026-02-09 15:30:38.959801", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11637,5 +11637,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Kielioppi", + "spanTypeWordChoice": "Sananvalinta", + "spanTypeSpelling": "Oikeinkirjoitus", + "spanTypePunctuation": "Välihuomautukset", + "spanTypeStyle": "Tyyli", + "spanTypeFluency": "Sujuvuus", + "spanTypeAccents": "Aksentit", + "spanTypeCapitalization": "Isot kirjaimet", + "spanTypeCorrection": "Korjaus", + "spanFeedbackTitle": "Ilmoita korjausongelmasta", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fil.arb b/lib/l10n/intl_fil.arb index 14e084989..2a6022855 100644 --- a/lib/l10n/intl_fil.arb +++ b/lib/l10n/intl_fil.arb @@ -2784,7 +2784,7 @@ "selectAll": "Piliin lahat", "deselectAll": "Huwag piliin lahat", "@@locale": "fil", - "@@last_modified": "2026-02-09 11:09:29.936599", + "@@last_modified": "2026-02-09 15:31:47.672143", "@setCustomPermissionLevel": { "type": "String", "placeholders": {} @@ -11990,5 +11990,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Balarila", + "spanTypeWordChoice": "Pagpili ng Salita", + "spanTypeSpelling": "Pagbaybay", + "spanTypePunctuation": "Bantas", + "spanTypeStyle": "Estilo", + "spanTypeFluency": "Kadaluyan", + "spanTypeAccents": "Mga Tono", + "spanTypeCapitalization": "Pagkakapital", + "spanTypeCorrection": "Pagwawasto", + "spanFeedbackTitle": "Iulat ang isyu sa pagwawasto", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index ba626d21f..953cb7541 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1,6 +1,6 @@ { "@@locale": "fr", - "@@last_modified": "2026-02-09 11:10:07.003221", + "@@last_modified": "2026-02-09 15:32:46.273653", "about": "À propos", "@about": { "type": "String", @@ -11338,5 +11338,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Grammaire", + "spanTypeWordChoice": "Choix des mots", + "spanTypeSpelling": "Orthographe", + "spanTypePunctuation": "Ponctuation", + "spanTypeStyle": "Style", + "spanTypeFluency": "Fluidité", + "spanTypeAccents": "Accents", + "spanTypeCapitalization": "Capitalisation", + "spanTypeCorrection": "Correction", + "spanFeedbackTitle": "Signaler un problème de correction", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ga.arb b/lib/l10n/intl_ga.arb index bdc067a78..0f52fbca2 100644 --- a/lib/l10n/intl_ga.arb +++ b/lib/l10n/intl_ga.arb @@ -4514,7 +4514,7 @@ "playWithAI": "Imir le AI faoi láthair", "courseStartDesc": "Tá Bot Pangea réidh chun dul am ar bith!\n\n...ach is fearr foghlaim le cairde!", "@@locale": "ga", - "@@last_modified": "2026-02-09 11:10:05.570216", + "@@last_modified": "2026-02-09 15:32:44.231605", "@customReaction": { "type": "String", "placeholders": {} @@ -11012,5 +11012,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramadach", + "spanTypeWordChoice": "Rogha Focal", + "spanTypeSpelling": "Litriú", + "spanTypePunctuation": "Póntú", + "spanTypeStyle": "Stíl", + "spanTypeFluency": "Sreabhadh", + "spanTypeAccents": "Guthanna", + "spanTypeCapitalization": "Caipitleadh", + "spanTypeCorrection": "Córas", + "spanFeedbackTitle": "Tuairisc a dhéanamh ar fhadhb le ceartú", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_gl.arb b/lib/l10n/intl_gl.arb index f598911a2..791b54790 100644 --- a/lib/l10n/intl_gl.arb +++ b/lib/l10n/intl_gl.arb @@ -1,6 +1,6 @@ { "@@locale": "gl", - "@@last_modified": "2026-02-09 11:08:42.508158", + "@@last_modified": "2026-02-09 15:30:36.091824", "about": "Acerca de", "@about": { "type": "String", @@ -11011,5 +11011,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramática", + "spanTypeWordChoice": "Escolma de Palabras", + "spanTypeSpelling": "Ortografía", + "spanTypePunctuation": "Pontuación", + "spanTypeStyle": "Estilo", + "spanTypeFluency": "Fluidez", + "spanTypeAccents": "Acentos", + "spanTypeCapitalization": "Capitalización", + "spanTypeCorrection": "Corrección", + "spanFeedbackTitle": "Informar de problemas de corrección", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_he.arb b/lib/l10n/intl_he.arb index c385f170e..20c6d0953 100644 --- a/lib/l10n/intl_he.arb +++ b/lib/l10n/intl_he.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:08:56.858820", + "@@last_modified": "2026-02-09 15:31:01.183623", "about": "אודות", "@about": { "type": "String", @@ -12063,5 +12063,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "דקדוק", + "spanTypeWordChoice": "בחירת מילים", + "spanTypeSpelling": "איות", + "spanTypePunctuation": "פיסוק", + "spanTypeStyle": "סגנון", + "spanTypeFluency": "שפה רהוטה", + "spanTypeAccents": "הטעמה", + "spanTypeCapitalization": "הגדלת אותיות", + "spanTypeCorrection": "תיקון", + "spanFeedbackTitle": "דווח על בעיית תיקון", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_hi.arb b/lib/l10n/intl_hi.arb index 301f9fe14..2c51d432c 100644 --- a/lib/l10n/intl_hi.arb +++ b/lib/l10n/intl_hi.arb @@ -4480,7 +4480,7 @@ "playWithAI": "अभी के लिए एआई के साथ खेलें", "courseStartDesc": "पैंजिया बॉट कभी भी जाने के लिए तैयार है!\n\n...लेकिन दोस्तों के साथ सीखना बेहतर है!", "@@locale": "hi", - "@@last_modified": "2026-02-09 11:09:56.145817", + "@@last_modified": "2026-02-09 15:32:32.199640", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12099,5 +12099,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "व्याकरण", + "spanTypeWordChoice": "शब्द चयन", + "spanTypeSpelling": "वर्तनी", + "spanTypePunctuation": "विराम चिह्न", + "spanTypeStyle": "शैली", + "spanTypeFluency": "धाराप्रवाहता", + "spanTypeAccents": "उच्चारण", + "spanTypeCapitalization": "बड़े अक्षर", + "spanTypeCorrection": "सुधार", + "spanFeedbackTitle": "सुधार समस्या की रिपोर्ट करें", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_hr.arb b/lib/l10n/intl_hr.arb index 4a1ad631d..3bba7943d 100644 --- a/lib/l10n/intl_hr.arb +++ b/lib/l10n/intl_hr.arb @@ -1,6 +1,6 @@ { "@@locale": "hr", - "@@last_modified": "2026-02-09 11:08:55.423013", + "@@last_modified": "2026-02-09 15:30:58.744003", "about": "Informacije", "@about": { "type": "String", @@ -11386,5 +11386,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Odabir riječi", + "spanTypeSpelling": "Pravopis", + "spanTypePunctuation": "Interpunkcija", + "spanTypeStyle": "Stil", + "spanTypeFluency": "Tečnost", + "spanTypeAccents": "Naglasci", + "spanTypeCapitalization": "Velika slova", + "spanTypeCorrection": "Ispravak", + "spanFeedbackTitle": "Prijavi problem s ispravkom", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_hu.arb b/lib/l10n/intl_hu.arb index 9ec22c848..e9ada78b8 100644 --- a/lib/l10n/intl_hu.arb +++ b/lib/l10n/intl_hu.arb @@ -1,6 +1,6 @@ { "@@locale": "hu", - "@@last_modified": "2026-02-09 11:08:47.162355", + "@@last_modified": "2026-02-09 15:30:44.809310", "about": "Névjegy", "@about": { "type": "String", @@ -11015,5 +11015,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Nyelvtan", + "spanTypeWordChoice": "Szóválasztás", + "spanTypeSpelling": "Helyesírás", + "spanTypePunctuation": "Írásjelek", + "spanTypeStyle": "Stílus", + "spanTypeFluency": "Folyékonyság", + "spanTypeAccents": "Akcentusok", + "spanTypeCapitalization": "Nagybetűs írás", + "spanTypeCorrection": "Javítás", + "spanFeedbackTitle": "Javítási probléma jelentése", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ia.arb b/lib/l10n/intl_ia.arb index d5ba26248..614a00661 100644 --- a/lib/l10n/intl_ia.arb +++ b/lib/l10n/intl_ia.arb @@ -1955,7 +1955,7 @@ "playWithAI": "Joca con le IA pro ora", "courseStartDesc": "Pangea Bot es preste a comenzar a qualunque momento!\n\n...ma apprender es melior con amicos!", "@@locale": "ia", - "@@last_modified": "2026-02-09 11:08:57.966375", + "@@last_modified": "2026-02-09 15:31:03.556297", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12092,5 +12092,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Escolha de Palavras", + "spanTypeSpelling": "Ortografia", + "spanTypePunctuation": "Pontuação", + "spanTypeStyle": "Estilo", + "spanTypeFluency": "Fluência", + "spanTypeAccents": "Acentos", + "spanTypeCapitalization": "Capitalização", + "spanTypeCorrection": "Correção", + "spanFeedbackTitle": "Relatar problema de correção", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_id.arb b/lib/l10n/intl_id.arb index 8503ecfad..49d95edb0 100644 --- a/lib/l10n/intl_id.arb +++ b/lib/l10n/intl_id.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:08:48.564116", + "@@last_modified": "2026-02-09 15:30:47.056945", "setAsCanonicalAlias": "Atur sebagai alias utama", "@setAsCanonicalAlias": { "type": "String", @@ -11005,5 +11005,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Tata Bahasa", + "spanTypeWordChoice": "Pilihan Kata", + "spanTypeSpelling": "Ejaan", + "spanTypePunctuation": "Tanda Baca", + "spanTypeStyle": "Gaya", + "spanTypeFluency": "Kefasihan", + "spanTypeAccents": "Aksen", + "spanTypeCapitalization": "Kapitalisasi", + "spanTypeCorrection": "Koreksi", + "spanFeedbackTitle": "Laporkan masalah koreksi", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ie.arb b/lib/l10n/intl_ie.arb index 33e01880d..64e46a6fd 100644 --- a/lib/l10n/intl_ie.arb +++ b/lib/l10n/intl_ie.arb @@ -4369,7 +4369,7 @@ "playWithAI": "Joca con AI pro ora", "courseStartDesc": "Pangea Bot es preste a partir a qualunque momento!\n\n...ma apprender es melior con amicos!", "@@locale": "ie", - "@@last_modified": "2026-02-09 11:08:53.872484", + "@@last_modified": "2026-02-09 15:30:55.872849", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11988,5 +11988,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatica", + "spanTypeWordChoice": "Elección de palabras", + "spanTypeSpelling": "Ortografía", + "spanTypePunctuation": "Puntuación", + "spanTypeStyle": "Estilo", + "spanTypeFluency": "Fluidez", + "spanTypeAccents": "Acentos", + "spanTypeCapitalization": "Capitalización", + "spanTypeCorrection": "Corrección", + "spanFeedbackTitle": "Informar problema de corrección", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 4cedeee53..3e3779fd4 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:17.051102", + "@@last_modified": "2026-02-09 15:31:23.943569", "about": "Informazioni", "@about": { "type": "String", @@ -11017,5 +11017,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Grammatica", + "spanTypeWordChoice": "Scelta delle parole", + "spanTypeSpelling": "Ortografia", + "spanTypePunctuation": "Punteggiatura", + "spanTypeStyle": "Stile", + "spanTypeFluency": "Fluidità", + "spanTypeAccents": "Accenti", + "spanTypeCapitalization": "Capitalizzazione", + "spanTypeCorrection": "Correzione", + "spanFeedbackTitle": "Segnala problema di correzione", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ja.arb b/lib/l10n/intl_ja.arb index 5c7d5e600..a3e3a81c8 100644 --- a/lib/l10n/intl_ja.arb +++ b/lib/l10n/intl_ja.arb @@ -1,6 +1,6 @@ { "@@locale": "ja", - "@@last_modified": "2026-02-09 11:09:54.188816", + "@@last_modified": "2026-02-09 15:32:29.891676", "about": "このアプリについて", "@about": { "type": "String", @@ -11804,5 +11804,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "文法", + "spanTypeWordChoice": "単語の選択", + "spanTypeSpelling": "スペル", + "spanTypePunctuation": "句読点", + "spanTypeStyle": "スタイル", + "spanTypeFluency": "流暢さ", + "spanTypeAccents": "アクセント", + "spanTypeCapitalization": "大文字化", + "spanTypeCorrection": "修正", + "spanFeedbackTitle": "修正の問題を報告する", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ka.arb b/lib/l10n/intl_ka.arb index a12c57a10..3f6998afb 100644 --- a/lib/l10n/intl_ka.arb +++ b/lib/l10n/intl_ka.arb @@ -2591,7 +2591,7 @@ "playWithAI": "ამ დროისთვის ითამაშეთ AI-თან", "courseStartDesc": "Pangea Bot მზადაა ნებისმიერ დროს გასასვლელად!\n\n...მაგრამ სწავლა უკეთესია მეგობრებთან ერთად!", "@@locale": "ka", - "@@last_modified": "2026-02-09 11:10:02.292014", + "@@last_modified": "2026-02-09 15:32:39.485983", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12044,5 +12044,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "გრამატიკა", + "spanTypeWordChoice": "სიტყვების არჩევანი", + "spanTypeSpelling": "წერა", + "spanTypePunctuation": "ნიშნის გამოყენება", + "spanTypeStyle": "სტილი", + "spanTypeFluency": "მიმდინარე", + "spanTypeAccents": "აქცენტები", + "spanTypeCapitalization": "დიდი ასოები", + "spanTypeCorrection": "კორექტირება", + "spanFeedbackTitle": "შეტყობინება კორექტირების პრობლემაზე", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ko.arb b/lib/l10n/intl_ko.arb index 79f66f7f7..d59230507 100644 --- a/lib/l10n/intl_ko.arb +++ b/lib/l10n/intl_ko.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:08:39.290513", + "@@last_modified": "2026-02-09 15:30:29.458374", "about": "소개", "@about": { "type": "String", @@ -11122,5 +11122,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "문법", + "spanTypeWordChoice": "단어 선택", + "spanTypeSpelling": "철자", + "spanTypePunctuation": "구두점", + "spanTypeStyle": "스타일", + "spanTypeFluency": "유창성", + "spanTypeAccents": "억양", + "spanTypeCapitalization": "대문자 사용", + "spanTypeCorrection": "수정", + "spanFeedbackTitle": "수정 문제 보고", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_lt.arb b/lib/l10n/intl_lt.arb index 17df2b9d3..ab65c2762 100644 --- a/lib/l10n/intl_lt.arb +++ b/lib/l10n/intl_lt.arb @@ -3858,7 +3858,7 @@ "playWithAI": "Žaiskite su dirbtiniu intelektu dabar", "courseStartDesc": "Pangea botas pasiruošęs bet kada pradėti!\n\n...bet mokymasis yra geresnis su draugais!", "@@locale": "lt", - "@@last_modified": "2026-02-09 11:09:38.541694", + "@@last_modified": "2026-02-09 15:32:01.402371", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -11819,5 +11819,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Žodžių pasirinkimas", + "spanTypeSpelling": "Rašyba", + "spanTypePunctuation": "Skyryba", + "spanTypeStyle": "Stilius", + "spanTypeFluency": "Sklandumas", + "spanTypeAccents": "Akcentai", + "spanTypeCapitalization": "Didžiųjų raidžių naudojimas", + "spanTypeCorrection": "Korekcija", + "spanFeedbackTitle": "Pranešti apie korekcijos problemą", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_lv.arb b/lib/l10n/intl_lv.arb index fd473b660..4c5d77857 100644 --- a/lib/l10n/intl_lv.arb +++ b/lib/l10n/intl_lv.arb @@ -4480,7 +4480,7 @@ "playWithAI": "Tagad spēlējiet ar AI", "courseStartDesc": "Pangea bots ir gatavs jebkurā laikā!\n\n...bet mācīties ir labāk ar draugiem!", "@@locale": "lv", - "@@last_modified": "2026-02-09 11:09:31.557778", + "@@last_modified": "2026-02-09 15:31:50.771177", "analyticsInactiveTitle": "Pieprasījumi neaktīviem lietotājiem nevar tikt nosūtīti", "analyticsInactiveDesc": "Neaktīvi lietotāji, kuri nav pieteikušies kopš šīs funkcijas ieviešanas, neredzēs jūsu pieprasījumu.\n\nPieprasījuma poga parādīsies, kad viņi atgriezīsies. Jūs varat atkārtoti nosūtīt pieprasījumu vēlāk, noklikšķinot uz pieprasījuma pogas viņu vārdā, kad tā būs pieejama.", "accessRequestedTitle": "Pieprasījums piekļūt analītikai", @@ -11000,5 +11000,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Vārdu izvēle", + "spanTypeSpelling": "Ortogrāfija", + "spanTypePunctuation": "Interpunkcija", + "spanTypeStyle": "Stils", + "spanTypeFluency": "Plūdums", + "spanTypeAccents": "Akcenti", + "spanTypeCapitalization": "Lielie burti", + "spanTypeCorrection": "Korekcija", + "spanFeedbackTitle": "Ziņot par korekcijas problēmu", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_nb.arb b/lib/l10n/intl_nb.arb index 33d24b295..d5977516d 100644 --- a/lib/l10n/intl_nb.arb +++ b/lib/l10n/intl_nb.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:21.418237", + "@@last_modified": "2026-02-09 15:31:32.332245", "about": "Om", "@about": { "type": "String", @@ -12107,5 +12107,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Grammatikk", + "spanTypeWordChoice": "Ordvalg", + "spanTypeSpelling": "Staving", + "spanTypePunctuation": "Tegnsetting", + "spanTypeStyle": "Stil", + "spanTypeFluency": "Flyt", + "spanTypeAccents": "Aksenter", + "spanTypeCapitalization": "Store bokstaver", + "spanTypeCorrection": "Korrigering", + "spanFeedbackTitle": "Rapporter korrigeringsproblem", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_nl.arb b/lib/l10n/intl_nl.arb index 8b2e372a2..87b5b670e 100644 --- a/lib/l10n/intl_nl.arb +++ b/lib/l10n/intl_nl.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:42.630335", + "@@last_modified": "2026-02-09 15:32:09.504392", "about": "Over ons", "@about": { "type": "String", @@ -11014,5 +11014,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Grammatica", + "spanTypeWordChoice": "Woordkeuze", + "spanTypeSpelling": "Spelling", + "spanTypePunctuation": "Interpunctie", + "spanTypeStyle": "Stijl", + "spanTypeFluency": "Vloeiendheid", + "spanTypeAccents": "Accenten", + "spanTypeCapitalization": "Hoofdletters", + "spanTypeCorrection": "Correctie", + "spanFeedbackTitle": "Rapporteer correctiefout", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index fb697743b..f9efba83e 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -1,6 +1,6 @@ { "@@locale": "pl", - "@@last_modified": "2026-02-09 11:09:49.851439", + "@@last_modified": "2026-02-09 15:32:22.411163", "about": "O aplikacji", "@about": { "type": "String", @@ -11012,5 +11012,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatyka", + "spanTypeWordChoice": "Wybór słów", + "spanTypeSpelling": "Ortografia", + "spanTypePunctuation": "Interpunkcja", + "spanTypeStyle": "Styl", + "spanTypeFluency": "Płynność", + "spanTypeAccents": "Akcenty", + "spanTypeCapitalization": "Kapitalizacja", + "spanTypeCorrection": "Korekta", + "spanFeedbackTitle": "Zgłoś problem z korektą", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 368846890..5de04b43c 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:03.927804", + "@@last_modified": "2026-02-09 15:31:11.061688", "copiedToClipboard": "Copiada para a área de transferência", "@copiedToClipboard": { "type": "String", @@ -12114,5 +12114,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramática", + "spanTypeWordChoice": "Escolha de Palavras", + "spanTypeSpelling": "Ortografia", + "spanTypePunctuation": "Pontuação", + "spanTypeStyle": "Estilo", + "spanTypeFluency": "Fluência", + "spanTypeAccents": "Acentos", + "spanTypeCapitalization": "Capitalização", + "spanTypeCorrection": "Correção", + "spanFeedbackTitle": "Relatar problema de correção", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pt_BR.arb b/lib/l10n/intl_pt_BR.arb index 231e186b8..ca5814119 100644 --- a/lib/l10n/intl_pt_BR.arb +++ b/lib/l10n/intl_pt_BR.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:08:59.417144", + "@@last_modified": "2026-02-09 15:31:06.403516", "about": "Sobre", "@about": { "type": "String", @@ -11372,5 +11372,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramática", + "spanTypeWordChoice": "Escolha de Palavras", + "spanTypeSpelling": "Ortografia", + "spanTypePunctuation": "Pontuação", + "spanTypeStyle": "Estilo", + "spanTypeFluency": "Fluência", + "spanTypeAccents": "Acentos", + "spanTypeCapitalization": "Capitalização", + "spanTypeCorrection": "Correção", + "spanFeedbackTitle": "Relatar problema de correção", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_pt_PT.arb b/lib/l10n/intl_pt_PT.arb index 77a475da6..6dfb9eda2 100644 --- a/lib/l10n/intl_pt_PT.arb +++ b/lib/l10n/intl_pt_PT.arb @@ -3328,7 +3328,7 @@ "selectAll": "Selecionar tudo", "deselectAll": "Desmarcar tudo", "@@locale": "pt_PT", - "@@last_modified": "2026-02-09 11:09:27.266695", + "@@last_modified": "2026-02-09 15:31:42.773873", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12043,5 +12043,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramática", + "spanTypeWordChoice": "Escolha de Palavras", + "spanTypeSpelling": "Ortografia", + "spanTypePunctuation": "Pontuação", + "spanTypeStyle": "Estilo", + "spanTypeFluency": "Fluência", + "spanTypeAccents": "Acentos", + "spanTypeCapitalization": "Capitalização", + "spanTypeCorrection": "Correção", + "spanFeedbackTitle": "Relatar problema de correção", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ro.arb b/lib/l10n/intl_ro.arb index 318b79fc2..2b4912913 100644 --- a/lib/l10n/intl_ro.arb +++ b/lib/l10n/intl_ro.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:08:50.792922", + "@@last_modified": "2026-02-09 15:30:49.927369", "about": "Despre", "@about": { "type": "String", @@ -11749,5 +11749,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatica", + "spanTypeWordChoice": "Alegerea cuvintelor", + "spanTypeSpelling": "Ortografie", + "spanTypePunctuation": "Punctuație", + "spanTypeStyle": "Stil", + "spanTypeFluency": "Fluență", + "spanTypeAccents": "Accente", + "spanTypeCapitalization": "Capitalizare", + "spanTypeCorrection": "Corectare", + "spanFeedbackTitle": "Raportează problema de corectare", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 75a4b00f1..da55bcfbc 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -1,6 +1,6 @@ { "@@locale": "ru", - "@@last_modified": "2026-02-09 11:09:59.118469", + "@@last_modified": "2026-02-09 15:32:37.241817", "about": "О проекте", "@about": { "type": "String", @@ -11122,5 +11122,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Грамматика", + "spanTypeWordChoice": "Выбор слов", + "spanTypeSpelling": "Орфография", + "spanTypePunctuation": "Пунктуация", + "spanTypeStyle": "Стиль", + "spanTypeFluency": "Связность", + "spanTypeAccents": "Акценты", + "spanTypeCapitalization": "Капитализация", + "spanTypeCorrection": "Коррекция", + "spanFeedbackTitle": "Сообщить о проблеме с коррекцией", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sk.arb b/lib/l10n/intl_sk.arb index e3b50a52c..42aacdf9a 100644 --- a/lib/l10n/intl_sk.arb +++ b/lib/l10n/intl_sk.arb @@ -1,6 +1,6 @@ { "@@locale": "sk", - "@@last_modified": "2026-02-09 11:08:52.351401", + "@@last_modified": "2026-02-09 15:30:52.656999", "about": "O aplikácii", "@about": { "type": "String", @@ -12098,5 +12098,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Výber slov", + "spanTypeSpelling": "Pravopis", + "spanTypePunctuation": "Interpunkcia", + "spanTypeStyle": "Štýl", + "spanTypeFluency": "Plynulosť", + "spanTypeAccents": "Prízvuky", + "spanTypeCapitalization": "Veľké písmená", + "spanTypeCorrection": "Oprava", + "spanFeedbackTitle": "Nahlásiť problém s opravou", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sl.arb b/lib/l10n/intl_sl.arb index c62e362d3..3e3ea4c3b 100644 --- a/lib/l10n/intl_sl.arb +++ b/lib/l10n/intl_sl.arb @@ -2461,7 +2461,7 @@ "playWithAI": "Za zdaj igrajte z AI-jem", "courseStartDesc": "Pangea Bot je pripravljen kadarkoli!\n\n...ampak je bolje učiti se s prijatelji!", "@@locale": "sl", - "@@last_modified": "2026-02-09 11:09:14.553223", + "@@last_modified": "2026-02-09 15:31:19.119268", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12095,5 +12095,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Izbira besed", + "spanTypeSpelling": "Pravopis", + "spanTypePunctuation": "Interpunkcija", + "spanTypeStyle": "Slog", + "spanTypeFluency": "Sposobnost", + "spanTypeAccents": "Naglaski", + "spanTypeCapitalization": "Velike začetnice", + "spanTypeCorrection": "Popravek", + "spanFeedbackTitle": "Poročilo o težavi s popravkom", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sr.arb b/lib/l10n/intl_sr.arb index 47cc65f3a..a05c0df09 100644 --- a/lib/l10n/intl_sr.arb +++ b/lib/l10n/intl_sr.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:10:03.632765", + "@@last_modified": "2026-02-09 15:32:41.761528", "about": "О програму", "@about": { "type": "String", @@ -12116,5 +12116,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Gramatika", + "spanTypeWordChoice": "Izbor reči", + "spanTypeSpelling": "Pravopis", + "spanTypePunctuation": "Interpunkcija", + "spanTypeStyle": "Stil", + "spanTypeFluency": "Tečnost", + "spanTypeAccents": "Akcenti", + "spanTypeCapitalization": "Velika slova", + "spanTypeCorrection": "Ispravka", + "spanFeedbackTitle": "Prijavi problem sa ispravkom", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_sv.arb b/lib/l10n/intl_sv.arb index 787d57133..7c0c741be 100644 --- a/lib/l10n/intl_sv.arb +++ b/lib/l10n/intl_sv.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:51.437329", + "@@last_modified": "2026-02-09 15:32:24.746342", "about": "Om", "@about": { "type": "String", @@ -11492,5 +11492,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Grammatik", + "spanTypeWordChoice": "Orval", + "spanTypeSpelling": "Stavning", + "spanTypePunctuation": "Interpunktion", + "spanTypeStyle": "Stil", + "spanTypeFluency": "Flyt", + "spanTypeAccents": "Accenter", + "spanTypeCapitalization": "Versalisering", + "spanTypeCorrection": "Korrigering", + "spanFeedbackTitle": "Rapportera korrigeringsproblem", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_ta.arb b/lib/l10n/intl_ta.arb index 77850c249..b269b5882 100644 --- a/lib/l10n/intl_ta.arb +++ b/lib/l10n/intl_ta.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:41.597668", + "@@last_modified": "2026-02-09 15:32:06.841503", "acceptedTheInvitation": "👍 {username} அழைப்பை ஏற்றுக்கொண்டது", "@acceptedTheInvitation": { "type": "String", @@ -11238,5 +11238,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "இயல்பியல்", + "spanTypeWordChoice": "சொல் தேர்வு", + "spanTypeSpelling": "எழுத்துப்பிழை", + "spanTypePunctuation": "இணைச்சொல்", + "spanTypeStyle": "அழகு", + "spanTypeFluency": "தரிசனம்", + "spanTypeAccents": "உயர்த்தல்கள்", + "spanTypeCapitalization": "முதலெழுத்து", + "spanTypeCorrection": "திருத்தம்", + "spanFeedbackTitle": "திருத்தப் பிரச்சினையைப் புகாரளிக்கவும்", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_te.arb b/lib/l10n/intl_te.arb index 544df2529..f31433634 100644 --- a/lib/l10n/intl_te.arb +++ b/lib/l10n/intl_te.arb @@ -1917,7 +1917,7 @@ "playWithAI": "ఇప్పుడే AI తో ఆడండి", "courseStartDesc": "పాంజియా బాట్ ఎప్పుడైనా సిద్ధంగా ఉంటుంది!\n\n...కానీ స్నేహితులతో నేర్చుకోవడం మెరుగైనది!", "@@locale": "te", - "@@last_modified": "2026-02-09 11:09:36.591690", + "@@last_modified": "2026-02-09 15:31:58.761810", "@setCustomPermissionLevel": { "type": "String", "placeholders": {} @@ -12103,5 +12103,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "వ్యాకరణం", + "spanTypeWordChoice": "పద ఎంపిక", + "spanTypeSpelling": "అక్షరరూపం", + "spanTypePunctuation": "పంక్తి చిహ్నాలు", + "spanTypeStyle": "శైలి", + "spanTypeFluency": "ప్రవాహం", + "spanTypeAccents": "ఉచ్చారణలు", + "spanTypeCapitalization": "పెద్ద అక్షరాలు", + "spanTypeCorrection": "సరిదిద్దు", + "spanFeedbackTitle": "సరిదిద్దు సమస్యను నివేదించండి", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_th.arb b/lib/l10n/intl_th.arb index e50e12ac3..f1b74bf55 100644 --- a/lib/l10n/intl_th.arb +++ b/lib/l10n/intl_th.arb @@ -4453,7 +4453,7 @@ "playWithAI": "เล่นกับ AI ชั่วคราว", "courseStartDesc": "Pangea Bot พร้อมที่จะเริ่มต้นได้ทุกเมื่อ!\n\n...แต่การเรียนรู้ดีกว่ากับเพื่อน!", "@@locale": "th", - "@@last_modified": "2026-02-09 11:09:25.941768", + "@@last_modified": "2026-02-09 15:31:39.902769", "@alwaysUse24HourFormat": { "type": "String", "placeholders": {} @@ -12072,5 +12072,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "ไวยากรณ์", + "spanTypeWordChoice": "การเลือกคำ", + "spanTypeSpelling": "การสะกด", + "spanTypePunctuation": "เครื่องหมายวรรคตอน", + "spanTypeStyle": "สไตล์", + "spanTypeFluency": "ความคล่องแคล่ว", + "spanTypeAccents": "สำเนียง", + "spanTypeCapitalization": "การใช้ตัวพิมพ์ใหญ่", + "spanTypeCorrection": "การแก้ไข", + "spanFeedbackTitle": "รายงานปัญหาการแก้ไข", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_tr.arb b/lib/l10n/intl_tr.arb index e90077aae..1ba922127 100644 --- a/lib/l10n/intl_tr.arb +++ b/lib/l10n/intl_tr.arb @@ -1,6 +1,6 @@ { "@@locale": "tr", - "@@last_modified": "2026-02-09 11:09:34.545567", + "@@last_modified": "2026-02-09 15:31:56.191986", "about": "Hakkında", "@about": { "type": "String", @@ -11236,5 +11236,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Dilbilgisi", + "spanTypeWordChoice": "Kelime Seçimi", + "spanTypeSpelling": "Yazım", + "spanTypePunctuation": "Noktalama", + "spanTypeStyle": "Üslup", + "spanTypeFluency": "Akıcılık", + "spanTypeAccents": "Aksanlar", + "spanTypeCapitalization": "Büyük Harf Kullanımı", + "spanTypeCorrection": "Düzeltme", + "spanFeedbackTitle": "Düzeltme sorununu bildir", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_uk.arb b/lib/l10n/intl_uk.arb index e43890fed..ea9946ec9 100644 --- a/lib/l10n/intl_uk.arb +++ b/lib/l10n/intl_uk.arb @@ -1,6 +1,6 @@ { "@@locale": "uk", - "@@last_modified": "2026-02-09 11:09:18.460030", + "@@last_modified": "2026-02-09 15:31:25.978266", "about": "Про застосунок", "@about": { "type": "String", @@ -11008,5 +11008,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Граматика", + "spanTypeWordChoice": "Вибір слів", + "spanTypeSpelling": "Орфографія", + "spanTypePunctuation": "Пунктуація", + "spanTypeStyle": "Стиль", + "spanTypeFluency": "Вільність", + "spanTypeAccents": "Акценти", + "spanTypeCapitalization": "Великі літери", + "spanTypeCorrection": "Виправлення", + "spanFeedbackTitle": "Повідомити про проблему з виправленням", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_vi.arb b/lib/l10n/intl_vi.arb index 97dc307cd..f11eeaae4 100644 --- a/lib/l10n/intl_vi.arb +++ b/lib/l10n/intl_vi.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:39.961686", + "@@last_modified": "2026-02-09 15:32:04.137171", "about": "Giới thiệu", "@about": { "type": "String", @@ -6592,5 +6592,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "Ngữ pháp", + "spanTypeWordChoice": "Lựa chọn từ", + "spanTypeSpelling": "Chính tả", + "spanTypePunctuation": "Dấu câu", + "spanTypeStyle": "Phong cách", + "spanTypeFluency": "Lưu loát", + "spanTypeAccents": "Giọng điệu", + "spanTypeCapitalization": "Chữ hoa", + "spanTypeCorrection": "Sửa lỗi", + "spanFeedbackTitle": "Báo cáo vấn đề sửa lỗi", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_yue.arb b/lib/l10n/intl_yue.arb index dab04d273..5d2d0d348 100644 --- a/lib/l10n/intl_yue.arb +++ b/lib/l10n/intl_yue.arb @@ -1853,7 +1853,7 @@ "selectAll": "全選", "deselectAll": "取消全選", "@@locale": "yue", - "@@last_modified": "2026-02-09 11:09:15.805629", + "@@last_modified": "2026-02-09 15:31:21.578854", "@ignoreUser": { "type": "String", "placeholders": {} @@ -12105,5 +12105,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "文法", + "spanTypeWordChoice": "用詞選擇", + "spanTypeSpelling": "拼寫", + "spanTypePunctuation": "標點符號", + "spanTypeStyle": "風格", + "spanTypeFluency": "流暢度", + "spanTypeAccents": "口音", + "spanTypeCapitalization": "大寫", + "spanTypeCorrection": "更正", + "spanFeedbackTitle": "報告更正問題", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index 0fe08595b..1f2424e65 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -1,6 +1,6 @@ { "@@locale": "zh", - "@@last_modified": "2026-02-09 11:09:45.374826", + "@@last_modified": "2026-02-09 15:32:14.219030", "about": "关于", "@about": { "type": "String", @@ -11005,5 +11005,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "语法", + "spanTypeWordChoice": "用词选择", + "spanTypeSpelling": "拼写", + "spanTypePunctuation": "标点", + "spanTypeStyle": "风格", + "spanTypeFluency": "流利度", + "spanTypeAccents": "口音", + "spanTypeCapitalization": "大写", + "spanTypeCorrection": "更正", + "spanFeedbackTitle": "报告更正问题", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/l10n/intl_zh_Hant.arb b/lib/l10n/intl_zh_Hant.arb index e39d78cdc..d40146897 100644 --- a/lib/l10n/intl_zh_Hant.arb +++ b/lib/l10n/intl_zh_Hant.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2026-02-09 11:09:28.599644", + "@@last_modified": "2026-02-09 15:31:45.022584", "about": "關於", "@about": { "type": "String", @@ -11012,5 +11012,55 @@ "@noAddressDescription": { "type": "String", "placeholders": {} + }, + "spanTypeGrammar": "文法", + "spanTypeWordChoice": "用詞選擇", + "spanTypeSpelling": "拼寫", + "spanTypePunctuation": "標點符號", + "spanTypeStyle": "風格", + "spanTypeFluency": "流暢度", + "spanTypeAccents": "重音", + "spanTypeCapitalization": "大寫", + "spanTypeCorrection": "修正", + "spanFeedbackTitle": "報告修正問題", + "@spanTypeGrammar": { + "type": "String", + "placeholders": {} + }, + "@spanTypeWordChoice": { + "type": "String", + "placeholders": {} + }, + "@spanTypeSpelling": { + "type": "String", + "placeholders": {} + }, + "@spanTypePunctuation": { + "type": "String", + "placeholders": {} + }, + "@spanTypeStyle": { + "type": "String", + "placeholders": {} + }, + "@spanTypeFluency": { + "type": "String", + "placeholders": {} + }, + "@spanTypeAccents": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCapitalization": { + "type": "String", + "placeholders": {} + }, + "@spanTypeCorrection": { + "type": "String", + "placeholders": {} + }, + "@spanFeedbackTitle": { + "type": "String", + "placeholders": {} } } \ No newline at end of file diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 86d333ca1..452e2a0a0 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -2240,18 +2240,23 @@ class ChatController extends State choreographer, context, showNextMatch, + (feedback) => onRequestWritingAssistance(feedback: feedback), ); } Future onRequestWritingAssistance({ bool manual = false, bool autosend = false, + String? feedback, }) async { if (shouldShowLanguageMismatchPopupByActivity) { return showLanguageMismatchPopup(manual: manual); } - await choreographer.requestWritingAssistance(manual: manual); + feedback == null + ? await choreographer.requestWritingAssistance(manual: manual) + : await choreographer.rerunWithFeedback(feedback); + if (choreographer.assistanceState == AssistanceStateEnum.fetched) { showNextMatch(); } else if (autosend) { diff --git a/lib/pages/chat/input_bar.dart b/lib/pages/chat/input_bar.dart index 77d0bab05..4aec9b5ca 100644 --- a/lib/pages/chat/input_bar.dart +++ b/lib/pages/chat/input_bar.dart @@ -37,6 +37,7 @@ class InputBar extends StatelessWidget { final PangeaTextController? controller; final Choreographer choreographer; final VoidCallback showNextMatch; + final Future Function(String) onFeedbackSubmitted; // Pangea# final InputDecoration decoration; final ValueChanged? onChanged; @@ -60,6 +61,7 @@ class InputBar extends StatelessWidget { // #Pangea required this.choreographer, required this.showNextMatch, + required this.onFeedbackSubmitted, // Pangea# super.key, }); @@ -426,6 +428,7 @@ class InputBar extends StatelessWidget { choreographer, context, showNextMatch, + onFeedbackSubmitted, ); // rebuild the text field to highlight the newly selected match @@ -468,9 +471,9 @@ class InputBar extends StatelessWidget { focusNode: focusNode, textEditingController: controller, optionsBuilder: getSuggestions, - fieldViewBuilder: (context, __, focusNode, _) => ValueListenableBuilder( - valueListenable: choreographer.itController.open, - builder: (context, _, __) { + fieldViewBuilder: (context, __, focusNode, _) => ListenableBuilder( + listenable: choreographer, + builder: (context, _) { return TextField( controller: controller, focusNode: focusNode, diff --git a/lib/pangea/chat/widgets/pangea_chat_input_row.dart b/lib/pangea/chat/widgets/pangea_chat_input_row.dart index fdff8a030..6c9f08c8a 100644 --- a/lib/pangea/chat/widgets/pangea_chat_input_row.dart +++ b/lib/pangea/chat/widgets/pangea_chat_input_row.dart @@ -211,6 +211,8 @@ class PangeaChatInputRow extends StatelessWidget { onChanged: controller.onInputBarChanged, choreographer: controller.choreographer, showNextMatch: controller.showNextMatch, + onFeedbackSubmitted: (feedback) => controller + .onRequestWritingAssistance(feedback: feedback), ), ), ), diff --git a/lib/pangea/choreographer/choreographer.dart b/lib/pangea/choreographer/choreographer.dart index 169f5edeb..500a70139 100644 --- a/lib/pangea/choreographer/choreographer.dart +++ b/lib/pangea/choreographer/choreographer.dart @@ -284,10 +284,22 @@ class Choreographer extends ChangeNotifier { } _stopLoading(); - if (!igcController.openMatches - .any((match) => match.updatedMatch.isITStart)) { - igcController.fetchAllSpanDetails().catchError((e) => clearMatches(e)); + } + + /// Re-runs IGC with user feedback and updates the UI. + Future rerunWithFeedback(String feedbackText) async { + MatrixState.pAnyState.closeAllOverlays(); + igcController.clearMatches(); + igcController.clearCurrentText(); + + _startLoading(); + final success = await igcController.rerunWithFeedback(feedbackText); + if (success && igcController.openAutomaticMatches.isNotEmpty) { + await igcController.acceptNormalizationMatches(); } + _stopLoading(); + + return success; } Future getMessageContent(String message) async { diff --git a/lib/pangea/choreographer/edit_type_auto_apply.md b/lib/pangea/choreographer/edit_type_auto_apply.md new file mode 100644 index 000000000..f55ac0d0b --- /dev/null +++ b/lib/pangea/choreographer/edit_type_auto_apply.md @@ -0,0 +1,97 @@ +# Edit Type Auto-Apply Planning + +## Current Behavior + +The client currently auto-applies edits (without user interaction) based on a single condition: +- **Normalization errors**: Edits where the correction is the same as the original when normalized (punctuation, spacing, accents removed) + +This is implemented in: +- [span_data_model.dart](igc/span_data_model.dart#L147) - `isNormalizationError()` method +- [igc_controller.dart](igc/igc_controller.dart#L43) - `openAutomaticMatches` getter +- [igc_controller.dart](igc/igc_controller.dart#L227) - `acceptNormalizationMatches()` method + +Current `isNormalizationError()` logic: +```dart +bool isNormalizationError() { + final correctChoice = choices?.firstWhereOrNull((c) => c.isBestCorrection)?.value; + final l2Code = MatrixState.pangeaController.userController.userL2?.langCodeShort; + + return correctChoice != null && + l2Code != null && + normalizeString(correctChoice, l2Code) == normalizeString(errorSpan, l2Code); +} +``` + +The `normalizeString` function (in [text_normalization_util.dart](igc/text_normalization_util.dart)): +- Converts to lowercase +- Removes diacritics (language-specific) +- Replaces hyphens with spaces +- Removes punctuation +- Normalizes whitespace + +## Proposed Change + +Split auto-apply behavior based on **edit type** instead of just normalization matching. + +### Questions to Answer + +1. **What edit types should we distinguish?** + - Punctuation-only edits + - Accent/diacritic-only edits + - Capitalization-only edits + - Spelling errors + - Grammar errors (conjugation, agreement, etc.) + - Word choice / vocabulary suggestions + - Code-switching corrections (L1 word replaced with L2) + +2. **Which edit types should auto-apply?** + - Current: All "normalization" edits (punctuation + accent + case) + - Proposed: Make this configurable by type? + +3. **Where does the edit type come from?** + - Currently from `SpanData.rule` (has `Rule.id`, `Rule.category`, etc.) + - Or from `SpanDataTypeEnum` (grammar, correction, etc.) + - May need choreo/backend to provide explicit type classification + +4. **What user interaction modes exist?** + - Auto-apply (no interaction, edit applied silently) + - Notification (edit applied but user is informed) + - Selection (user must choose from options) + - Full interaction (span card with explanation) + +## Files to Modify + +### Client-side (this repo) +- `igc/span_data_model.dart` - Add edit type classification methods +- `igc/igc_controller.dart` - Update auto-apply logic based on type +- `choreographer.dart` - Handle different interaction modes +- Potentially new enum for edit categories + +### Backend (choreo) +- May need to return explicit edit type/category in response +- See [2-step-choreographer next_steps.md](../../../../../2-step-choreographer/app/handlers/wa/next_steps.md) + +## Current SpanData Structure + +```dart +class SpanData { + final String? message; + final String? shortMessage; + final List? choices; + final int offset; + final int length; + final String fullText; + final SpanDataTypeEnum type; // grammar, correction, etc. + final Rule? rule; // has id, category, description +} +``` + +## Tasks + +- [ ] Define edit type categories/enum +- [ ] Determine classification logic (client-side vs server-side) +- [ ] Design interaction mode mapping (type → mode) +- [ ] Implement type classification in SpanData +- [ ] Update IgcController to use type-based auto-apply +- [ ] Add user preference support (optional) +- [ ] Coordinate with choreo backend if needed diff --git a/lib/pangea/choreographer/igc/SPAN_CARD_REDESIGN_FINALIZED.md b/lib/pangea/choreographer/igc/SPAN_CARD_REDESIGN_FINALIZED.md new file mode 100644 index 000000000..330678eeb --- /dev/null +++ b/lib/pangea/choreographer/igc/SPAN_CARD_REDESIGN_FINALIZED.md @@ -0,0 +1,269 @@ +# Span Card UI Redesign - Finalized Plan + +## Overview + +Redesign the `SpanCard` widget to improve UX and add user feedback capabilities. This document consolidates all decisions from the design Q&A. + +--- + +## New Layout + +### Visual Structure + +``` +┌─────────────────────────────────────────┐ +│ [X] 🤖 [🚩] │ <- Header: Close, BotFace, Flag +├─────────────────────────────────────────┤ +│ Span Type Label │ <- Error category (e.g., "Grammar") +├─────────────────────────────────────────┤ +│ [ Choice 1 ] [ Choice 2 ] [ ... ] │ <- ChoicesArray +├─────────────────────────────────────────┤ +│ "Best choice feedback text..." │ <- Feedback shown on open +├─────────────────────────────────────────┤ +│ [ Ignore ] [ Replace ] │ <- Action buttons +└─────────────────────────────────────────┘ +``` + +### Header Row Details + +Follow `WordZoomWidget` pattern exactly: + +```dart +SizedBox( + height: 40.0, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + color: Theme.of(context).iconTheme.color, + icon: const Icon(Icons.close), + onPressed: widget.showNextMatch, + ), + Flexible( + child: Container( + constraints: const BoxConstraints(minHeight: 40.0), + alignment: Alignment.center, + child: BotFace(width: 40, expression: BotExpression.idle), + ), + ), + IconButton( + color: Theme.of(context).iconTheme.color, + icon: const Icon(Icons.flag_outlined), + onPressed: _onFlagPressed, + ), + ], + ), +), +``` + +--- + +## Color Scheme + +### Underline Colors by Type + +Use `AppConfig.primaryColor` (#8560E0) for IT/auto-apply types. + +| Category | Types | Color | +| ------------- | ------------------------------------------------ | ----------------------------------------- | +| IT/Auto-apply | `itStart`, `punct`, `diacritics`, `spell`, `cap` | `AppConfig.primaryColor.withOpacity(0.7)` | +| Grammar | All `grammarTypes` list | `AppConfig.warning.withOpacity(0.7)` | +| Word Choice | All `wordChoiceTypes` list | `Colors.blue.withOpacity(0.7)` | +| Style/Fluency | `style`, `fluency` | `Colors.teal.withOpacity(0.7)` | +| Other/Unknown | Everything else | `colorScheme.error.withOpacity(0.7)` | + +### Implementation + +Add to `replacement_type_enum.dart`: + +```dart +extension ReplacementTypeEnumColors on ReplacementTypeEnum { + Color underlineColor(BuildContext context) { + if (this == ReplacementTypeEnum.itStart || isAutoApply) { + return AppConfig.primaryColor.withOpacity(0.7); + } + if (isGrammarType) { + return AppConfig.warning.withOpacity(0.7); + } + if (isWordChoiceType) { + return Colors.blue.withOpacity(0.7); + } + switch (this) { + case ReplacementTypeEnum.style: + case ReplacementTypeEnum.fluency: + return Colors.teal.withOpacity(0.7); + default: + return Theme.of(context).colorScheme.error.withOpacity(0.7); + } + } +} +``` + +--- + +## Feedback Flow + +### Flag Button (Explicit Feedback) + +1. User taps 🚩 flag icon +2. Show `FeedbackDialog` for user to enter feedback text +3. Close span card, show spinning IGC indicator +4. Call grammar_v2 endpoint with feedback attached via new `IgcController.rerunWithFeedback()` method +5. Clear existing matches, replace with new response +6. Display new span card if matches exist + +### Ignore Button (Auto-Feedback) + +1. User taps "Ignore" +2. Fire-and-forget call to grammar_v2 with auto-generated feedback: + ``` + "user ignored the correction ({old} -> {new}) without feedback. not sure why" + ``` +3. Don't wait for response, proceed to next match +4. Silent fail on errors (logged server-side for finetuning) + +### Feedback Schema + +Use existing `LLMFeedbackSchema` structure: + +```dart +{ + "feedback": "user's feedback text", + "content": { /* original IGCResponseModel as JSON */ } +} +``` + +### Implementation Details + +1. **Store last response**: Add `IGCResponseModel? _lastResponse` field to `IgcController` +2. **New method**: Add `rerunWithFeedback(String feedbackText)` to `IgcController` +3. **Feedback always included**: No loading state needed - feedback text comes with initial response +4. **No snackbar initially**: Just use spinning IGC indicator (may add UX feedback later if too abrupt) + +--- + +## Span Type Display + +### Display Names + +Add `displayName(context)` method to `ReplacementTypeEnum`: + +| Type Category | Display String | +| ------------- | ---------------- | +| Grammar types | "Grammar" | +| Word choice | "Word Choice" | +| `spell` | "Spelling" | +| `punct` | "Punctuation" | +| `style` | "Style" | +| `fluency` | "Fluency" | +| `diacritics` | "Accents" | +| `cap` | "Capitalization" | +| Other | "Correction" | + +--- + +## Choices Behavior + +### Alts vs Distractors + +- **Alts**: Equally valid alternatives (e.g., "él" vs "ella" when gender ambiguous) +- **Distractors**: Intentionally wrong options to test learner + +These arrive as `SpanChoice` objects in `SpanData.choices`. + +### Selection Behavior + +| Choice Type | On Select | +| ----------- | ----------------------------------- | +| Best | Enable "Replace" button | +| Alt | Treat as accepted, apply that value | +| Distractor | Show "try again" feedback | + +### Single Choice + +If only one choice exists, show it without choice UI chrome. + +--- + +## Button Behaviors + +| Button | Action | +| ------- | -------------------------------------------------- | +| X | Close card, show next match (same as Ignore) | +| Replace | Apply selected choice, close card, show next match | +| Ignore | Auto-feedback (fire-and-forget), show next match | +| Flag | Open feedback dialog, re-run WA with user feedback | + +--- + +## Files to Modify + +| File | Changes | +| ----------------------------- | -------------------------------------------------------- | +| `replacement_type_enum.dart` | Add `underlineColor(context)` and `displayName(context)` | +| `pangea_text_controller.dart` | Update `_underlineColor` to use type-based colors | +| `span_card.dart` | Restructure layout per new design | +| `intl_en.arb` | Add new l10n strings | + +--- + +## Localization Strings + +Add to `intl_en.arb`: + +```json +{ + "spanFeedbackTitle": "Report correction issue", + "spanTypeGrammar": "Grammar", + "spanTypeWordChoice": "Word Choice", + "spanTypeSpelling": "Spelling", + "spanTypePunctuation": "Punctuation", + "spanTypeStyle": "Style", + "spanTypeFluency": "Fluency", + "spanTypeAccents": "Accents", + "spanTypeCapitalization": "Capitalization", + "spanTypeCorrection": "Correction" +} +``` + +**TODO**: Run translation script after adding strings. + +--- + +## Implementation Order + +1. [ ] Update `replacement_type_enum.dart` + - [ ] Add `underlineColor(context)` method + - [ ] Add `displayName(context)` method +2. [ ] Update `pangea_text_controller.dart` + - [ ] Change `_underlineColor` to use type-based colors +3. [ ] Add l10n strings to `intl_en.arb` +4. [ ] Update `igc_controller.dart` + - [ ] Add `IGCResponseModel? _lastResponse` field + - [ ] Store response in `getIGCTextData()` after fetch + - [ ] Add `rerunWithFeedback(String feedbackText)` method + - [ ] Add `sendAutoFeedback(PangeaMatch match)` method (fire-and-forget) +5. [ ] Restructure `span_card.dart` + - [ ] Add header row (X, BotFace, Flag) + - [ ] Add span type display row + - [ ] Show feedback on card open (no loading state needed) + - [ ] Wire up flag button to feedback flow + - [ ] Wire up ignore button to auto-feedback +6. [ ] Test full flow + +--- + +## Testing Considerations + +- Verify underline colors display correctly for each error type +- Test feedback dialog flow end-to-end +- Test auto-feedback on ignore (verify silent fail) +- Ensure span card closes properly after all actions +- Test with various span types to verify type labels +- Test distractor selection shows "try again" +- Test alt selection applies the alt value + +## NEXT STEPS + +- Figure out why feedback isn't displaying +- Considering migrating to using match message field instead of choice feedback diff --git a/lib/pangea/choreographer/igc/SPAN_CARD_REDESIGN_Q_AND_A.md b/lib/pangea/choreographer/igc/SPAN_CARD_REDESIGN_Q_AND_A.md new file mode 100644 index 000000000..6587ea63c --- /dev/null +++ b/lib/pangea/choreographer/igc/SPAN_CARD_REDESIGN_Q_AND_A.md @@ -0,0 +1,574 @@ +# Span Card UI Redesign Plan + +## Overview + +Redesign the `SpanCard` widget to improve UX and add user feedback capabilities. This document outlines the changes needed for the new layout and feedback flow. + +## Decisions Made ✅ + +### 1. Feedback Endpoint Behavior ✅ + +**Decision**: Re-run WA analysis with feedback to get different/better correction + +**Implementation notes**: + +- Use `gpt_5_2` model on regeneration +- Use `prompt_version="verbose"` +- Pull at least 3 varied examples in +- Need choreo-side testing to verify feedback is included in re-run + +### 2. Close Button (X) Behavior ✅ + +**Decision**: Close span card and show next match (same as current "Ignore") + +### 3. Best Choice Feedback Display ✅ + +**Decision**: Show immediately on card open + +> _Pedagogical note: Since the user has already seen their error highlighted and the choices displayed, the feedback explains "why" after they've had a chance to think about it. Hiding it behind a button adds friction without clear benefit._ + +### 4. Span Type Copy Format ✅ + +**Decision**: Use short labels defined in `replacement_type_enum.dart` + +Will add `displayName(context)` method returning l10n strings like: + +- "Grammar" / "Word Choice" / "Spelling" / "Punctuation" / "Style" + +### 5. Color Scheme ✅ + +**Decision**: Use brand colors from `app_config.dart`: + +- **Primary/Purple**: `AppConfig.primaryColor` (#8560E0) - for IT/auto-apply +- **Warning/Orange**: `AppConfig.warning` (rgba 210,124,12) - for grammar errors +- **Error/Red**: Use `colorScheme.error` - for unknown/other +- **Style/Teal**: Keep proposed teal for style/fluency +- **Word Choice**: Keep proposed blue + +### 6. Auto-Feedback on Ignore ✅ + +**Decision**: Auto-submit feedback on ignore (fire-and-forget) + +**Implementation**: + +- Submit async, don't block UI +- Catch errors internally (silent fail) +- Default message: `"user ignored the correction ({old} -> {new}) without feedback. not sure why"` + +### 7. Re-run Feedback Flow UX ✅ + +**Decision**: Close card → show spinning IGC indicator → snackbar with bot face "Got it! Recalculating..." + +- Shows card again when complete if there are more matches +- Uses existing spinning indicator pattern in input bar + +### 8. What if re-run returns no correction? ✅ + +**Decision**: Spinner turns green and it's done (no special message) + +### 9. Choices Layout ✅ + +**Decision**: Include alts/distractors in initial WA call with expanded TSV format + +--- + +## Discussion: Expanded TSV Format for Alts/Distractors + +### Proposed Format + +``` +old new type reason alt_1 alt_2 distractor_1 distractor_2 +``` + +### Your Pros + +1. Include alts when subjective (gender ambiguity, etc.) - user can pick +2. Include distractors when it makes a good learning exercise +3. Single handler = less to finetune, less feedback loops to manage + +### My Analysis on Complexity vs Quality + +**Short answer**: I think it's fine, with some guardrails. + +**Reasoning**: + +1. **Column count is manageable** - 8 columns is still very parseable for the model. The current 4-column format works well; doubling it shouldn't degrade accuracy much. + +2. **Optional columns help** - Making alt/distractor columns optional (empty when not applicable) keeps simple cases simple. The model only populates them when genuinely useful. + +3. **Clear semantics matter most** - As long as the prompt clearly defines: + - `alt_1/alt_2`: Equally valid alternatives (subjective/ambiguous cases) + - `distractor_1/distractor_2`: Plausible-but-wrong options (learning exercise) + + ...the model should handle it fine. + +4. **Finetuning examples will solidify it** - If your training examples show when to use alts vs not, the model will learn the pattern. + +### Potential Risks + +1. **Over-generation of alts** - Model might add alts when not needed. Mitigation: Prompt to say "only include alts if genuinely ambiguous" + +2. **Distractor quality** - Bad distractors could confuse learners. Mitigation: Could validate distractors are grammatically plausible but semantically wrong + +3. **Parsing edge cases** - Tab characters in reasons could break TSV. Already handled? + +### My Recommendation + +Go for it. The benefits (unified handler, better UX for ambiguous cases) outweigh the complexity cost. Just make sure: + +- Clear prompt instructions on when to use each column +- Good training examples showing both populated and empty cases +- Keep distractor generation optional/conservative initially + +--- + +## Follow-up Questions + +### 10. Alts vs Distractors Distinction ✅ + +**Clarified**: + +- **Alts**: All correct options given the context (e.g., "él" vs "ella" when gender unclear) +- **Distractors**: Intentionally wrong options to test the learner + +### 11. What if user picks an alt? ✅ + +**Decision**: Treat as "accepted" - apply that alt as the correction + +### 12. Distractor selection behavior ✅ + +**Decision**: Show "try again" feedback, don't apply the distractor + +### 13. Empty alts/distractors ✅ + +**Decision**: Just show the single `new` choice (no choice UI for single option) + +--- + +## All Questions Resolved ✅ + +No more open questions. Ready for implementation. + +--- + +## Current Implementation + +**File**: [span_card.dart](span_card.dart) + +Current layout (top to bottom): + +1. `ChoicesArray` - answer options +2. `_SpanCardFeedback` - feedback text with lightbulb button +3. `_SpanCardButtons` - Ignore / Replace buttons + +## New Layout + +### Visual Structure + +``` +┌─────────────────────────────────────────┐ +│ [X] 🤖 [🚩] │ <- Row 1: Close, BotFace, Flag +├─────────────────────────────────────────┤ +│ Span Type Copy │ <- Row 2: Error category label +├─────────────────────────────────────────┤ +│ [ Choice 1 ] [ Choice 2 ] [ ... ] │ <- Row 3: ChoicesArray +├─────────────────────────────────────────┤ +│ "Best choice feedback text..." │ <- Row 4: Best choice feedback +├─────────────────────────────────────────┤ +│ [ Ignore ] [ Replace ] │ <- Row 5: Action buttons +└─────────────────────────────────────────┘ +``` + +### Detailed Rows + +1. **Top Row (Header)** + - Left: X button (close overlay) - `IconButton(Icons.close)` + - Center: Bot face - `BotFace(width: 40, expression: BotExpression.idle)` + - Right: Flag button (feedback) - `IconButton(Icons.flag_outlined)` + +2. **Span Type Row** + - Display the error category from `match.updatedMatch.match.type` + - Use `ReplacementTypeEnum.defaultPrompt(context)` for human-readable text + - Consider adding l10n strings for each type's display name + +3. **Choices Row** + - Keep existing `ChoicesArray` widget + - No changes needed here + +4. **Best Choice Feedback Row** + - Display `bestChoice.feedback` text when available + - Show on card open (no button needed) since feedback is now always included + - Fall back to loading state if feedback needs fetching + +5. **Action Buttons Row** + - Keep existing `_SpanCardButtons` widget + - No changes needed here + +## Underline Color by Type + +### Files to Modify + +**File**: [pangea_text_controller.dart](../text_editing/pangea_text_controller.dart) + +Current `_underlineColor` method uses `match.match.rule?.id` to determine color. Change to use `match.match.type` (ReplacementTypeEnum). + +### Proposed Color Mapping + +Add extension method to `ReplacementTypeEnum`: + +```dart +// In replacement_type_enum.dart +extension ReplacementTypeEnumColors on ReplacementTypeEnum { + Color get underlineColor { + if (isAutoApply) { + return Colors.purple.withOpacity(0.7); // punct, diacritics, spell, cap + } + if (isGrammarType) { + return Colors.orange.withOpacity(0.7); // grammar errors + } + if (isWordChoiceType) { + return Colors.blue.withOpacity(0.7); // word choice issues + } + // Higher-level suggestions + switch (this) { + case ReplacementTypeEnum.style: + case ReplacementTypeEnum.fluency: + return Colors.teal.withOpacity(0.7); + case ReplacementTypeEnum.itStart: + return Colors.purple.withOpacity(0.7); + default: + return Colors.red.withOpacity(0.7); // other/unknown + } + } +} +``` + +Update `pangea_text_controller.dart`: + +```dart +Color _underlineColor(PangeaMatch match) { + if (match.status == PangeaMatchStatusEnum.automatic) { + return const Color.fromARGB(187, 132, 96, 224); + } + + // Use type-based coloring instead of rule ID + return match.match.type.underlineColor; +} +``` + +## Feedback Flag Flow + +### Reference Implementation + +See activity feedback flow in: + +- [activity_sessions_start_view.dart](../../activity_sessions/activity_session_start/activity_sessions_start_view.dart#L83-L139) +- [feedback_dialog.dart](../../common/widgets/feedback_dialog.dart) + +### Flow Steps + +1. User taps flag icon in SpanCard header +2. Show `FeedbackDialog` with optional text input +3. On submit, call WA endpoint with feedback +4. Show `FeedbackResponseDialog` with response +5. Close span card + +### New Files to Create + +1. **`span_feedback_request.dart`** - Request model for WA feedback +2. **`span_feedback_repo.dart`** - Repository to call WA endpoint with feedback + +### Endpoint Integration + +The WA endpoint already supports feedback via `GrammarRequestV2.feedback` field. + +**Choreo endpoint**: `POST /choreo/grammar_v2` + +**Request with feedback**: + +```json +{ + "full_text": "original user text", + "user_l1": "en", + "user_l2": "es", + "feedback": [ + { + "user_feedback": "This correction doesn't make sense", + "input": { ... original request ... }, + "output": { ... original response ... } + } + ] +} +``` + +### Implementation in SpanCard + +```dart +// In SpanCardState + +Future _onFlagPressed() async { + final feedback = await showDialog( + context: context, + builder: (context) => FeedbackDialog( + title: L10n.of(context).spanFeedbackTitle, + onSubmit: (feedback) => Navigator.of(context).pop(feedback), + scrollable: false, + ), + ); + + if (feedback == null || feedback.isEmpty) return; + + final resp = await showFutureLoadingDialog( + context: context, + future: () => SpanFeedbackRepo.submitFeedback( + SpanFeedbackRequest( + span: widget.match.updatedMatch.match, + feedbackText: feedback, + userId: Matrix.of(context).client.userID!, + userL1: MatrixState.pangeaController.userController.userL1Code!, + userL2: MatrixState.pangeaController.userController.userL2Code!, + ), + ), + ); + + if (resp.isError) return; + + await showDialog( + context: context, + builder: (context) => FeedbackResponseDialog( + title: L10n.of(context).feedbackTitle, + feedback: resp.result!.userFriendlyResponse, + description: L10n.of(context).feedbackRespDesc, + ), + ); + + // Close the span card + widget.showNextMatch(); +} +``` + +## Localization Strings Needed + +Add to `intl_en.arb`: + +```json +{ + "spanFeedbackTitle": "Report correction issue", + "spanTypeGrammar": "Grammar", + "spanTypeWordChoice": "Word Choice", + "spanTypeSpelling": "Spelling", + "spanTypePunctuation": "Punctuation", + "spanTypeStyle": "Style", + "spanTypeFluency": "Fluency" +} +``` + +## Files to Modify + +| File | Changes | +| ----------------------------- | ----------------------------------------------------------------- | +| `span_card.dart` | Restructure layout, add header row with X/BotFace/Flag | +| `replacement_type_enum.dart` | Add `underlineColor` extension, add `displayName(context)` method | +| `pangea_text_controller.dart` | Update `_underlineColor` to use type-based colors | + +## New Files to Create + +| File | Purpose | +| ---------------------------- | -------------------------------------- | +| `span_feedback_request.dart` | Request model for span feedback | +| `span_feedback_repo.dart` | API calls for submitting span feedback | + +## Implementation Order + +1. [ ] Update `replacement_type_enum.dart` with `underlineColor` and `displayName` +2. [ ] Update `pangea_text_controller.dart` to use type-based underline colors +3. [ ] Create `span_feedback_request.dart` and `span_feedback_repo.dart` +4. [ ] Restructure `span_card.dart` layout: + - [ ] Add header row (X, BotFace, Flag) + - [ ] Add span type display row + - [ ] Move ChoicesArray + - [ ] Show best choice feedback on open + - [ ] Implement flag button handler +5. [ ] Add localization strings +6. [ ] Test full flow + +## Testing Considerations + +- Verify underline colors display correctly for each error type +- Test feedback dialog flow end-to-end +- Ensure span card closes properly after feedback submission +- Test with various span types to verify type label displays correctly + +--- + +## Pre-Implementation Questions + +### 1. Re-run Feedback Flow Architecture + +The plan says feedback triggers a re-run of WA analysis with `gpt_5_2` model and `prompt_version="verbose"`. + +- **Q1a**: Does the choreo endpoint (`POST /choreo/grammar_v2`) already support the `prompt_version` parameter, or does this need to be added? +- **Q1b**: Where should the model override (`gpt_5_2`) be configured? On the request from the client, or hardcoded in the handler when feedback is present? + +this will be handled server-side. the client just needs to send the feedback in the request object. you can remove the notes about the serverside implemention, those have been added to server doc wa/next_steps.md + +### 2. Alts/Distractors in TSV Format + +The plan mentions expanding TSV format to include `alt_1`, `alt_2`, `distractor_1`, `distractor_2` columns. + +- **Q2a**: Is this TSV format expansion already implemented in choreo, or is this a future change we're planning for? +- **Q2b**: If not implemented yet, should the SpanCard redesign proceed without the alts/distractors UI, or should we stub it out? + +don't worry about this. they'll be SpanChoices in SpanData + +### 3. SpanFeedbackRepo Return Type + +The plan shows `resp.result!.userFriendlyResponse` after submitting feedback (similar to activity feedback). + +- **Q3**: For span feedback, what should the response contain? A new `GrammarResponseV2` with re-analyzed corrections? Or a simple acknowledgment with `userFriendlyResponse`? + +the client will call grammar_v2 endpoint again with feedback. it'll return a new grammar response object. + +### 4. Auto-Feedback on Ignore + +The plan states auto-submit feedback on ignore with message: `"user ignored the correction ({old} -> {new}) without feedback"`. + +- **Q4**: Should this auto-feedback actually trigger a re-run of WA analysis (like explicit feedback does), or should it just be logged/stored for finetuning purposes without re-analysis? + +it should send it via the grammar_v2 endpoint again but not worry about the result. the choreo will audit and store. + +### 5. Localization + +The plan lists new l10n strings to add. + +- **Q5**: Should I add these to `intl_en.arb` only, or are there other language files that need the new keys as well? + +some may be existing, others will be new. just add to intl_en.arb. make a TODO about them to run the translation script. + +### 6. Color Implementation + +The plan uses `Colors.purple.withOpacity(0.7)` for auto-apply types, but references `AppConfig.primaryColor` (#8560E0) in the decisions section. + +- **Q6**: Should I use the literal `Colors.purple.withOpacity(0.7)` or the app's `AppConfig.primaryColor` with opacity? (They're slightly different shades) + +use the primary with opacity. it's what itStart uses. + +--- + +## Follow-up Questions (Round 2) + +### 7. Feedback Request Structure + +The existing `GrammarRequestV2` has a `feedback` field of type `List`. Looking at the activity feedback flow, it uses a separate `ActivityFeedbackRequest` model. + +- **Q7a**: Should the span feedback flow modify the existing `IGCController` to re-call the grammar endpoint with feedback attached to the request, or create a separate `SpanFeedbackRepo` that wraps the grammar call? + +reuse the existing flow unless you spot complications with that. + +- **Q7b**: What fields should be included in the feedback object? Just `user_feedback` text, or also the original `input`/`output` as shown in the plan's JSON example? + +LLMFeedbackSchema calls for the response object plus the user_feedback text + +### 8. UI Flow on Re-run + +The plan says: "Close card → show spinning IGC indicator → snackbar with bot face 'Got it! Recalculating...'" + +- **Q8a**: After the re-run completes, if the same span now has a different correction (or no correction), how should we update the existing match state? Replace in-place, or clear all matches and re-process? + +it will return all new matches. clear the IGC match data and replace it with new + +- **Q8b**: Should the snackbar be shown, or is the spinning IGC indicator sufficient feedback? + +sure, let's skip the snackbar for now and see. + +### 9. SpanCard Header Layout + +The plan shows `[X] 🤖 [🚩]` in the header. + +- **Q9**: Should the X button and Flag button be the same size for visual symmetry, or should flag be smaller/less prominent? + +same size and color for visual symmetry. see the word card for example and follow that exactly + +--- + +## All Questions Resolved ✅ + +Ready to implement. Summary of key decisions from Q&A: + +1. **Feedback flow**: Reuse existing `IGCController` to call grammar endpoint with feedback attached +2. **Feedback schema**: `LLMFeedbackSchema` with `feedback` (user text) + `content` (original response object) +3. **Re-run result**: Clear existing IGC matches and replace with new response +4. **No snackbar**: Just use spinning IGC indicator +5. **Header layout**: Follow `WordZoomWidget` exactly - both buttons same size/color using `IconButton` with `Theme.of(context).iconTheme.color` +6. **Colors**: Use `AppConfig.primaryColor` with opacity for IT/auto-apply +7. **Localization**: Add to `intl_en.arb` only, TODO for translation script + +--- + +## Follow-up Questions (Round 3) + +### 10. Best Choice Feedback Loading State + +The original plan mentioned "Fall back to loading state if feedback needs fetching." + +**Q10**: Is feedback always included in the initial WA response now (so no fetching needed), or should we still handle a loading state for feedback? + +yes, both feedback and choices will always be included + +### 11. FeedbackResponseDialog After Flag + +The original plan showed a `FeedbackResponseDialog` after submitting feedback, but Q8b decided "skip the snackbar" and Q3 clarified the response is a new `GrammarResponseV2` (not a `userFriendlyResponse` string). + +**Q11**: After flag feedback re-run, should we: + +- (A) Just close card → spinner → show new matches (no dialog), or +- (B) Show a dialog acknowledging the feedback before showing new results? + +i think we probably need something. just closing the card would be abrupt. that's why i was thinking the snackbar. let's start without it and see though. + +### 12. Re-run Trigger Method + +The feedback calls grammar_v2 which returns a new response. + +**Q12**: How does the SpanCard trigger the re-run through IGCController? Should it: + +- (A) Call a new method like `igcController.rerunWithFeedback(feedback, originalResponse)`, or +- (B) Call the existing flow but with feedback attached to the request somehow? + +probably a new method is a good idea so we can add in any logic needed for this + +### 13. Original Response Reference + +`LLMFeedbackSchema` needs the original response (`content` field). + +yes. send the contested grammar response + +**Q13**: Where is the original `GrammarResponseV2` stored that we can reference when building the feedback object? Is it on `IGCController` or `Choreographer`? + +i'm not sure exactly. actually, i think it should be accessible via igc_repo.dart which will have cached it + +--- + +## All Round 3 Questions Answered ✅ + +Summary of Round 3 decisions: + +10. **Feedback always included**: No loading state needed for feedback text +11. **Post-feedback UX**: Start without snackbar/dialog, may add later if too abrupt +12. **New method needed**: Create `igcController.rerunWithFeedback(feedback, originalResponse)` +13. **Original response location**: Access via `IgcRepo` cache (keyed by request hashcode) + +--- + +## Follow-up Questions (Round 4) + +### 14. IgcRepo Cache Access + +Looking at `igc_repo.dart`, the cache is keyed by `IGCRequestModel.hashCode.toString()` and stores `Future`. The cache is private (`_igcCache`). + +**Q14**: To access the cached response for feedback, should I: + +- (A) Add a public method to `IgcRepo` like `getLastResponse()` or `getCachedResponse(request)`, or +- (B) Store the response on `IgcController` after fetch completes (simpler)? + +not sure. use your judgment. diff --git a/lib/pangea/choreographer/igc/igc_controller.dart b/lib/pangea/choreographer/igc/igc_controller.dart index 9b78cb439..7ca41e5c8 100644 --- a/lib/pangea/choreographer/igc/igc_controller.dart +++ b/lib/pangea/choreographer/igc/igc_controller.dart @@ -7,11 +7,11 @@ import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/choreographer/igc/igc_repo.dart'; import 'package:fluffychat/pangea/choreographer/igc/igc_request_model.dart'; +import 'package:fluffychat/pangea/choreographer/igc/igc_response_model.dart'; import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart'; import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart'; import 'package:fluffychat/pangea/choreographer/igc/span_data_model.dart'; -import 'package:fluffychat/pangea/choreographer/igc/span_data_repo.dart'; -import 'package:fluffychat/pangea/choreographer/igc/span_data_request.dart'; +import 'package:fluffychat/pangea/common/models/llm_feedback_model.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -24,6 +24,12 @@ class IgcController { bool _isFetching = false; String? _currentText; + /// Last request made - stored for feedback rerun + IGCRequestModel? _lastRequest; + + /// Last response received - stored for feedback rerun + IGCResponseModel? _lastResponse; + final List _openMatches = []; final List _closedMatches = []; @@ -70,21 +76,11 @@ class IgcController { IGCRequestModel( fullText: text, userId: MatrixState.pangeaController.userController.client.userID!, - userL1: MatrixState.pangeaController.userController.userL1Code!, - userL2: MatrixState.pangeaController.userController.userL2Code!, enableIGC: true, enableIT: true, prevMessages: prevMessages, ); - SpanDetailsRequest _spanDetailsRequest(SpanData span) => SpanDetailsRequest( - userL1: MatrixState.pangeaController.userController.userL1Code!, - userL2: MatrixState.pangeaController.userController.userL2Code!, - enableIGC: true, - enableIT: true, - span: span, - ); - void dispose() { matchUpdateStream.close(); } @@ -92,6 +88,8 @@ class IgcController { void clear() { _isFetching = false; _currentText = null; + _lastRequest = null; + _lastResponse = null; _openMatches.clear(); _closedMatches.clear(); MatrixState.pAnyState.closeAllOverlays(); @@ -102,6 +100,8 @@ class IgcController { _closedMatches.clear(); } + void clearCurrentText() => _currentText = null; + void _filterPreviouslyIgnoredMatches() { for (final match in _openMatches) { if (IgcRepo.isIgnored(match.updatedMatch)) { @@ -304,29 +304,88 @@ class IgcController { ) async { if (text.isEmpty) return clear(); if (_isFetching) return; + + final request = _igcRequest(text, prevMessages); + await _fetchIGC(request); + } + + /// Re-runs IGC with user feedback about the previous response. + /// Returns true if feedback was submitted, false if no previous data. + Future rerunWithFeedback(String feedbackText) async { + debugPrint('rerunWithFeedback called with: $feedbackText'); + debugPrint('_lastRequest: $_lastRequest, _lastResponse: $_lastResponse'); + if (_lastRequest == null || _lastResponse == null) { + ErrorHandler.logError( + e: StateError( + 'rerunWithFeedback called without prior request/response', + ), + data: { + 'hasLastRequest': _lastRequest != null, + 'hasLastResponse': _lastResponse != null, + 'currentText': _currentText, + }, + ); + return false; + } + if (_isFetching) { + debugPrint('rerunWithFeedback: already fetching, returning false'); + return false; + } + + // Create feedback containing the original response + final feedback = LLMFeedbackModel( + feedback: feedbackText, + content: _lastResponse!, + contentToJson: (r) => r.toJson(), + ); + + // Clear existing matches and state + clearMatches(); + + // Create request with feedback attached + final requestWithFeedback = _lastRequest!.copyWithFeedback([feedback]); + debugPrint( + 'requestWithFeedback.feedback.length: ${requestWithFeedback.feedback.length}', + ); + debugPrint('requestWithFeedback.hashCode: ${requestWithFeedback.hashCode}'); + debugPrint('_lastRequest.hashCode: ${_lastRequest!.hashCode}'); + debugPrint('Calling IgcRepo.get...'); + return _fetchIGC(requestWithFeedback); + } + + Future _fetchIGC(IGCRequestModel request) async { _isFetching = true; + _lastRequest = request; final res = await IgcRepo.get( MatrixState.pangeaController.userController.accessToken, - _igcRequest(text, prevMessages), + request, ).timeout( - (const Duration(seconds: 10)), + const Duration(seconds: 10), onTimeout: () { return Result.error( - TimeoutException('IGC request timed out'), + TimeoutException( + request.feedback.isNotEmpty + ? 'IGC feedback request timed out' + : 'IGC request timed out', + ), ); }, ); if (res.isError) { + debugPrint('IgcRepo.get error: ${res.asError}'); onError(res.asError!); clear(); - return; - } else { - onFetch(); + return false; } - if (!_isFetching) return; + debugPrint('IgcRepo.get success, calling onFetch'); + onFetch(); + + if (!_isFetching) return false; + + _lastResponse = res.result!; _currentText = res.result!.originalInput; for (final match in res.result!.matches) { final matchState = PangeaMatchState( @@ -342,38 +401,6 @@ class IgcController { } _filterPreviouslyIgnoredMatches(); _isFetching = false; - } - - Future fetchSpanDetails({ - required PangeaMatchState match, - bool force = false, - }) async { - final span = match.updatedMatch.match; - if (span.isNormalizationError() && !force) { - return; - } - - final response = await SpanDataRepo.get( - MatrixState.pangeaController.userController.accessToken, - request: _spanDetailsRequest(span), - ).timeout( - (const Duration(seconds: 10)), - onTimeout: () { - return Result.error( - TimeoutException('Span details request timed out'), - ); - }, - ); - - if (response.isError) throw response.error!; - setSpanData(match, response.result!); - } - - Future fetchAllSpanDetails() async { - final fetches = []; - for (final match in _openMatches) { - fetches.add(fetchSpanDetails(match: match)); - } - await Future.wait(fetches); + return true; } } diff --git a/lib/pangea/choreographer/igc/igc_repo.dart b/lib/pangea/choreographer/igc/igc_repo.dart index 3ffbc5d07..998a1faee 100644 --- a/lib/pangea/choreographer/igc/igc_repo.dart +++ b/lib/pangea/choreographer/igc/igc_repo.dart @@ -56,10 +56,15 @@ class IgcRepo { String? accessToken, IGCRequestModel igcRequest, ) { + debugPrint( + '[IgcRepo.get] called, request.hashCode: ${igcRequest.hashCode}', + ); final cached = _getCached(igcRequest); if (cached != null) { + debugPrint('[IgcRepo.get] cache HIT'); return _getResult(igcRequest, cached); } + debugPrint('[IgcRepo.get] cache MISS, fetching from server...'); final future = _fetch( accessToken, diff --git a/lib/pangea/choreographer/igc/igc_request_model.dart b/lib/pangea/choreographer/igc/igc_request_model.dart index cbe6a41eb..1d3ddb7f2 100644 --- a/lib/pangea/choreographer/igc/igc_request_model.dart +++ b/lib/pangea/choreographer/igc/igc_request_model.dart @@ -1,36 +1,67 @@ import 'dart:convert'; +import 'package:fluffychat/pangea/choreographer/igc/igc_response_model.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; +import 'package:fluffychat/pangea/common/models/base_request_model.dart'; +import 'package:fluffychat/pangea/common/models/llm_feedback_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; -class IGCRequestModel { +class IGCRequestModel with BaseRequestModel { final String fullText; - final String userL1; - final String userL2; final bool enableIT; final bool enableIGC; final String userId; final List prevMessages; + final List> feedback; + + @override + String get userCefr => MatrixState + .pangeaController.userController.profile.userSettings.cefrLevel.string; + + @override + String get userL1 => MatrixState.pangeaController.userController.userL1Code!; + + @override + String get userL2 => MatrixState.pangeaController.userController.userL2Code!; const IGCRequestModel({ required this.fullText, - required this.userL1, - required this.userL2, required this.enableIGC, required this.enableIT, required this.userId, required this.prevMessages, + this.feedback = const [], }); - Map toJson() => { - ModelKey.fullText: fullText, - ModelKey.userL1: userL1, - ModelKey.userL2: userL2, - ModelKey.enableIT: enableIT, - ModelKey.enableIGC: enableIGC, - ModelKey.userId: userId, - ModelKey.prevMessages: - jsonEncode(prevMessages.map((x) => x.toJson()).toList()), - }; + /// Creates a copy of this request with optional feedback. + IGCRequestModel copyWithFeedback( + List> newFeedback, + ) => + IGCRequestModel( + fullText: fullText, + enableIGC: enableIGC, + enableIT: enableIT, + userId: userId, + prevMessages: prevMessages, + feedback: newFeedback, + ); + + Map toJson() { + final json = { + ModelKey.fullText: fullText, + ModelKey.userL1: userL1, + ModelKey.userL2: userL2, + ModelKey.enableIT: enableIT, + ModelKey.enableIGC: enableIGC, + ModelKey.userId: userId, + ModelKey.prevMessages: + jsonEncode(prevMessages.map((x) => x.toJson()).toList()), + }; + if (feedback.isNotEmpty) { + json[ModelKey.feedback] = feedback.map((f) => f.toJson()).toList(); + } + return json; + } @override bool operator ==(Object other) { @@ -43,9 +74,14 @@ class IGCRequestModel { userL1 == other.userL1 && userL2 == other.userL2 && enableIT == other.enableIT && - userId == other.userId; + userId == other.userId && + _feedbackHash == other._feedbackHash; } + /// Hash of feedback content for cache differentiation + int get _feedbackHash => + feedback.isEmpty ? 0 : Object.hashAll(feedback.map((f) => f.feedback)); + @override int get hashCode => Object.hash( fullText.trim(), @@ -54,6 +90,7 @@ class IGCRequestModel { enableIT, enableIGC, userId, + _feedbackHash, ); } diff --git a/lib/pangea/choreographer/igc/igc_response_model.dart b/lib/pangea/choreographer/igc/igc_response_model.dart index aed6b4bcd..2feb383fb 100644 --- a/lib/pangea/choreographer/igc/igc_response_model.dart +++ b/lib/pangea/choreographer/igc/igc_response_model.dart @@ -7,7 +7,13 @@ class IGCResponseModel { final List matches; final String userL1; final String userL2; + + /// Whether interactive translation is enabled. + /// Defaults to true for V2 responses which don't include this field. final bool enableIT; + + /// Whether in-context grammar is enabled. + /// Defaults to true for V2 responses which don't include this field. final bool enableIGC; IGCResponseModel({ @@ -16,35 +22,39 @@ class IGCResponseModel { required this.matches, required this.userL1, required this.userL2, - required this.enableIT, - required this.enableIGC, + this.enableIT = true, + this.enableIGC = true, }); factory IGCResponseModel.fromJson(Map json) { + final String originalInput = json["original_input"]; return IGCResponseModel( matches: json["matches"] != null ? (json["matches"] as Iterable) .map( - (e) { - return PangeaMatch.fromJson(e as Map); - }, + (e) => PangeaMatch.fromJson( + e as Map, + fullText: originalInput, + ), ) .toList() .cast() : [], - originalInput: json["original_input"], + originalInput: originalInput, fullTextCorrection: json["full_text_correction"], userL1: json[ModelKey.userL1], userL2: json[ModelKey.userL2], - enableIT: json[ModelKey.enableIT], - enableIGC: json[ModelKey.enableIGC], + // V2 responses don't include these fields; default to true + enableIT: json[ModelKey.enableIT] ?? true, + enableIGC: json[ModelKey.enableIGC] ?? true, ); } Map toJson() => { "original_input": originalInput, "full_text_correction": fullTextCorrection, - "matches": matches.map((e) => e.toJson()).toList(), + // Serialize as flat SpanData objects matching server's SpanDataV2 schema + "matches": matches.map((e) => e.match.toJson()).toList(), ModelKey.userL1: userL1, ModelKey.userL2: userL2, ModelKey.enableIT: enableIT, diff --git a/lib/pangea/choreographer/igc/pangea_match_model.dart b/lib/pangea/choreographer/igc/pangea_match_model.dart index a2db081f8..e76400813 100644 --- a/lib/pangea/choreographer/igc/pangea_match_model.dart +++ b/lib/pangea/choreographer/igc/pangea_match_model.dart @@ -1,5 +1,5 @@ import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart'; -import 'package:fluffychat/pangea/choreographer/igc/span_data_type_enum.dart'; +import 'package:fluffychat/pangea/choreographer/igc/replacement_type_enum.dart'; import 'match_rule_id_model.dart'; import 'span_data_model.dart'; @@ -12,10 +12,31 @@ class PangeaMatch { required this.status, }); - factory PangeaMatch.fromJson(Map json) { + /// Parse PangeaMatch from JSON. + /// + /// Supports two formats: + /// - V1/Legacy: {"match": {...span_data...}, "status": "open"} + /// - V2: {...span_data...} (SpanData directly, status defaults to open) + /// + /// [fullText] is passed to SpanData as fallback when the span JSON doesn't + /// contain full_text (e.g., when using original_input from parent response). + factory PangeaMatch.fromJson( + Map json, { + String? fullText, + }) { + // Check if this is V1 format (has "match" wrapper) or V2 format (flat SpanData) + final bool isV1Format = json[_matchKey] is Map; + + final Map spanJson = + isV1Format ? json[_matchKey] as Map : json; + return PangeaMatch( - match: SpanData.fromJson(json[_matchKey] as Map), - status: json[_statusKey] != null + match: SpanData.fromJson( + spanJson, + parentFullText: fullText, + ), + // V1 format may have status; V2 format always defaults to open + status: isV1Format && json[_statusKey] != null ? PangeaMatchStatusEnum.fromString(json[_statusKey] as String) : PangeaMatchStatusEnum.open, ); @@ -31,8 +52,7 @@ class PangeaMatch { bool get isITStart => match.rule?.id == MatchRuleIdModel.interactiveTranslation || - [SpanDataTypeEnum.itStart, SpanDataTypeEnum.itStart.name] - .contains(match.type.typeName); + match.type == ReplacementTypeEnum.itStart; bool get _needsTranslation => match.rule?.id != null ? [ diff --git a/lib/pangea/choreographer/igc/replacement_type_enum.dart b/lib/pangea/choreographer/igc/replacement_type_enum.dart new file mode 100644 index 000000000..827d587cb --- /dev/null +++ b/lib/pangea/choreographer/igc/replacement_type_enum.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +enum ReplacementTypeEnum { + // Client-specific types + definition, + practice, + itStart, + + // === GRAMMAR CATEGORIES (granular for teacher analytics) === + verbConjugation, // Wrong form for person/number/tense + verbTense, // Using wrong tense for context + verbMood, // Indicative vs subjunctive vs imperative + subjectVerbAgreement, // "he go" -> "he goes" + genderAgreement, // "la libro" -> "el libro" + numberAgreement, // Singular/plural mismatches + caseError, // For languages with grammatical cases + article, // Missing, wrong, or unnecessary articles + preposition, // Wrong preposition choice + pronoun, // Wrong form, reference, or missing + wordOrder, // Syntax/structure issues + negation, // Double negatives, wrong placement + questionFormation, // Incorrect question structure + relativeClause, // who/which/that errors + connector, // Conjunction usage (but/and/however) + possessive, // Apostrophe usage, possessive pronouns + comparative, // Comparative/superlative forms + passiveVoice, // Passive construction errors + conditional, // If clauses, would/could + infinitiveGerund, // Infinitive vs gerund usage + modal, // Modal verb usage (can/could/should/must) + + // === SURFACE-LEVEL CORRECTIONS (auto-applied) === + punct, + diacritics, + spell, + cap, + + // === WORD CHOICE CATEGORIES (granular for teacher analytics) === + falseCognate, // False friends (e.g., "embarazada" ≠ "embarrassed") + l1Interference, // L1 patterns bleeding through incorrectly + collocation, // Wrong word pairing (e.g., "do a mistake" → "make a mistake") + semanticConfusion, // Similar meanings, wrong choice (e.g., "see/watch/look") + + // === HIGHER-LEVEL SUGGESTIONS === + transcription, + style, + fluency, + didYouMean, + translation, + other, +} + +extension SpanDataTypeEnumExt on ReplacementTypeEnum { + /// Types that should be auto-applied without user interaction. + /// These are minor corrections like punctuation, spacing, accents, etc. + static const List autoApplyTypes = [ + ReplacementTypeEnum.punct, + ReplacementTypeEnum.diacritics, + ReplacementTypeEnum.spell, + ReplacementTypeEnum.cap, + ]; + + /// Grammar types that require explanatory reasons for learning. + static const List grammarTypes = [ + ReplacementTypeEnum.verbConjugation, + ReplacementTypeEnum.verbTense, + ReplacementTypeEnum.verbMood, + ReplacementTypeEnum.subjectVerbAgreement, + ReplacementTypeEnum.genderAgreement, + ReplacementTypeEnum.numberAgreement, + ReplacementTypeEnum.caseError, + ReplacementTypeEnum.article, + ReplacementTypeEnum.preposition, + ReplacementTypeEnum.pronoun, + ReplacementTypeEnum.wordOrder, + ReplacementTypeEnum.negation, + ReplacementTypeEnum.questionFormation, + ReplacementTypeEnum.relativeClause, + ReplacementTypeEnum.connector, + ReplacementTypeEnum.possessive, + ReplacementTypeEnum.comparative, + ReplacementTypeEnum.passiveVoice, + ReplacementTypeEnum.conditional, + ReplacementTypeEnum.infinitiveGerund, + ReplacementTypeEnum.modal, + ]; + + /// Word choice types that require explanatory reasons for learning. + static const List wordChoiceTypes = [ + ReplacementTypeEnum.falseCognate, + ReplacementTypeEnum.l1Interference, + ReplacementTypeEnum.collocation, + ReplacementTypeEnum.semanticConfusion, + ]; + + /// Whether this type should be auto-applied without user interaction. + bool get isAutoApply => autoApplyTypes.contains(this); + + /// Whether this is a grammar-related type (for analytics grouping). + bool get isGrammarType => grammarTypes.contains(this); + + /// Whether this is a word-choice-related type (for analytics grouping). + bool get isWordChoiceType => wordChoiceTypes.contains(this); + + /// Convert enum to snake_case string for JSON serialization. + String get name { + switch (this) { + // Client-specific types + case ReplacementTypeEnum.definition: + return "definition"; + case ReplacementTypeEnum.practice: + return "practice"; + case ReplacementTypeEnum.itStart: + return "itStart"; + + // Grammar types + case ReplacementTypeEnum.verbConjugation: + return "verb_conjugation"; + case ReplacementTypeEnum.verbTense: + return "verb_tense"; + case ReplacementTypeEnum.verbMood: + return "verb_mood"; + case ReplacementTypeEnum.subjectVerbAgreement: + return "subject_verb_agreement"; + case ReplacementTypeEnum.genderAgreement: + return "gender_agreement"; + case ReplacementTypeEnum.numberAgreement: + return "number_agreement"; + case ReplacementTypeEnum.caseError: + return "case_error"; + case ReplacementTypeEnum.article: + return "article"; + case ReplacementTypeEnum.preposition: + return "preposition"; + case ReplacementTypeEnum.pronoun: + return "pronoun"; + case ReplacementTypeEnum.wordOrder: + return "word_order"; + case ReplacementTypeEnum.negation: + return "negation"; + case ReplacementTypeEnum.questionFormation: + return "question_formation"; + case ReplacementTypeEnum.relativeClause: + return "relative_clause"; + case ReplacementTypeEnum.connector: + return "connector"; + case ReplacementTypeEnum.possessive: + return "possessive"; + case ReplacementTypeEnum.comparative: + return "comparative"; + case ReplacementTypeEnum.passiveVoice: + return "passive_voice"; + case ReplacementTypeEnum.conditional: + return "conditional"; + case ReplacementTypeEnum.infinitiveGerund: + return "infinitive_gerund"; + case ReplacementTypeEnum.modal: + return "modal"; + + // Surface-level corrections + case ReplacementTypeEnum.punct: + return "punct"; + case ReplacementTypeEnum.diacritics: + return "diacritics"; + case ReplacementTypeEnum.spell: + return "spell"; + case ReplacementTypeEnum.cap: + return "cap"; + + // Word choice types + case ReplacementTypeEnum.falseCognate: + return "false_cognate"; + case ReplacementTypeEnum.l1Interference: + return "l1_interference"; + case ReplacementTypeEnum.collocation: + return "collocation"; + case ReplacementTypeEnum.semanticConfusion: + return "semantic_confusion"; + + // Higher-level suggestions + case ReplacementTypeEnum.transcription: + return "transcription"; + case ReplacementTypeEnum.style: + return "style"; + case ReplacementTypeEnum.fluency: + return "fluency"; + case ReplacementTypeEnum.didYouMean: + return "did_you_mean"; + case ReplacementTypeEnum.translation: + return "translation"; + case ReplacementTypeEnum.other: + return "other"; + } + } + + /// Parse type string from JSON, handling backward compatibility + /// for old saved data and snake_case to camelCase conversion. + static ReplacementTypeEnum? fromString(String? typeString) { + if (typeString == null) return null; + + // Normalize snake_case to camelCase and handle backward compatibility + final normalized = switch (typeString) { + // Legacy mappings - grammar and word_choice were split into subtypes + 'correction' => 'subjectVerbAgreement', // Legacy fallback + 'grammar' => 'subjectVerbAgreement', // Legacy fallback + 'word_choice' => 'semanticConfusion', // Legacy fallback + + // Snake_case to camelCase conversions - grammar types + 'did_you_mean' => 'didYouMean', + 'verb_conjugation' => 'verbConjugation', + 'verb_tense' => 'verbTense', + 'verb_mood' => 'verbMood', + 'subject_verb_agreement' => 'subjectVerbAgreement', + 'gender_agreement' => 'genderAgreement', + 'number_agreement' => 'numberAgreement', + 'case_error' => 'caseError', + 'word_order' => 'wordOrder', + 'question_formation' => 'questionFormation', + 'relative_clause' => 'relativeClause', + 'passive_voice' => 'passiveVoice', + 'infinitive_gerund' => 'infinitiveGerund', + + // Snake_case to camelCase conversions - word choice types + 'false_cognate' => 'falseCognate', + 'l1_interference' => 'l1Interference', + 'semantic_confusion' => 'semanticConfusion', + // 'collocation' is already single word, no conversion needed + + // Already camelCase or single word - pass through + _ => typeString, + }; + + return ReplacementTypeEnum.values.firstWhereOrNull( + (e) => e.name == normalized || e.toString().split('.').last == normalized, + ); + } + + String defaultPrompt(BuildContext context) { + switch (this) { + case ReplacementTypeEnum.definition: + return L10n.of(context).definitionDefaultPrompt; + case ReplacementTypeEnum.practice: + return L10n.of(context).practiceDefaultPrompt; + case ReplacementTypeEnum.itStart: + return L10n.of(context).needsItMessage( + MatrixState.pangeaController.userController.userL2 + ?.getDisplayName(context) ?? + L10n.of(context).targetLanguage, + ); + // All grammar types and other corrections use the same default prompt + default: + return L10n.of(context).correctionDefaultPrompt; + } + } + + /// Returns the underline color for this replacement type. + /// Used to visually distinguish different error categories in the text field. + Color underlineColor() { + // IT start and auto-apply types use primary color + if (this == ReplacementTypeEnum.itStart || isAutoApply) { + return AppConfig.primaryColor.withAlpha(180); + } + // Grammar errors use warning/orange + if (isGrammarType) { + return AppConfig.warning.withAlpha(180); + } + // Word choice uses blue + if (isWordChoiceType) { + return Colors.blue.withAlpha(180); + } + // Style and fluency use teal + switch (this) { + case ReplacementTypeEnum.style: + case ReplacementTypeEnum.fluency: + return Colors.teal.withAlpha(180); + default: + // Other/unknown use error color + return AppConfig.error.withAlpha(180); + } + } + + /// Returns a human-readable display name for this replacement type. + /// Used in the SpanCard UI to show the error category. + String displayName(BuildContext context) { + if (isGrammarType) { + return L10n.of(context).spanTypeGrammar; + } + if (isWordChoiceType) { + return L10n.of(context).spanTypeWordChoice; + } + switch (this) { + case ReplacementTypeEnum.spell: + return L10n.of(context).spanTypeSpelling; + case ReplacementTypeEnum.punct: + return L10n.of(context).spanTypePunctuation; + case ReplacementTypeEnum.style: + return L10n.of(context).spanTypeStyle; + case ReplacementTypeEnum.fluency: + return L10n.of(context).spanTypeFluency; + case ReplacementTypeEnum.diacritics: + return L10n.of(context).spanTypeAccents; + case ReplacementTypeEnum.cap: + return L10n.of(context).spanTypeCapitalization; + default: + return L10n.of(context).spanTypeCorrection; + } + } +} diff --git a/lib/pangea/choreographer/igc/span_card.dart b/lib/pangea/choreographer/igc/span_card.dart index a69225650..3b8ca6715 100644 --- a/lib/pangea/choreographer/igc/span_card.dart +++ b/lib/pangea/choreographer/igc/span_card.dart @@ -4,14 +4,15 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/bot/utils/bot_style.dart'; +import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart'; import 'package:fluffychat/pangea/choreographer/choreographer.dart'; import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart'; import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart'; +import 'package:fluffychat/pangea/choreographer/igc/replacement_type_enum.dart'; import 'package:fluffychat/pangea/choreographer/igc/span_choice_type_enum.dart'; import 'package:fluffychat/pangea/choreographer/igc/span_data_model.dart'; -import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; +import 'package:fluffychat/pangea/common/widgets/feedback_dialog.dart'; import '../../../widgets/matrix.dart'; import '../../common/widgets/choice_array.dart'; @@ -19,12 +20,14 @@ class SpanCard extends StatefulWidget { final PangeaMatchState match; final Choreographer choreographer; final VoidCallback showNextMatch; + final Future Function(String) onFeedbackSubmitted; const SpanCard({ super.key, required this.match, required this.choreographer, required this.showNextMatch, + required this.onFeedbackSubmitted, }); @override @@ -32,95 +35,24 @@ class SpanCard extends StatefulWidget { } class SpanCardState extends State { - bool _loadingChoices = true; - final ValueNotifier> _feedbackState = - ValueNotifier>(const AsyncIdle()); - final ScrollController scrollController = ScrollController(); @override void initState() { super.initState(); - _fetchChoices(); } @override void dispose() { - _feedbackState.dispose(); scrollController.dispose(); super.dispose(); } - List? get _choices => widget.match.updatedMatch.match.choices; - SpanChoice? get _selectedChoice => widget.match.updatedMatch.match.selectedChoice; - String? get _selectedFeedback => _selectedChoice?.feedback; - - Future _fetchChoices() async { - if (_choices != null && _choices!.length > 1) { - setState(() => _loadingChoices = false); - return; - } - - setState(() => _loadingChoices = true); - - try { - await widget.choreographer.igcController.fetchSpanDetails( - match: widget.match, - ); - - if (_choices == null || _choices!.isEmpty) { - widget.choreographer.clearMatches( - 'No choices available for span ${widget.match.updatedMatch.match.message}', - ); - } - } catch (e) { - widget.choreographer.clearMatches(e); - } finally { - if (mounted) { - setState(() => _loadingChoices = false); - } - } - } - - Future _fetchFeedback() async { - if (_selectedFeedback != null) { - _feedbackState.value = AsyncLoaded(_selectedFeedback!); - return; - } - - try { - _feedbackState.value = const AsyncLoading(); - await widget.choreographer.igcController.fetchSpanDetails( - match: widget.match, - force: true, - ); - - if (!mounted) return; - if (_selectedFeedback != null) { - _feedbackState.value = AsyncLoaded(_selectedFeedback!); - } else { - _feedbackState.value = AsyncError( - L10n.of(context).failedToLoadFeedback, - ); - } - } catch (e) { - if (mounted) { - _feedbackState.value = AsyncError(e); - } - } - } - void _onChoiceSelect(int index) { - final selected = _choices![index]; widget.match.selectChoice(index); - - _feedbackState.value = selected.feedback != null - ? AsyncLoaded(selected.feedback!) - : const AsyncIdle(); - setState(() {}); } @@ -145,12 +77,54 @@ class SpanCardState extends State { } } + Future _showFeedbackDialog() async { + final resp = await showDialog( + context: context, + builder: (context) => FeedbackDialog( + title: L10n.of(context).spanFeedbackTitle, + onSubmit: (feedback) => Navigator.of(context).pop(feedback), + ), + ); + if (resp == null || resp.isEmpty) { + return; + } + + await widget.onFeedbackSubmitted(resp); + } + @override Widget build(BuildContext context) { return SizedBox( height: 300.0, child: Column( children: [ + // Header row: Close, Type Label + BotFace, Flag + SizedBox( + height: 40.0, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + icon: const Icon(Icons.close), + color: Theme.of(context).iconTheme.color, + onPressed: () => _updateMatch(PangeaMatchStatusEnum.ignored), + ), + const Flexible( + child: Center( + child: BotFace( + width: 32.0, + expression: BotExpression.idle, + ), + ), + ), + IconButton( + icon: const Icon(Icons.flag_outlined), + color: Theme.of(context).iconTheme.color, + onPressed: _showFeedbackDialog, + ), + ], + ), + ), Expanded( child: Scrollbar( controller: scrollController, @@ -165,13 +139,13 @@ class SpanCardState extends State { spacing: 12.0, children: [ ChoicesArray( - isLoading: _loadingChoices, + isLoading: false, choices: widget.match.updatedMatch.match.choices ?.map( (e) => Choice( text: e.value, color: e.selected ? e.type.color : null, - isGold: e.type.name == 'bestCorrection', + isGold: e.type.isSuggestion, ), ) .toList(), @@ -184,9 +158,7 @@ class SpanCardState extends State { ), const SizedBox(), _SpanCardFeedback( - _selectedChoice != null, - _fetchFeedback, - _feedbackState, + widget.match.updatedMatch.match, ), ], ), @@ -206,48 +178,32 @@ class SpanCardState extends State { } class _SpanCardFeedback extends StatelessWidget { - final bool hasSelectedChoice; - final VoidCallback fetchFeedback; - final ValueNotifier> feedbackState; - - const _SpanCardFeedback( - this.hasSelectedChoice, - this.fetchFeedback, - this.feedbackState, - ); + final SpanData? span; + const _SpanCardFeedback(this.span); @override Widget build(BuildContext context) { + String prompt = L10n.of(context).correctionDefaultPrompt; + if (span != null) { + prompt = span!.type.defaultPrompt(context); + } + + final defaultContent = Text( + prompt, + style: BotStyle.text(context).copyWith( + fontStyle: FontStyle.italic, + ), + ); + return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - ValueListenableBuilder( - valueListenable: feedbackState, - builder: (context, state, __) { - return switch (state) { - AsyncIdle() => hasSelectedChoice - ? IconButton( - onPressed: fetchFeedback, - icon: const Icon(Icons.lightbulb_outline, size: 24), - ) - : Text( - L10n.of(context).correctionDefaultPrompt, - style: BotStyle.text(context).copyWith( - fontStyle: FontStyle.italic, - ), - ), - AsyncLoading() => const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(), - ), - AsyncError(:final error) => - ErrorIndicator(message: error.toString()), - AsyncLoaded(:final value) => - Text(value, style: BotStyle.text(context)), - }; - }, - ), + span == null || span!.selectedChoice == null + ? defaultContent + : Text( + span!.selectedChoice!.feedbackToDisplay(context), + style: BotStyle.text(context), + ), ], ); } diff --git a/lib/pangea/choreographer/igc/span_choice_type_enum.dart b/lib/pangea/choreographer/igc/span_choice_type_enum.dart index af8dccb32..94e03fbba 100644 --- a/lib/pangea/choreographer/igc/span_choice_type_enum.dart +++ b/lib/pangea/choreographer/igc/span_choice_type_enum.dart @@ -3,53 +3,64 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; enum SpanChoiceTypeEnum { - bestCorrection, + suggestion, + alt, distractor, + @Deprecated('Use suggestion instead') + bestCorrection, + @Deprecated('Use suggestion instead') bestAnswer, } extension SpanChoiceExt on SpanChoiceTypeEnum { - String get name { - switch (this) { - case SpanChoiceTypeEnum.bestCorrection: - return "bestCorrection"; - case SpanChoiceTypeEnum.distractor: - return "distractor"; - case SpanChoiceTypeEnum.bestAnswer: - return "bestAnswer"; - } - } + bool get isSuggestion => + this == SpanChoiceTypeEnum.suggestion || + // ignore: deprecated_member_use_from_same_package + this == SpanChoiceTypeEnum.bestCorrection || + // ignore: deprecated_member_use_from_same_package + this == SpanChoiceTypeEnum.bestAnswer; String defaultFeedback(BuildContext context) { switch (this) { + case SpanChoiceTypeEnum.suggestion: + // ignore: deprecated_member_use_from_same_package case SpanChoiceTypeEnum.bestCorrection: return L10n.of(context).bestCorrectionFeedback; - case SpanChoiceTypeEnum.distractor: - return L10n.of(context).distractorFeedback; + case SpanChoiceTypeEnum.alt: + // ignore: deprecated_member_use_from_same_package case SpanChoiceTypeEnum.bestAnswer: return L10n.of(context).bestAnswerFeedback; + case SpanChoiceTypeEnum.distractor: + return L10n.of(context).distractorFeedback; } } IconData get icon { switch (this) { + case SpanChoiceTypeEnum.suggestion: + // ignore: deprecated_member_use_from_same_package case SpanChoiceTypeEnum.bestCorrection: + case SpanChoiceTypeEnum.alt: + // ignore: deprecated_member_use_from_same_package + case SpanChoiceTypeEnum.bestAnswer: return Icons.check_circle; case SpanChoiceTypeEnum.distractor: return Icons.cancel; - case SpanChoiceTypeEnum.bestAnswer: - return Icons.check_circle; } } Color get color { switch (this) { + case SpanChoiceTypeEnum.suggestion: + // ignore: deprecated_member_use_from_same_package case SpanChoiceTypeEnum.bestCorrection: return Colors.green; + case SpanChoiceTypeEnum.alt: + // ignore: deprecated_member_use_from_same_package + case SpanChoiceTypeEnum.bestAnswer: + return Colors.green; case SpanChoiceTypeEnum.distractor: return Colors.red; - case SpanChoiceTypeEnum.bestAnswer: - return Colors.green; } } } diff --git a/lib/pangea/choreographer/igc/span_data_model.dart b/lib/pangea/choreographer/igc/span_data_model.dart index ac65a1347..fea744140 100644 --- a/lib/pangea/choreographer/igc/span_data_model.dart +++ b/lib/pangea/choreographer/igc/span_data_model.dart @@ -4,8 +4,8 @@ import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/choreographer/igc/text_normalization_util.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'replacement_type_enum.dart'; import 'span_choice_type_enum.dart'; -import 'span_data_type_enum.dart'; class SpanData { final String? message; @@ -14,7 +14,7 @@ class SpanData { final int offset; final int length; final String fullText; - final SpanDataType type; + final ReplacementTypeEnum type; final Rule? rule; SpanData({ @@ -35,7 +35,7 @@ class SpanData { int? offset, int? length, String? fullText, - SpanDataType? type, + ReplacementTypeEnum? type, Rule? rule, }) { return SpanData( @@ -50,8 +50,28 @@ class SpanData { ); } - factory SpanData.fromJson(Map json) { + /// Parse SpanData from JSON. + /// + /// [parentFullText] is used as fallback when the span JSON doesn't contain + /// full_text (e.g., when the server omits it to reduce payload size and + /// the full text is available at the response level as original_input). + factory SpanData.fromJson( + Map json, { + String? parentFullText, + }) { final Iterable? choices = json['choices'] ?? json['replacements']; + final dynamic rawType = + json['type'] ?? json['type_name'] ?? json['typeName']; + final String? typeString = rawType is Map + ? (rawType['type_name'] ?? rawType['type'] ?? rawType['typeName']) + as String? + : rawType as String?; + + // Try to get fullText from span JSON, fall back to parent's original_input + final String? spanFullText = + json['sentence'] ?? json['full_text'] ?? json['fullText']; + final String fullText = spanFullText ?? parentFullText ?? ''; + return SpanData( message: json['message'], shortMessage: json['shortMessage'] ?? json['short_message'], @@ -62,9 +82,9 @@ class SpanData { .toList(), offset: json['offset'] as int, length: json['length'] as int, - fullText: - json['sentence'] ?? json['full_text'] ?? json['fullText'] as String, - type: SpanDataType.fromJson(json['type'] as Map), + fullText: fullText, + type: SpanDataTypeEnumExt.fromString(typeString) ?? + ReplacementTypeEnum.other, rule: json['rule'] != null ? Rule.fromJson(json['rule'] as Map) : null, @@ -76,7 +96,7 @@ class SpanData { 'offset': offset, 'length': length, 'full_text': fullText, - 'type': type.toJson(), + 'type': type.name, }; if (message != null) { @@ -135,7 +155,17 @@ class SpanData { String get errorSpan => fullText.characters.skip(offset).take(length).toString(); + /// Whether this span is a minor correction that should be auto-applied. + /// Returns true if: + /// 1. The type is explicitly marked as auto-apply (e.g., punct, spell, cap, diacritics), OR + /// 2. For backwards compatibility with old data that lacks new types: + /// the type is NOT auto-apply AND the normalized strings match. bool isNormalizationError() { + // New data with explicit auto-apply types + if (type.isAutoApply) { + return true; + } + final correctChoice = choices ?.firstWhereOrNull( (c) => c.isBestCorrection, @@ -227,8 +257,8 @@ class SpanChoice { ? SpanChoiceTypeEnum.values.firstWhereOrNull( (element) => element.name == json['type'], ) ?? - SpanChoiceTypeEnum.bestCorrection - : SpanChoiceTypeEnum.bestCorrection, + SpanChoiceTypeEnum.suggestion + : SpanChoiceTypeEnum.suggestion, feedback: json['feedback'], selected: json['selected'] ?? false, timestamp: @@ -242,18 +272,15 @@ class SpanChoice { 'type': type.name, }; - if (selected) { - data['selected'] = selected; + // V2 format: use selected_at instead of separate selected + timestamp + if (selected && timestamp != null) { + data['selected_at'] = timestamp!.toIso8601String(); } if (feedback != null) { data['feedback'] = feedback; } - if (timestamp != null) { - data['timestamp'] = timestamp!.toIso8601String(); - } - return data; } @@ -264,7 +291,7 @@ class SpanChoice { return feedback!; } - bool get isBestCorrection => type == SpanChoiceTypeEnum.bestCorrection; + bool get isBestCorrection => type.isSuggestion; Color get color => type.color; @@ -318,39 +345,3 @@ class Rule { return id.hashCode; } } - -class SpanDataType { - final SpanDataTypeEnum typeName; - - const SpanDataType({ - required this.typeName, - }); - - factory SpanDataType.fromJson(Map json) { - final String? type = - json['typeName'] ?? json['type'] ?? json['type_name'] as String?; - return SpanDataType( - typeName: type != null - ? SpanDataTypeEnum.values - .firstWhereOrNull((element) => element.name == type) ?? - SpanDataTypeEnum.correction - : SpanDataTypeEnum.correction, - ); - } - - Map toJson() => { - 'type_name': typeName.name, - }; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is! SpanDataType) return false; - return other.typeName == typeName; - } - - @override - int get hashCode { - return typeName.hashCode; - } -} diff --git a/lib/pangea/choreographer/igc/span_data_type_enum.dart b/lib/pangea/choreographer/igc/span_data_type_enum.dart deleted file mode 100644 index d3cfa4b87..000000000 --- a/lib/pangea/choreographer/igc/span_data_type_enum.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -enum SpanDataTypeEnum { - definition, - practice, - correction, - itStart, -} - -extension SpanDataTypeEnumExt on SpanDataTypeEnum { - String get name { - switch (this) { - case SpanDataTypeEnum.definition: - return "definition"; - case SpanDataTypeEnum.practice: - return "practice"; - case SpanDataTypeEnum.correction: - return "correction"; - case SpanDataTypeEnum.itStart: - return "itStart"; - } - } - - String defaultPrompt(BuildContext context) { - switch (this) { - case SpanDataTypeEnum.definition: - return L10n.of(context).definitionDefaultPrompt; - case SpanDataTypeEnum.practice: - return L10n.of(context).practiceDefaultPrompt; - case SpanDataTypeEnum.correction: - return L10n.of(context).correctionDefaultPrompt; - case SpanDataTypeEnum.itStart: - return L10n.of(context).needsItMessage( - MatrixState.pangeaController.userController.userL2 - ?.getDisplayName(context) ?? - L10n.of(context).targetLanguage, - ); - } - } -} diff --git a/lib/pangea/choreographer/text_editing/pangea_text_controller.dart b/lib/pangea/choreographer/text_editing/pangea_text_controller.dart index 26691b94c..73b0e1d98 100644 --- a/lib/pangea/choreographer/text_editing/pangea_text_controller.dart +++ b/lib/pangea/choreographer/text_editing/pangea_text_controller.dart @@ -2,12 +2,13 @@ import 'package:flutter/material.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/choreographer/choreo_constants.dart'; import 'package:fluffychat/pangea/choreographer/igc/autocorrect_span.dart'; -import 'package:fluffychat/pangea/choreographer/igc/match_rule_id_model.dart'; import 'package:fluffychat/pangea/choreographer/igc/pangea_match_model.dart'; import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart'; import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart'; +import 'package:fluffychat/pangea/choreographer/igc/replacement_type_enum.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -34,19 +35,13 @@ class PangeaTextController extends TextEditingController { ); Color _underlineColor(PangeaMatch match) { + // Automatic corrections use primary color if (match.status == PangeaMatchStatusEnum.automatic) { - return const Color.fromARGB(187, 132, 96, 224); + return AppConfig.primaryColor.withAlpha(180); } - switch (match.match.rule?.id ?? "unknown") { - case MatchRuleIdModel.interactiveTranslation: - return const Color.fromARGB(187, 132, 96, 224); - case MatchRuleIdModel.tokenNeedsTranslation: - case MatchRuleIdModel.tokenSpanNeedsTranslation: - return const Color.fromARGB(186, 255, 132, 0); - default: - return const Color.fromARGB(149, 255, 17, 0); - } + // Use type-based coloring + return match.match.type.underlineColor(); } TextStyle _textStyle( diff --git a/lib/pangea/common/constants/model_keys.dart b/lib/pangea/common/constants/model_keys.dart index 445c5b8ff..74d28e6e3 100644 --- a/lib/pangea/common/constants/model_keys.dart +++ b/lib/pangea/common/constants/model_keys.dart @@ -107,6 +107,8 @@ class ModelKey { static const String currentText = "current"; static const String bestContinuance = "best_continuance"; static const String feedbackLang = "feedback_lang"; + static const String feedback = "feedback"; + static const String content = "content"; static const String transcription = "transcription"; static const String botTranscription = 'bot_transcription'; diff --git a/lib/pangea/common/models/base_request_model.dart b/lib/pangea/common/models/base_request_model.dart new file mode 100644 index 000000000..2ffd77a27 --- /dev/null +++ b/lib/pangea/common/models/base_request_model.dart @@ -0,0 +1,41 @@ +import 'package:fluffychat/pangea/common/constants/model_keys.dart'; +import 'package:fluffychat/pangea/learning_settings/gender_enum.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +/// Base request schema matching the backend's BaseRequestSchema. +/// Common fields for all LLM-based requests. +mixin BaseRequestModel { + /// User's native language code (L1) + String get userL1; + + /// User's target language code (L2) + String get userL2; + + /// User's CEFR proficiency level (defaults to "pre_a1") + String get userCefr; + + /// Convert to JSON map with common fields + Map toBaseJson() => { + ModelKey.userL1: userL1, + ModelKey.userL2: userL2, + ModelKey.cefrLevel: userCefr, + ModelKey.userGender: MatrixState + .pangeaController.userController.profile.userSettings.gender.string, + }; + + /// Injects user context (CEFR level, gender) into a request body. + /// Safely handles cases where MatrixState is not yet initialized. + /// Does not overwrite existing values. + static Map injectUserContext(Map body) { + final result = Map.from(body); + try { + final settings = + MatrixState.pangeaController.userController.profile.userSettings; + result[ModelKey.cefrLevel] ??= settings.cefrLevel.string; + result[ModelKey.userGender] ??= settings.gender.string; + } catch (_) { + // MatrixState not initialized - leave existing values or omit + } + return result; + } +} diff --git a/lib/pangea/common/models/llm_feedback_model.dart b/lib/pangea/common/models/llm_feedback_model.dart new file mode 100644 index 000000000..29e0a83ee --- /dev/null +++ b/lib/pangea/common/models/llm_feedback_model.dart @@ -0,0 +1,25 @@ +import 'package:fluffychat/pangea/common/constants/model_keys.dart'; + +/// Generic feedback schema matching the backend's LLMFeedbackSchema. +/// Used for providing user corrections to LLM-generated content. +class LLMFeedbackModel { + /// User's feedback text describing the issue + final String feedback; + + /// Original response that user is providing feedback on + final T content; + + /// Function to serialize the content to JSON + final Map Function(T) contentToJson; + + const LLMFeedbackModel({ + required this.feedback, + required this.content, + required this.contentToJson, + }); + + Map toJson() => { + ModelKey.feedback: feedback, + ModelKey.content: contentToJson(content), + }; +} diff --git a/lib/pangea/common/network/requests.dart b/lib/pangea/common/network/requests.dart index 4cf4e792c..7481db2fd 100644 --- a/lib/pangea/common/network/requests.dart +++ b/lib/pangea/common/network/requests.dart @@ -5,9 +5,7 @@ import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:fluffychat/pangea/common/constants/model_keys.dart'; -import 'package:fluffychat/pangea/learning_settings/gender_enum.dart'; -import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/pangea/common/models/base_request_model.dart'; class Requests { late String? accessToken; @@ -22,13 +20,10 @@ class Requests { required String url, required Map body, }) async { - body[ModelKey.cefrLevel] = MatrixState - .pangeaController.userController.profile.userSettings.cefrLevel.string; - body[ModelKey.userGender] = MatrixState - .pangeaController.userController.profile.userSettings.gender.string; + final enrichedBody = BaseRequestModel.injectUserContext(body); dynamic encoded; - encoded = jsonEncode(body); + encoded = jsonEncode(enrichedBody); final http.Response response = await http.post( Uri.parse(url), diff --git a/lib/pangea/common/network/urls.dart b/lib/pangea/common/network/urls.dart index a9b027e01..e8102bf55 100644 --- a/lib/pangea/common/network/urls.dart +++ b/lib/pangea/common/network/urls.dart @@ -29,7 +29,7 @@ class PApiUrls { static String languageDetection = "${PApiUrls._choreoEndpoint}/language_detection"; - static String igcLite = "${PApiUrls._choreoEndpoint}/grammar_lite"; + static String igcLite = "${PApiUrls._choreoEndpoint}/grammar_v2"; static String spanDetails = "${PApiUrls._choreoEndpoint}/span_details"; static String simpleTranslation = diff --git a/lib/pangea/common/utils/overlay.dart b/lib/pangea/common/utils/overlay.dart index 23bc2ff27..d7ecad999 100644 --- a/lib/pangea/common/utils/overlay.dart +++ b/lib/pangea/common/utils/overlay.dart @@ -226,6 +226,7 @@ class OverlayUtil { Choreographer choreographer, BuildContext context, VoidCallback showNextMatch, + Future Function(String) onFeedbackSubmitted, ) { MatrixState.pAnyState.closeAllOverlays(); showPositionedCard( @@ -236,6 +237,7 @@ class OverlayUtil { match: match, choreographer: choreographer, showNextMatch: showNextMatch, + onFeedbackSubmitted: onFeedbackSubmitted, ), maxHeight: 325, maxWidth: 325, diff --git a/test/pangea/igc_response_model_test.dart b/test/pangea/igc_response_model_test.dart new file mode 100644 index 000000000..d26e40151 --- /dev/null +++ b/test/pangea/igc_response_model_test.dart @@ -0,0 +1,246 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/pangea/choreographer/igc/igc_response_model.dart'; + +void main() { + group('IGCResponseModel.fromJson', () { + test('passes originalInput to matches as fullText fallback', () { + final Map jsonData = { + 'original_input': 'I want to know the United States', + 'full_text_correction': null, + 'matches': [ + { + 'match': { + 'message': 'Grammar error', + 'short_message': 'grammar', + 'choices': [ + {'value': 'learn about', 'type': 'bestCorrection'}, + ], + 'offset': 10, + 'length': 4, + // Note: no full_text in match - should use original_input + 'type': 'grammar', + }, + 'status': 'open', + }, + ], + 'user_l1': 'en', + 'user_l2': 'es', + 'enable_it': true, + 'enable_igc': true, + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.matches.length, 1); + expect( + response.matches[0].match.fullText, + 'I want to know the United States', + ); + }); + + test('match full_text takes precedence over originalInput', () { + final Map jsonData = { + 'original_input': 'Original input text', + 'full_text_correction': null, + 'matches': [ + { + 'match': { + 'message': 'Grammar error', + 'short_message': 'grammar', + 'choices': [ + {'value': 'correction', 'type': 'bestCorrection'}, + ], + 'offset': 0, + 'length': 5, + 'full_text': 'Full text from span', // This should take precedence + 'type': 'grammar', + }, + 'status': 'open', + }, + ], + 'user_l1': 'en', + 'user_l2': 'es', + 'enable_it': true, + 'enable_igc': true, + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.matches.length, 1); + expect(response.matches[0].match.fullText, 'Full text from span'); + }); + + test('handles empty matches array', () { + final Map jsonData = { + 'original_input': 'Clean text with no errors', + 'full_text_correction': null, + 'matches': [], + 'user_l1': 'en', + 'user_l2': 'es', + 'enable_it': true, + 'enable_igc': true, + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.matches.length, 0); + expect(response.originalInput, 'Clean text with no errors'); + }); + + test('handles null matches', () { + final Map jsonData = { + 'original_input': 'Text', + 'full_text_correction': null, + 'matches': null, + 'user_l1': 'en', + 'user_l2': 'es', + 'enable_it': true, + 'enable_igc': true, + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.matches.length, 0); + }); + }); + + group('IGCResponseModel V2 format compatibility', () { + test('parses V2 response without enable_it and enable_igc', () { + // V2 response format from /choreo/grammar_v2 endpoint + final Map jsonData = { + 'original_input': 'Me gusta el café', + 'matches': [ + { + // V2 format: flat SpanData, no "match" wrapper + 'choices': [ + { + 'value': 'Me encanta', + 'type': 'bestCorrection', + 'feedback': 'Use "encantar" for expressing love', + }, + ], + 'offset': 0, + 'length': 8, + 'type': 'vocabulary', + }, + ], + 'user_l1': 'en', + 'user_l2': 'es', + // Note: no enable_it, enable_igc in V2 response + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.originalInput, 'Me gusta el café'); + expect(response.userL1, 'en'); + expect(response.userL2, 'es'); + // Should default to true when not present + expect(response.enableIT, true); + expect(response.enableIGC, true); + expect(response.matches.length, 1); + expect(response.matches[0].match.offset, 0); + expect(response.matches[0].match.length, 8); + }); + + test('parses V2 response with empty matches', () { + final Map jsonData = { + 'original_input': 'Perfect sentence with no errors', + 'matches': [], + 'user_l1': 'en', + 'user_l2': 'fr', + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.matches.length, 0); + expect(response.enableIT, true); + expect(response.enableIGC, true); + expect(response.fullTextCorrection, isNull); + }); + + test('parses V2 response with multiple matches', () { + final Map jsonData = { + 'original_input': 'Yo soy ir a la tienda', + 'matches': [ + { + 'choices': [ + { + 'value': 'voy', + 'type': 'bestCorrection', + 'feedback': 'Use conjugated form', + }, + ], + 'offset': 7, + 'length': 2, + 'type': 'grammar', + }, + { + 'choices': [ + {'value': 'Voy', 'type': 'bestCorrection'}, + ], + 'offset': 0, + 'length': 6, + 'type': 'grammar', + }, + ], + 'user_l1': 'en', + 'user_l2': 'es', + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.matches.length, 2); + expect(response.matches[0].match.offset, 7); + expect(response.matches[1].match.offset, 0); + }); + + test('V1 format with explicit enable_it=false still works', () { + final Map jsonData = { + 'original_input': 'Test text', + 'full_text_correction': 'Corrected text', + 'matches': [], + 'user_l1': 'en', + 'user_l2': 'es', + 'enable_it': false, + 'enable_igc': false, + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.enableIT, false); + expect(response.enableIGC, false); + expect(response.fullTextCorrection, 'Corrected text'); + }); + + test('V2 response choice includes feedback field', () { + final Map jsonData = { + 'original_input': 'Je suis alle', + 'matches': [ + { + 'choices': [ + { + 'value': 'allé', + 'type': 'bestCorrection', + 'feedback': 'Add accent to past participle', + }, + ], + 'offset': 8, + 'length': 4, + 'type': 'diacritics', + }, + ], + 'user_l1': 'en', + 'user_l2': 'fr', + }; + + final IGCResponseModel response = IGCResponseModel.fromJson(jsonData); + + expect(response.matches.length, 1); + expect( + response.matches[0].match.bestChoice?.feedback, + 'Add accent to past participle', + ); + }); + }); +} diff --git a/test/pangea/pangea_match_model_test.dart b/test/pangea/pangea_match_model_test.dart new file mode 100644 index 000000000..73783dcd8 --- /dev/null +++ b/test/pangea/pangea_match_model_test.dart @@ -0,0 +1,179 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/pangea/choreographer/igc/pangea_match_model.dart'; +import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart'; + +void main() { + group('PangeaMatch.fromJson', () { + group('V1 format (wrapped with match key)', () { + test('parses match wrapper correctly', () { + final Map jsonData = { + 'match': { + 'message': 'Grammar error', + 'short_message': 'grammar', + 'choices': [ + {'value': 'correction', 'type': 'bestCorrection'}, + ], + 'offset': 10, + 'length': 4, + 'full_text': 'Some full text', + 'type': 'grammar', + }, + 'status': 'open', + }; + + final PangeaMatch match = PangeaMatch.fromJson(jsonData); + + expect(match.match.offset, 10); + expect(match.match.length, 4); + expect(match.match.fullText, 'Some full text'); + expect(match.status, PangeaMatchStatusEnum.open); + }); + + test('uses parentFullText as fallback when no full_text in match', () { + final Map jsonData = { + 'match': { + 'message': 'Error', + 'choices': [ + {'value': 'fix', 'type': 'bestCorrection'}, + ], + 'offset': 5, + 'length': 3, + 'type': 'grammar', + }, + 'status': 'open', + }; + + final PangeaMatch match = PangeaMatch.fromJson( + jsonData, + fullText: 'Parent original input', + ); + + expect(match.match.fullText, 'Parent original input'); + }); + + test('parses status from V1 format', () { + final Map jsonData = { + 'match': { + 'message': 'Error', + 'choices': [], + 'offset': 0, + 'length': 1, + 'type': 'grammar', + }, + 'status': 'accepted', + }; + + final PangeaMatch match = PangeaMatch.fromJson(jsonData); + + expect(match.status, PangeaMatchStatusEnum.accepted); + }); + }); + + group('V2 format (flat SpanData)', () { + test('parses flat SpanData correctly', () { + final Map jsonData = { + 'message': 'Grammar error', + 'short_message': 'grammar', + 'choices': [ + {'value': 'correction', 'type': 'bestCorrection'}, + ], + 'offset': 10, + 'length': 4, + 'type': 'grammar', + }; + + final PangeaMatch match = PangeaMatch.fromJson(jsonData); + + expect(match.match.offset, 10); + expect(match.match.length, 4); + expect(match.match.message, 'Grammar error'); + // V2 format always defaults to open status + expect(match.status, PangeaMatchStatusEnum.open); + }); + + test('uses parentFullText when provided', () { + final Map jsonData = { + 'message': 'Error', + 'choices': [ + {'value': 'fix', 'type': 'bestCorrection'}, + ], + 'offset': 5, + 'length': 3, + 'type': 'vocabulary', + }; + + final PangeaMatch match = PangeaMatch.fromJson( + jsonData, + fullText: 'The original input text', + ); + + expect(match.match.fullText, 'The original input text'); + }); + + test('parses type as string in V2 format', () { + final Map jsonData = { + 'message': 'Out of target', + 'choices': [], + 'offset': 0, + 'length': 5, + 'type': 'itStart', // String type in V2 + }; + + final PangeaMatch match = PangeaMatch.fromJson(jsonData); + + expect(match.isITStart, true); + }); + + test('handles V2 format with string type grammar', () { + final Map jsonData = { + 'message': 'Tense error', + 'choices': [ + {'value': 'went', 'type': 'bestCorrection'}, + ], + 'offset': 2, + 'length': 4, + 'type': 'grammar', // String type in V2 + }; + + final PangeaMatch match = PangeaMatch.fromJson(jsonData); + + expect(match.isGrammarMatch, true); + expect(match.isITStart, false); + }); + }); + + group('backward compatibility', () { + test('V1 format with type as object still works', () { + final Map jsonData = { + 'match': { + 'message': 'Error', + 'choices': [], + 'offset': 0, + 'length': 1, + 'type': {'type_name': 'grammar'}, // Old object format + }, + 'status': 'open', + }; + + final PangeaMatch match = PangeaMatch.fromJson(jsonData); + + expect(match.isGrammarMatch, true); + }); + + test('V2 format with type as string works', () { + final Map jsonData = { + 'message': 'Error', + 'choices': [], + 'offset': 0, + 'length': 1, + 'type': 'grammar', // New string format + }; + + final PangeaMatch match = PangeaMatch.fromJson(jsonData); + + expect(match.isGrammarMatch, true); + }); + }); + }); +} diff --git a/test/pangea/span_data_model_test.dart b/test/pangea/span_data_model_test.dart new file mode 100644 index 000000000..cef2755c2 --- /dev/null +++ b/test/pangea/span_data_model_test.dart @@ -0,0 +1,178 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fluffychat/pangea/choreographer/igc/replacement_type_enum.dart'; +import 'package:fluffychat/pangea/choreographer/igc/span_data_model.dart'; + +void main() { + test('SpanData.fromJson handles legacy correction type (maps to grammar)', + () { + final Map legacyJson = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'full_text': 'Test', + 'type': { + 'type_name': 'correction', + }, + }; + + expect(() => SpanData.fromJson(legacyJson), returnsNormally); + final SpanData span = SpanData.fromJson(legacyJson); + // 'correction' is mapped to 'grammar' for backward compatibility + expect(span.type, ReplacementTypeEnum.subjectVerbAgreement); + }); + + test('SpanData.fromJson handles legacy typeName object', () { + final Map legacyJson = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'full_text': 'Test', + 'type': { + 'typeName': 'itStart', + }, + }; + + expect(() => SpanData.fromJson(legacyJson), returnsNormally); + final SpanData span = SpanData.fromJson(legacyJson); + expect(span.type, ReplacementTypeEnum.itStart); + }); + + test('SpanData.fromJson handles did_you_mean string', () { + final Map jsonData = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'full_text': 'Test', + 'type': 'did_you_mean', + }; + + expect(() => SpanData.fromJson(jsonData), returnsNormally); + final SpanData span = SpanData.fromJson(jsonData); + expect(span.type, ReplacementTypeEnum.didYouMean); + }); + + test('SpanData.fromJson handles legacy vocabulary type (maps to wordChoice)', + () { + final Map legacyJson = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'full_text': 'Test', + 'type': 'vocabulary', + }; + + expect(() => SpanData.fromJson(legacyJson), returnsNormally); + final SpanData span = SpanData.fromJson(legacyJson); + expect(span.type, ReplacementTypeEnum.other); + }); + + test('SpanData.fromJson handles new grammar type directly', () { + final Map jsonData = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'full_text': 'Test', + 'type': 'grammar', + }; + + expect(() => SpanData.fromJson(jsonData), returnsNormally); + final SpanData span = SpanData.fromJson(jsonData); + expect(span.type, ReplacementTypeEnum.subjectVerbAgreement); + }); + + test('SpanData.fromJson handles translation type', () { + final Map jsonData = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'full_text': 'Test', + 'type': 'translation', + }; + + expect(() => SpanData.fromJson(jsonData), returnsNormally); + final SpanData span = SpanData.fromJson(jsonData); + expect(span.type, ReplacementTypeEnum.translation); + }); + + group('SpanData.fromJson fullText fallback', () { + test('uses full_text from JSON when present', () { + final Map jsonData = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'full_text': 'Text from span', + 'type': 'grammar', + }; + + final SpanData span = SpanData.fromJson( + jsonData, + parentFullText: 'Text from parent', + ); + expect(span.fullText, 'Text from span'); + }); + + test('uses parentFullText when full_text not in JSON', () { + final Map jsonData = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + // Note: no full_text field + 'type': 'grammar', + }; + + final SpanData span = SpanData.fromJson( + jsonData, + parentFullText: 'Text from parent', + ); + expect(span.fullText, 'Text from parent'); + }); + + test('uses empty string when neither full_text nor parentFullText present', + () { + final Map jsonData = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'type': 'grammar', + }; + + final SpanData span = SpanData.fromJson(jsonData); + expect(span.fullText, ''); + }); + + test('prefers sentence over full_text (legacy field name)', () { + final Map jsonData = { + 'message': null, + 'short_message': null, + 'choices': [], + 'offset': 0, + 'length': 4, + 'sentence': 'Text from sentence field', + 'full_text': 'Text from full_text field', + 'type': 'grammar', + }; + + final SpanData span = SpanData.fromJson(jsonData); + expect(span.fullText, 'Text from sentence field'); + }); + }); +}