docs: writing assistance redesign design spec (#5655) (#5696)

* "docs: writing assistance redesign design spec (#5655)

Add comprehensive design doc for the WA redesign:
- AssistanceRing replaces StartIGCButton (segmented ring around Pangea icon)
- Background highlights with category colors (not red/orange error tones)
- Simplified match lifecycle: open → viewed → accepted (no ignore)
- Persistent span card with smooth transitions between matches
- Send always available, no gate on unresolved matches

Remove superseded design docs (SPAN_CARD_REDESIGN_FINALIZED.md,
SPAN_CARD_REDESIGN_Q_AND_A.md, choreographer.instructions.md)."

* feat: replace ignored status with viewed status, initial updates to span card

* resolve merge conflicts

* rebuild input bar on active match update to fix span hightlighting

* cleanup

* allow opening span cards for closed matches

* no gate on sending, update underline colors

* animate span card transitions

* initial updates to add segmented IGC progress ring

* update segment colors / opacities based on match statuses

* use same widget for igc loading and fetched

* more segment animation changes

* fix scrolling and wrap in span card

* better disabled color

* close span card on assistance state change

* remove print statements

* update design doc

* cleanup

---------

Co-authored-by: ggurdin <ggurdin@gmail.com>
This commit is contained in:
wcjord 2026-02-25 13:07:53 -05:00 committed by GitHub
parent 77559b9838
commit 473ffbaf24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1349 additions and 1630 deletions

View file

@ -1,167 +0,0 @@
---
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`).
**What the client sends**: The feedback request sends `List<LLMFeedbackSchema>` items on the request body. Each item carries `feedback` (optional string), `content` (the previous response for context), and `score` (optional int, 010). The score lets native speakers approve content (910) or reject it (06), while learners typically send a low score with corrective text. We'll probably just do 0 or 10 corresponding to thumbs up/down, but the schema supports finer granularity if needed.
**What the server does with it**: The router extracts `matrix_user_id` from the auth token and passes it along with the feedback list to `get()`. When feedback is present, `get()` builds an `Audit` internally (score + auditor + feedback text) and appends it to the CMS document (fire-and-forget). If the score indicates rejection (< 7), `get()` regenerates with an escalated model. The human judgment (who rejected/approved, why, when) lives on the `res.audit` array. See the server-side inference doc's Feedback Architecture section for the full flow.
**Native speaker approval**: When a native speaker sends score 910, the server persists the audit (upgrading the doc to fine-tuning eligible) and returns the cached response without regeneration.
### 5. Sending
On send, `Choreographer.getMessageContent()`:
1. Calls `/tokenize` to get `PangeaToken` data for the final text (with exponential backoff on errors).
2. Builds `PangeaMessageContentModel` containing:
- The final message text
- `ChoreoRecordModel` (full editing history)
- `PangeaRepresentation` for original written text (if IT was used)
- `PangeaMessageTokens` (token/lemma/morph data)
## AssistanceStateEnum
Derived in `choreographer_state_extension.dart`. Drives the send-button color and UI hints:
| State | Meaning |
|---|---|
| `noSub` | User has no active subscription |
| `noMessage` | Text field is empty |
| `notFetched` | Text entered but IGC hasn't run yet |
| `fetching` | IGC request in flight |
| `fetched` | Matches present — user needs to resolve them |
| `complete` | All matches resolved, ready to send |
| `error` | IGC error (backoff active) |
## ReplacementTypeEnum — Match Type Taxonomy
Defined in `igc/replacement_type_enum.dart`. Categories returned by `/grammar_v2`:
| Category | Types | Behavior |
|---|---|---|
| **Client-only** | `definition`, `practice`, `itStart` | Not from server; `itStart` triggers deprecated IT flow |
| **Grammar** (~21 types) | `verbConjugation`, `verbTense`, `verbMood`, `subjectVerbAgreement`, `genderAgreement`, `numberAgreement`, `caseError`, `article`, `preposition`, `pronoun`, `wordOrder`, `negation`, `questionFormation`, `relativeClause`, `connector`, `possessive`, `comparative`, `passiveVoice`, `conditional`, `infinitiveGerund`, `modal` | Orange underline, user must accept/ignore |
| **Surface corrections** | `punct`, `diacritics`, `spell`, `cap` | Auto-applied (no user interaction), undo via `AutocorrectPopup` |
| **Word choice** | `falseCognate`, `l1Interference`, `collocation`, `semanticConfusion` | Blue underline, user must accept/ignore |
| **Higher-level** | `transcription`, `style`, `fluency`, `didYouMean`, `translation`, `other` | Teal (style/fluency) or error color |
Key extension helpers: `isAutoApply`, `isGrammarType`, `isWordChoiceType`, `underlineColor()`, `displayName()`, `fromString()` (handles legacy snake_case and old type names like `grammar``subjectVerbAgreement`).
## Key Models
| Model | File | Purpose |
|---|---|---|
| `SpanData` | `igc/span_data_model.dart` | A match span (offset, length, choices, message, rule, `ReplacementTypeEnum`) |
| `PangeaMatch` | `igc/pangea_match_model.dart` | SpanData + status |
| `PangeaMatchState` | `igc/pangea_match_state_model.dart` | Mutable wrapper tracking original vs updated match state |
| `ChoreoRecordModel` | `choreo_record_model.dart` | Full editing history: steps, open matches, original text |
| `ChoreoRecordStepModel` | `choreo_edit_model.dart` | Single edit step (text before/after, accepted match) |
| `IGCRequestModel` | `igc/igc_request_model.dart` | Request to `/grammar_v2` |
| `IGCResponseModel` | `igc/igc_response_model.dart` | Response from `/grammar_v2` |
| `MatchRuleIdModel` | `igc/match_rule_id_model.dart` | Rule ID constants (⚠️ `tokenNeedsTranslation`, `tokenSpanNeedsTranslation`, `l1SpanAndGrammar` — not currently sent by server) |
| `AutocorrectPopup` | `igc/autocorrect_popup.dart` | Undo widget for auto-applied corrections |
## API Endpoints
| Endpoint | Repo File | Status |
|---|---|---|
| `/choreo/grammar_v2` | `igc/igc_repo.dart` | ✅ Active — primary IGC endpoint |
| `/choreo/tokenize` | `events/repo/tokens_repo.dart` | ✅ Active — tokenizes final text on send |
| `/choreo/span_details` | `igc/span_data_repo.dart` | ❌ Dead code — `SpanDataRepo` class is defined but never imported anywhere |
| `/choreo/it_initialstep` | `it/it_repo.dart` | ⚠️ Deprecated — IT flow |
| `/choreo/contextual_definition` | `contextual_definition_repo.dart` | ⚠️ Deprecated — only used by IT's `word_data_card.dart` |
## Edit Types (`EditTypeEnum`)
- `keyboard` — User typing
- `igc` — System applying IGC match
- `it` — ⚠️ Deprecated — System applying IT continuance
- `itDismissed` — ⚠️ Deprecated — IT dismissed, restoring source text
## Deprecated: SpanChoiceTypeEnum
In `igc/span_choice_type_enum.dart`:
- `bestCorrection``@Deprecated('Use suggestion instead')`
- `bestAnswer``@Deprecated('Use suggestion instead')`
- `suggestion` — Active replacement
## Error Handling
- IGC and token errors trigger exponential backoff (`_igcErrorBackoff *= 2`, `_tokenErrorBackoff *= 2`)
- Backoff resets on next successful request
- Errors surfaced via `ChoreographerErrorController`
- Error state exposed in `AssistanceStateEnum.error`
## ⚠️ Deprecated: Interactive Translation (IT)
> **Do not extend.** IT is being deprecated. Translation will become a match type within IGC.
The `it/` directory still contains `ITController`, `ITRepo`, `ITStepModel`, `CompletedITStepModel`, `GoldRouteTrackerModel`, `it_bar.dart`, `it_feedback_card.dart`, and `word_data_card.dart`. The choreographer still wires up IT via `_onOpenIT()` / `_onCloseIT()` / `_onAcceptContinuance()`, triggered when an `itStart` match is found. The `it_bar.dart` widget is still imported by `chat_input_bar.dart`.
This entire flow will be removed once testing confirms IT is no longer needed as a separate mode.

View file

@ -0,0 +1,88 @@
---
applyTo: "lib/pangea/morphs/**, lib/pangea/constructs/**, lib/pangea/analytics_details_popup/morph_*"
---
# Grammar Analytics — Design & Intent
The grammar analytics section surfaces which morphological grammar concepts a learner has used (and which they haven't) based on the [Universal Dependencies](https://universaldependencies.org/) (UD) framework. In the UI it is labeled "Grammar" but internally the data model calls these **morph constructs** (`ConstructTypeEnum.morph`).
## Design Goals
### 1. Motivational progress tracker, not a textbook
The grammar page is **not** a grammar reference. It exists to let students see at a glance which grammar concepts they've already produced in real messages and which remain unused — promoting more varied, adventurous language use. The framing is "look what you've done / here's what you could try" rather than "here are 30 categories you need to learn."
### 2. Language-specific relevance
The full UD feature/tag inventory is large and language-agnostic. Only a subset matters for any given L2. The server maintains an **exclusion-based list** per language (`morphs_exclusions_by_language.json` in 2-step-choreographer) that trims the master UD inventory down to what's relevant. The client fetches this trimmed list via `GET /choreo/morphs/{language_code}` (see `morph_repo.dart`).
> **Known gap:** These per-language exclusion lists have only been audited for a handful of languages. Many languages still show tags that don't apply or are missing tags that do. Cleaning these up is ongoing work — contributions from linguists and language teachers are needed.
### 3. Tokenized dataset contribution
A secondary intent is to build up tokenized message datasets annotated with UD morphological information. This data helps improve NLP quality for low-resource languages where training data is scarce. Surfacing grammar analytics to users is partly a mechanism for generating and validating this annotation at scale.
## Data Architecture
### Construct model
Every grammar data point is a **construct** identified by a `ConstructIdentifier`:
| Field | Meaning | Example |
|---|---|---|
| `type` | Always `ConstructTypeEnum.morph` | `morph` |
| `category` | The UD feature name (maps to `MorphFeaturesEnum`) | `Tense` |
| `lemma` | The UD tag value within that feature | `Pres` |
A user's usage of each construct is tracked as `ConstructUses`, which accumulates XP and a proficiency level (`ConstructLevelEnum`).
### Morph feature inventory
The canonical tag list lives server-side in `ud_constants.py`. The client carries a `defaultMorphMapping` fallback (`default_morph_mapping.dart`) and fetches the L2-specific version from the API. Features are sorted by `morphFeatureSortOrder` — roughly by pedagogical importance (POS → tense → aspect → mood → …).
### Human-readable descriptions
The **morph meaning** system (server: `morph_meaning/`, client: `morph_meaning/`) provides LLM-generated titles and descriptions for each feature/tag pair, keyed by user's L1 display language. These are stored in the CMS and generated on-demand for missing entries. See the server-side `morph-meaning_v1.instructions.md` for the full data model and request flow.
## UI Structure
### Grammar list view (`morph_analytics_list_view.dart`)
Top-level page showing all relevant UD features as expandable boxes. Each box lists the tags within that feature (e.g., Tense → Past, Present, Future). Tags are color-coded by the user's proficiency level. Tags the user hasn't encountered yet are visible but dimmed — this is intentional to motivate exploration.
### Grammar detail view (`morph_details_view.dart`)
Drill-down for a single tag showing:
- Tag display name and icon (`morph_tag_display.dart`, `morph_icon.dart`)
- Feature category label (`morph_feature_display.dart`)
- Human-readable meaning (`morph_meaning_widget.dart`)
- XP progress bar
- Usage examples from the user's actual messages
### Grammar practice (`grammar_error_practice_generator.dart`, `morph_category_activity_generator.dart`)
Recent addition: users can practice grammar concepts they've struggled with. `GrammarErrorPracticeGenerator` creates activities from past IGC grammar corrections. `MorphCategoryActivityGenerator` creates practice targeting specific morph categories.
## Recent Improvements
- **Grammar practice section** on the analytics page allowing rehearsal of past grammar mistakes via generated multiple-choice activities.
## Future Work
- **In-app feedback on grammar info**: Allow users to flag incorrect or confusing grammar tags/descriptions (similar to the feedback mechanism on other features) to trigger internal review. This covers both the per-L2 tag list (exclusions) and the morph meaning descriptions.
- **Pre-made examples per tag**: Add curated example sentences for each grammar concept so users can see the construct in context without needing to have encountered it themselves.
- **Target grammar in activities**: Allow course creators and activity generators to specify grammar concepts as learning targets (e.g., "practice the subjunctive"), connecting the grammar inventory to the activity system.
## Key Files
| Area | Files |
|---|---|
| **Data model** | `constructs/construct_identifier.dart`, `analytics_misc/construct_type_enum.dart`, `analytics_misc/construct_use_model.dart` |
| **UD features** | `morphs/morph_features_enum.dart`, `morphs/morph_models.dart`, `morphs/default_morph_mapping.dart`, `morphs/parts_of_speech_enum.dart` |
| **Display copy** | `morphs/get_grammar_copy.dart`, `morphs/morph_meaning/` |
| **API** | `morphs/morph_repo.dart` (fetches L2-specific feature/tag list) |
| **UI — list** | `analytics_details_popup/morph_analytics_list_view.dart`, `analytics_details_popup/analytics_details_popup.dart` |
| **UI — detail** | `analytics_details_popup/morph_details_view.dart`, `morphs/morph_tag_display.dart`, `morphs/morph_feature_display.dart`, `morphs/morph_icon.dart` |
| **Practice** | `analytics_practice/grammar_error_practice_generator.dart`, `analytics_practice/morph_category_activity_generator.dart` |
| **Server — tag lists** | `2-step-choreographer: app/handlers/universal_dependencies/` |
| **Server — descriptions** | `2-step-choreographer: app/handlers/morph_meaning/` |

View file

@ -0,0 +1,79 @@
---
applyTo: "**/.env,**/assets/.env*"
---
# Matrix Auth — Staging Test Tokens
How to obtain a Matrix access token for staging API testing (choreo endpoints, Synapse admin API, Playwright login, etc.).
## Credentials
Staging test credentials live in `client/.env`:
- `STAGING_TEST_EMAIL` — email address
- `STAGING_TEST_USER` — full Matrix user ID (e.g. `@wykuji:staging.pangea.chat`)
- `STAGING_TEST_PASSWORD` — password
Read these values from the file at runtime. **Never hardcode credentials in skills, scripts, or chat output.**
## Get a Matrix Access Token
```sh
curl -s -X POST 'https://matrix.staging.pangea.chat/_matrix/client/v3/login' \
-H 'Content-Type: application/json' \
-d '{
"type": "m.login.password",
"identifier": {"type": "m.id.user", "user": "<USERNAME_WITHOUT_@_OR_DOMAIN>"},
"password": "<STAGING_TEST_PASSWORD>"
}' | python3 -m json.tool
```
The response contains `access_token`, `user_id`, `device_id`, and `home_server`.
### Extracting the token programmatically
```sh
# Read creds from client/.env
STAGING_USER=$(grep STAGING_TEST_USER client/.env | sed 's/.*= *"//;s/".*//' | sed 's/@//;s/:.*//')
STAGING_PASS=$(grep STAGING_TEST_PASSWORD client/.env | sed 's/.*= *"//;s/".*//')
# Login and extract token
MATRIX_TOKEN=$(curl -s -X POST 'https://matrix.staging.pangea.chat/_matrix/client/v3/login' \
-H 'Content-Type: application/json' \
-d "{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"$STAGING_USER\"},\"password\":\"$STAGING_PASS\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
echo "$MATRIX_TOKEN"
```
## Use the Token
### Choreo API
Choreo requires **both** the Matrix token and the API key (from `CHOREO_API_KEY` in `client/.env`):
```sh
curl -s 'https://api.staging.pangea.chat/choreo/<endpoint>' \
-H "Authorization: Bearer $MATRIX_TOKEN" \
-H 'api-key: <CHOREO_API_KEY>'
```
### Synapse Client-Server API
```sh
curl -s 'https://matrix.staging.pangea.chat/_matrix/client/v3/joined_rooms' \
-H "Authorization: Bearer $MATRIX_TOKEN"
```
### Synapse Admin API
The test account is **not** a server admin. For admin endpoints, use the bot account or a real admin token.
## Common Errors
| Error | Cause | Fix |
|-------|-------|-----|
| `M_FORBIDDEN` | Token expired or invalidated | Re-run the login curl to get a fresh token |
| `M_UNKNOWN_TOKEN` | Token from a different homeserver or old session | Confirm you're hitting `matrix.staging.pangea.chat` |
| `Could not validate Matrix token` from choreo | Missing `api-key` header | Add both `Authorization` and `api-key` headers |
| `M_USER_DEACTIVATED` | Test account was deactivated | Re-register or use a different test account |

View file

@ -0,0 +1,114 @@
---
applyTo: "lib/pangea/**,lib/pages/**,lib/widgets/**"
---
# Playwright Testing — Flutter Web Client
How to interact with the Pangea Chat Flutter web app using the Playwright MCP tools.
## Critical: Flutter Web Uses CanvasKit
Flutter web renders to a `<canvas>` element, not DOM nodes. Standard Playwright selectors (`page.getByText()`, `page.locator()`) **will not find Flutter widgets**. You must:
1. **Enable accessibility** — Click the "Enable accessibility" button that Flutter overlays on the page. This activates the semantics tree, which exposes widget labels to Playwright's accessibility snapshot.
2. **Use `browser_snapshot`** — After enabling accessibility, use `browser_snapshot` to see the semantic tree. This returns ARIA labels and roles that map to Flutter widget `Semantics` / `Tooltip` / button labels.
3. **Use `browser_click` with `ref`** — Click elements by their `ref` from the snapshot, not by CSS selectors.
4. **Use `browser_type` with `ref`** — Type into text fields by their `ref` from the snapshot.
5. **Use `browser_take_screenshot`** — When the semantic tree is insufficient (e.g. visual layout issues, canvas rendering bugs), take a screenshot to see what's actually on screen.
### Enable Accessibility (First Step After Navigation)
Flutter's "Enable accessibility" button is placed **off-screen** by default and is often unreachable via normal click due to scroll/viewport issues. **Use `browser_run_code` to force-enable it via JavaScript:**
```js
async (page) => {
// Flutter places the semantics placeholder off-screen. Force-click it via JS.
await page.evaluate(() => {
const btn = document.querySelector('flt-semantics-placeholder')
|| document.querySelector('[aria-label="Enable accessibility"]');
if (btn) btn.click();
});
}
```
Then wait 23 seconds and take a snapshot — you should now see Flutter widget labels.
**Do not** try to find and click the button via `browser_snapshot` + `browser_click` — the button is intentionally positioned outside the viewport and Playwright cannot scroll to it reliably.
## Login Flow
### Prerequisites
- Flutter web app running locally (e.g. `flutter run -d chrome` on some port)
- Staging test credentials from `client/.env` (see [matrix-auth.instructions.md](matrix-auth.instructions.md))
### Step-by-Step Login
1. **Navigate** to the app URL (e.g. `http://localhost:<port>`)
2. **Enable accessibility** (see above)
3. **Snapshot** — you should see "Start" and "Login to my account" buttons
4. **Click "Login to my account"** → navigates to `/home/login`
5. **Snapshot** — you should see "Sign in with Apple", "Sign in with Google", "Email" options
6. **Click "Email"** → navigates to `/home/login/email`
7. **Snapshot** — you should see "Username or Email" and "Password" text fields, and a "Login" button
8. **Type** the username (just the localpart, e.g. `wykuji`, not the full `@wykuji:staging.pangea.chat`) into the username field
9. **Type** the password into the password field
10. **Click "Login"** button
11. **Wait** 510 seconds for sync to complete
12. **Snapshot** — you should now be on the chat list (`/rooms`)
### Navigate to a Room
After login, navigate to a specific room by URL:
```
http://localhost:<port>/#/rooms/<room_id>
```
Or find the room in the chat list via snapshot and click it.
## Route Map
| Route | What You'll See |
|---|---|
| `/#/home` | Landing page: logo, "Start", "Login to my account" |
| `/#/home/login` | Login options: Apple, Google, Email |
| `/#/home/login/email` | Username + Password form |
| `/#/rooms` | Chat list (requires auth) |
| `/#/rooms/<room_id>` | Chat room with message input |
## Interacting With Chat
Once in a room:
1. **Snapshot** to find the text input field and other UI elements
2. **Type** a message into the input field
3. **Wait** for writing assistance to trigger (debounce ~1.5s after typing stops)
4. **Snapshot** to see the assistance ring, highlighted text, and any span cards
5. **Click** highlighted text to open the span card
6. **Screenshot** to visually inspect the ring segments, highlight colors, and span card layout
### What to Look For
| Element | Semantic Label / Visual Cue |
|---|---|
| Text input | Input field in the bottom bar |
| Assistance ring | Pangea logo icon with colored ring segments |
| Send button | Right-most button in input bar |
| Span card | Overlay popup with category title, bot face, choices |
| Highlighted text | Background color behind matched text in the input |
## Tips
- **Snapshots are better than screenshots** for finding interactive elements and their `ref` IDs.
- **Screenshots are better than snapshots** for verifying visual styling (colors, layout, animations).
- **Wait between actions** — Flutter web can be slow, especially during initial load and sync. Use `browser_wait_for` with 25 second delays after navigation or login.
- **Hash routing** — all Flutter routes use `/#/` prefix. Direct navigation works.
- **Session is ephemeral** — the Playwright browser doesn't share the user's Chrome session. You must log in each time.
## Limitations
- SSO login (Apple/Google) cannot be automated — use email/password login only.
- CanvasKit rendering means pixel-level visual assertions are screenshot-based, not DOM-based.
- Some widgets may not have semantic labels — file a bug if a key interaction point is invisible to the accessibility snapshot.
- Animations (ring spin, card transitions) won't appear in snapshots — use screenshots or video for those.

View file

@ -0,0 +1,255 @@
---
applyTo: "lib/pangea/choreographer/**"
---
# Writing Assistance — Design & Architecture
Writing assistance is a friendly, non-judgmental helper that quietly reviews what the user types and offers suggestions. It must never feel like error correction — it's a learning companion.
> **⚠️ IT (Interactive Translation) is deprecated.** Do not add new IT functionality. Translation will become a match type within IGC.
## Design Intent
### Core Principle: Helper, Not Judge
Playtest feedback consistently shows users perceive writing corrections as errors or punishments. The redesign addresses this by:
1. **Removing all accept/reject language** — no "Ignore" or "Replace" buttons. The user simply views suggestions and optionally taps a choice.
2. **Using warm, varied colors** — each match type gets a distinct, fun color (not red/orange "error" tones). Colors signal category, not severity.
3. **Making interaction optional** — viewing a suggestion is enough. The user can send their message at any point, even with unviewed suggestions.
4. **Reusing a single popup** — one persistent span card that updates its content as the user navigates between matches, eliminating the jitter of opening/closing overlays.
### Target User Perception
> "Oh, my writing assistant noticed a few things. Let me take a look… ah, I see. Cool."
Not:
> "I made 3 errors and need to fix them before I can send."
---
## Assistance Ring (replaces StartIGCButton)
The current `StartIGCButton` is updated to display a **segmented ring**, indicating match states.
### Ring Behavior
- The ring's outer edge divides into **one segment per match** returned by the server.
- Each segment's **color** corresponds to its match's `ReplacementTypeEnum` color category.
- Segments have two opacity states:
- **Bright (full opacity)** — match not yet viewed.
- **Muted (low opacity)** — match has been viewed (or was auto-applied).
- **AutocorrectPopup retained** — when surface corrections are auto-applied, the existing `AutocorrectPopup` briefly appears to draw attention to the change. This ensures auto-applied edits aren't silently swallowed. The popup is the same toast-like overlay currently used.
- The ring is a **status indicator only** — tapping it does not navigate between matches. Users navigate by tapping highlighted text in the input field.
### Icon States
| State | Icon Behavior |
| --------------- | ---------------------------------------------- |
| Idle / no text | Grey check icon with 5 grey segments |
| Fetching | Segments spin, check is hidden |
| Matches present | Ring segments visible |
| Zero matches | Check visible, **solid green ring** |
| Input cleared | Ring **animates out**, returns to idle |
| Re-fetch | Icon **spins again**, old segments cleared |
| Error | error indicator (TBD) |
The ring **animates in** when segments first appear and **animates out** when the input field is cleared. When the user edits text and triggers a re-fetch, the icon spins again and old segments are discarded — the new response rebuilds the ring from scratch.
### What We're Removing
- The `autorenew` spinning icon
- The elevation/shadow changes based on state
---
## Text Highlighting
Matched text in the input field uses **background highlights** (not just underlines) with the same color system as the ring segments.
### Highlight Behavior
- **Unviewed matches**: Highlighted with **bright (full opacity)** background in the match's category color.
- **Viewed matches**: Highlighted with **muted (low opacity)** background after the user has opened and navigated away from the span card for that match.
- **Accepted matches**: Text is replaced. The highlight remains bright. The user can tap it again to revisit and undo.
- **Auto-applied matches**: Text already replaced. Highlighted bright immediately.
### Color Palette
Each `ReplacementTypeEnum` category gets a distinct, friendly color. Colors should feel varied and playful — not a gradient from "fine" to "bad." Examples of the spirit (exact values TBD in implementation):
| Category | Color Spirit |
| -------------------- | ----------------- |
| Grammar | Coral / warm pink |
| Word choice | Sky blue |
| Style / fluency | Lavender |
| Surface (auto-apply) | Mint green |
| Translation | Amber |
---
## Span Card (Redesigned)
A single, persistent popup that changes content as the user taps different highlighted matches. No open/close animation between matches — the card stays in place and its content transitions smoothly.
### Positioning
The card positions itself relative to the currently selected match's highlighted text. When the user taps a different match, the card **animates smoothly** to the new position (slide + content crossfade) rather than jumping. The animation should feel fluid and natural — not snapping or teleporting. If the message is short and all highlights are close together, the movement will be subtle. For long messages where highlights are far apart, the card glides to follow.
### Layout
```
┌─────────────────────────────────┐
│ ✕ Edit Category Title 🚩 │ ← Header: close, category name (e.g. "Verb Conjugation"), flag
│ │
│ 🤖 Hint text explaining the │ ← Bot face left-aligned, hint text beside it
│ suggestion in detail │
│ │
│ ┌──────┐ ┌──────┐ ┌──────┐ │ ← Choices: horizontal if they fit,
│ │ word │ │ word │ │ undo │ │ vertical if not. Undo action at end.
│ └──────┘ └──────┘ └──────┘ │
└─────────────────────────────────┘
```
**When revisiting an already-accepted match**, the choices row is replaced with a compact diff view:
```
┌─────────────────────────────────┐
│ ✕ Edit Category Title 🚩 │
│ │
│ 🤖 Hint text │
│ │
│ original → replacement ↩ │ ← Shows what changed + undo icon
└─────────────────────────────────┘
```
### Interaction Model
1. **User taps highlighted text** → Span card appears (or updates content if already open) showing that match's hint and choices.
2. **User reads the suggestion** → Once they navigate away (tap different text, tap close, or tap outside), the match is marked **viewed**. Its highlight and ring segment become bright.
3. **User taps a choice** → If the choice is the best replacement, the text is replaced, the match is marked **accepted**, and the card advances to the next unviewed match (or closes if none remain). If not, the choice is marked as selected and the user gets the feedback of the color change of the choice. They can click it again to do it anyway. "alt" choices should appear as a lighter green. "distractors" should appear as red.
4. **User taps the undo action** → Reverts to the original text for that match. The match returns to `viewed` status (bright, but choices shown again instead of diff view). Undo is available per-match (not just most-recent) because **spans never overlap** — each match targets a unique, non-overlapping substring.
5. **User taps an already-accepted match's text** → Span card reopens showing `original → replacement` with an undo icon, instead of the choices row. Tapping undo reverts the text and restores the full choices view. This is identical for auto-applied matches — tapping a bright auto-applied highlight opens the same diff view with undo.
6. **User taps close (✕)** → Card closes. Match is marked viewed.
> **Open question — auto-advance viewing**: When the user accepts a choice and the card auto-advances to the next match, does that brief display count as "viewed"? Try the current behavior in practice and adjust if users feel they're missing content.
> **Open question — text re-editing**: If the user edits text after matches have been returned (e.g., types more, backspaces into a matched region), what happens to existing matches? Current behavior is to re-fetch. Consider whether partial invalidation is worth the complexity or if a full re-fetch on significant edits is acceptable.
### What We're Removing
- "Ignore in this text" button
- "Replace" button
- The entire bottom button row
- Per-match overlay creation/destruction (single reusable card instead)
### What We're Keeping
- Close button (✕)
- Bot face icon (now left-aligned next to hint text)
- Edit category as header title (e.g. "Verb Conjugation", "Word Choice")
- Flag button (feedback)
- Choices array (with responsive horizontal/vertical layout)
- Hint/explanation text
---
## Match Lifecycle
Matches no longer require explicit accept/ignore. The lifecycle simplifies to:
| Status | Meaning | Ring/Highlight |
| ----------- | ------------------------------------------------------- | --------------------- |
| `automatic` | Auto-applied on arrival (punct, diacritics, spell, cap) | Bright immediately |
| `open` | Server-returned, not yet viewed | Muted |
| `viewed` | User opened the span card and navigated away | Bright |
| `accepted` | User tapped a choice, text replaced | Bright |
| `undone` | User reverted an accepted match | Bright (still viewed) |
The `ignored` status is removed — viewing is sufficient. The message is sendable at any point regardless of match status.
---
## Sending
The user can send at any time. There is no gate on unresolved matches.
- **Send button** remains separate from the assistance ring (to the right of it in the input row).
- On send, the choreographer tokenizes the final text and saves a `ChoreoRecordModel` with the message, recording which matches were viewed, accepted, or left open.
---
## Feedback System
Unchanged from current design. When the user taps the flag (🚩) on the span card:
- They can submit feedback text and a score (thumbs up/down maps to 10/0).
- The server audits the suggestion, escalates the model if rejected, and persists the judgment for fine-tuning.
- Native speaker approval (score 910) caches the response without regeneration.
---
## Match Type Categories
Categories returned by `/grammar_v2`. Each gets a distinct color in the ring and text highlights:
| Category | Types | Behavior |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| **Grammar** (~21 types) | verb conjugation/tense/mood, agreement (subject-verb, gender, number, case), article, preposition, pronoun, word order, negation, question formation, relative clause, connector, possessive, comparative, passive voice, conditional, infinitive/gerund, modal | Highlight + ring segment, user-viewable |
| **Surface** | punct, diacritics, spell, cap | **Auto-applied**, bright immediately, undo via span card. Hint text displayed only if server provides one (non-null) — omitted for obvious corrections, included when pedagogically useful (e.g. explaining an accent rule). |
| **Word choice** | false cognate, L1 interference, collocation, semantic confusion | Highlight + ring segment, user-viewable |
| **Style / fluency** | style, fluency, didYouMean, transcription, translation, other | Highlight + ring segment, user-viewable |
---
## Architecture
```
Choreographer (ChangeNotifier)
├── PangeaTextController ← Extended TextEditingController (tracks edit types)
├── IgcController ← Grammar check matches (primary flow)
├── ChoreographerErrorController ← Error state + backoff
└── ChoreographerStateExtension ← AssistanceStateEnum derivation
```
### Flow Summary
1. User types → debounce → `/grammar_v2` request
2. Response returns matches → auto-apply surface corrections, display the rest
3. Ring segments and text highlights appear (muted)
4. User taps highlights to view suggestions, optionally accepts choices
5. Viewed matches become bright
6. User sends when ready — no gate on unresolved matches
7. On send: tokenize final text, save `ChoreoRecordModel` with match history
### API Endpoints
| Endpoint | Status |
| ------------------------------- | ---------------------------------------- |
| `/choreo/grammar_v2` | ✅ Active — primary IGC endpoint |
| `/choreo/tokenize` | ✅ Active — tokenizes final text on send |
| `/choreo/span_details` | ❌ Dead code — remove |
| `/choreo/it_initialstep` | ⚠️ Deprecated — remove with IT |
| `/choreo/contextual_definition` | ⚠️ Deprecated — remove with IT |
---
## Deprecated: Interactive Translation (IT)
> **Do not extend. Scheduled for removal.**
The `it/` directory, `ITController`, and all IT-related code (`it_bar.dart`, `it_feedback_card.dart`, `word_data_card.dart`, `choreo_mode_enum.dart`) will be removed. Translation will become a match type within IGC.
---
## Future Work
- **Cycling placeholder text** in the input bar ("Type in English or Spanish…") to teach users they can write in L1 ([#5653](https://github.com/pangeachat/client/issues/5653))
- **Color palette finalization** — exact hue/opacity values for each match category
- **Ring animate in/out** — entrance animation when segments appear, exit when input cleared
- **Ring segment muted→bright transition** — per-segment animation when a match is viewed
- **Span card slide animation** — fluid positional slide + content crossfade when switching between matches
- **Accessibility badge** — small unviewed-count badge on the Pangea Chat icon
- **Analytics events** — track span card views, choice selections, and undo actions in Firebase Analytics
- **`to_replace` migration** — the current offset-based span targeting is more brittle than the server's unique `to_replace` substring system. Consider migrating the client to identify spans by `to_replace` text rather than character offsets. This is a large change because legacy span data stored in saved JSON events would need `fromJson` migration.

View file

@ -43,6 +43,7 @@ import 'package:fluffychat/pangea/choreographer/choreo_constants.dart';
import 'package:fluffychat/pangea/choreographer/choreo_record_model.dart';
import 'package:fluffychat/pangea/choreographer/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart';
import 'package:fluffychat/pangea/choreographer/text_editing/edit_type_enum.dart';
import 'package:fluffychat/pangea/choreographer/text_editing/pangea_text_controller.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
@ -484,7 +485,9 @@ class ChatController extends State<ChatPageWithRoom>
// inputFocus.addListener(_inputFocusListener);
// Pangea#
_loadDraft();
// #Pangea
// _loadDraft();
// Pangea#
WidgetsBinding.instance.addPostFrameCallback(_shareItems);
super.initState();
_displayChatDetailsColumn = ValueNotifier(
@ -503,6 +506,7 @@ class ChatController extends State<ChatPageWithRoom>
_tryLoadTimeline();
// #Pangea
_pangeaInit();
_loadDraft();
// Pangea#
}
@ -948,6 +952,9 @@ class ChatController extends State<ChatPageWithRoom>
// Future<void> send() async {
// if (sendController.text.trim().isEmpty) return;
Future<void> send() async {
// Close span card if open
MatrixState.pAnyState.closeAllOverlays();
final message = sendController.text;
final edit = editEvent.value;
final reply = replyEvent.value;
@ -1822,7 +1829,7 @@ class ChatController extends State<ChatPageWithRoom>
PaywallCard.show(context, ChoreoConstants.inputTransformTargetKey);
return;
}
await onRequestWritingAssistance(manual: false, autosend: true);
await _onRequestWritingAssistance(manual: false, autosend: true);
}
// Pangea#
@ -2069,8 +2076,9 @@ class ChatController extends State<ChatPageWithRoom>
final StreamController<void> stopMediaStream = StreamController.broadcast();
bool get _isToolbarOpen =>
MatrixState.pAnyState.isOverlayOpen(RegExp(r'^message_toolbar_overlay$'));
bool get _isToolbarOpen => MatrixState.pAnyState.isOverlayOpen(
overlayKey: "message_toolbar_overlay",
);
void showToolbar(
Event event, {
@ -2270,32 +2278,63 @@ class ChatController extends State<ChatPageWithRoom>
);
}
void showNextMatch() {
MatrixState.pAnyState.closeOverlay();
final match = choreographer.igcController.openMatches.firstOrNull;
if (match == null) {
void showNextMatch({PangeaMatchState? match}) {
final matchToShow =
match ?? choreographer.igcController.openMatches.firstOrNull;
if (matchToShow == null) {
inputFocus.requestFocus();
return;
}
match.updatedMatch.isITStart
? choreographer.itController.openIT(sendController.text)
: OverlayUtil.showIGCMatch(
match,
choreographer,
context,
showNextMatch,
(feedback) => onRequestWritingAssistance(feedback: feedback),
);
if (matchToShow.updatedMatch.isITStart) {
choreographer.itController.openIT(sendController.text);
return;
}
final isSpanCardOpen = MatrixState.pAnyState.isOverlayOpen(
overlayKey: 'span-card-overlay',
);
try {
choreographer.igcController.setActiveMatch(match: matchToShow);
} catch (e, s) {
ErrorHandler.logError(e: e, s: s, data: {'match': matchToShow.toJson()});
return;
}
if (!isSpanCardOpen) {
OverlayUtil.showIGCMatch(
matchToShow,
choreographer,
context,
onWritingAssistanceFeedback,
);
}
}
Future<void> onRequestWritingAssistance({
Future<void> onManualWritingAssistance() =>
_onRequestWritingAssistance(manual: true);
Future<void> onWritingAssistanceFeedback(String feedback) =>
_onRequestWritingAssistance(feedback: feedback);
Future<void> _onRequestWritingAssistance({
bool manual = false,
bool autosend = false,
String? feedback,
}) async {
if (shouldShowLanguageMismatchPopupByActivity) {
return showLanguageMismatchPopup(manual: manual);
return showLanguageMismatchPopup(manual: manual, autosend: autosend);
}
// If this request should send on a success, and is not a manual request, and assistance
// has already been requested, then just send the message instead of requesting assistance again.
if (autosend &&
!manual &&
choreographer.assistanceState != AssistanceStateEnum.notFetched) {
await send();
return;
}
feedback == null
@ -2311,7 +2350,7 @@ class ChatController extends State<ChatPageWithRoom>
}
}
void showLanguageMismatchPopup({bool manual = false}) {
void showLanguageMismatchPopup({bool manual = false, bool autosend = false}) {
if (!shouldShowLanguageMismatchPopupByActivity) {
return;
}
@ -2324,7 +2363,7 @@ class ChatController extends State<ChatPageWithRoom>
message: L10n.of(context).languageMismatchDesc,
targetLanguage: targetLanguage,
onConfirm: () => WidgetsBinding.instance.addPostFrameCallback(
(_) => onRequestWritingAssistance(manual: manual, autosend: true),
(_) => _onRequestWritingAssistance(manual: manual, autosend: autosend),
),
);
}

View file

@ -10,9 +10,8 @@ import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/choreographer/choreo_constants.dart';
import 'package:fluffychat/pangea/choreographer/choreo_mode_enum.dart';
import 'package:fluffychat/pangea/choreographer/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/text_editing/edit_type_enum.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart';
import 'package:fluffychat/pangea/choreographer/text_editing/pangea_text_controller.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/common/widgets/shrinkable_text.dart';
import 'package:fluffychat/pangea/learning_settings/tool_settings_enum.dart';
import 'package:fluffychat/pangea/subscription/controllers/subscription_controller.dart';
@ -36,7 +35,7 @@ class InputBar extends StatelessWidget {
// final TextEditingController? controller;
final PangeaTextController? controller;
final Choreographer choreographer;
final VoidCallback showNextMatch;
final Function(PangeaMatchState) showMatch;
final Future Function(String) onFeedbackSubmitted;
// Pangea#
final InputDecoration decoration;
@ -62,7 +61,7 @@ class InputBar extends StatelessWidget {
required this.suggestionEmojis,
// #Pangea
required this.choreographer,
required this.showNextMatch,
required this.showMatch,
required this.onFeedbackSubmitted,
// Pangea#
super.key,
@ -429,27 +428,11 @@ class InputBar extends StatelessWidget {
final adjustedOffset = _adjustOffsetForNormalization(baseOffset);
final match = choreographer.igcController.getMatchByOffset(adjustedOffset);
if (match == null) return;
showMatch(match);
if (match.updatedMatch.isITStart) {
choreographer.itController.openIT(controller!.text);
} else {
OverlayUtil.showIGCMatch(
match,
choreographer,
context,
showNextMatch,
onFeedbackSubmitted,
);
// rebuild the text field to highlight the newly selected match
choreographer.textController.setSystemText(
choreographer.textController.text,
EditTypeEnum.other,
);
choreographer.textController.selection = TextSelection.collapsed(
offset: baseOffset,
);
}
choreographer.textController.selection = TextSelection.collapsed(
offset: baseOffset,
);
}
bool _shouldShowPaywall(BuildContext context) {
@ -462,7 +445,8 @@ class InputBar extends StatelessWidget {
int _adjustOffsetForNormalization(int baseOffset) {
int adjustedOffset = baseOffset;
final corrections = choreographer.igcController.recentAutomaticCorrections;
final corrections =
choreographer.igcController.closedNormalizationCorrections;
for (final correction in corrections) {
final match = correction.updatedMatch.match;
@ -484,7 +468,10 @@ class InputBar extends StatelessWidget {
// #Pangea
// fieldViewBuilder: (context, controller, focusNode, _) => TextField(
fieldViewBuilder: (context, _, focusNode, _) => ListenableBuilder(
listenable: choreographer,
listenable: Listenable.merge([
choreographer,
choreographer.igcController.activeMatch,
]),
builder: (context, _) {
return TextField(
// Pangea#

View file

@ -11,7 +11,9 @@ extension ActivityMenuLogic on ChatController {
bool get shouldShowActivityInstructions {
if (InstructionsEnum.showedActivityMenu.isToggledOff ||
InstructionsEnum.activityStatsMenu.isToggledOff ||
MatrixState.pAnyState.isOverlayOpen(RegExp(r"^word-zoom-card-.*$")) ||
MatrixState.pAnyState.isOverlayOpen(
regex: RegExp(r"^word-zoom-card-.*$"),
) ||
timeline == null ||
GoRouterState.of(context).fullPath?.endsWith(':roomid') != true) {
return false;

View file

@ -65,7 +65,7 @@ class LevelUpUtil {
static Future<void> _waitForSnackbars(BuildContext context) async {
final snackbarRegex = RegExp(r'_snackbar$');
while (MatrixState.pAnyState.isOverlayOpen(snackbarRegex)) {
while (MatrixState.pAnyState.isOverlayOpen(regex: snackbarRegex)) {
await Future.delayed(const Duration(milliseconds: 100));
}
}

View file

@ -261,9 +261,9 @@ class PangeaChatInputRow extends StatelessWidget {
),
onChanged: controller.onInputBarChanged,
choreographer: controller.choreographer,
showNextMatch: controller.showNextMatch,
onFeedbackSubmitted: (feedback) => controller
.onRequestWritingAssistance(feedback: feedback),
showMatch: (m) => controller.showNextMatch(match: m),
onFeedbackSubmitted:
controller.onWritingAssistanceFeedback,
suggestionEmojis:
getDefaultEmojiLocale(
AppSettings
@ -283,13 +283,11 @@ class PangeaChatInputRow extends StatelessWidget {
),
),
StartIGCButton(
key: ValueKey(controller.choreographer),
onPressed: () =>
controller.onRequestWritingAssistance(manual: true),
key: ValueKey("start_igc_button_${controller.room.id}"),
onPressed: controller.onManualWritingAssistance,
choreographer: controller.choreographer,
initialState: state,
initialForegroundColor: state.stateColor(context),
initialBackgroundColor: state.backgroundColor(context),
),
ValueListenableBuilder(
valueListenable: controller.sendController,

View file

@ -21,7 +21,7 @@ enum AssistanceStateEnum {
case AssistanceStateEnum.noMessage:
case AssistanceStateEnum.fetched:
case AssistanceStateEnum.error:
return Theme.of(context).disabledColor;
return Colors.grey[400]!;
case AssistanceStateEnum.notFetched:
case AssistanceStateEnum.fetching:
return Theme.of(context).colorScheme.primary;
@ -50,14 +50,12 @@ enum AssistanceStateEnum {
_ => false,
};
Color backgroundColor(BuildContext context) => switch (this) {
AssistanceStateEnum.noSub ||
AssistanceStateEnum.noMessage ||
AssistanceStateEnum.fetched ||
AssistanceStateEnum.complete ||
AssistanceStateEnum.error => Theme.of(
context,
).colorScheme.surfaceContainerHighest,
_ => Theme.of(context).colorScheme.primaryContainer,
bool get showIcon => switch (this) {
AssistanceStateEnum.noSub => true,
AssistanceStateEnum.noMessage => true,
AssistanceStateEnum.notFetched => true,
AssistanceStateEnum.error => true,
AssistanceStateEnum.complete => true,
_ => false,
};
}

View file

@ -273,7 +273,7 @@ class Choreographer extends ChangeNotifier {
openMatches: [],
);
if (igcController.openAutomaticMatches.isNotEmpty) {
if (igcController.openNormalizationMatches.isNotEmpty) {
await igcController.acceptNormalizationMatches();
} else {
// trigger a re-render of the text field to show IGC matches
@ -291,7 +291,7 @@ class Choreographer extends ChangeNotifier {
_startLoading();
final success = await igcController.rerunWithFeedback(feedbackText);
if (success && igcController.openAutomaticMatches.isNotEmpty) {
if (success && igcController.openNormalizationMatches.isNotEmpty) {
await igcController.acceptNormalizationMatches();
}
_stopLoading();
@ -423,7 +423,7 @@ class Choreographer extends ChangeNotifier {
switch (match.updatedMatch.status) {
case PangeaMatchStatusEnum.accepted:
case PangeaMatchStatusEnum.automatic:
case PangeaMatchStatusEnum.ignored:
case PangeaMatchStatusEnum.viewed:
_record.addRecord(textController.text, match: match.updatedMatch);
case PangeaMatchStatusEnum.undo:
_record.choreoSteps.removeWhere(

View file

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

View file

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

View file

@ -30,46 +30,39 @@ class IgcController {
/// Last response received - stored for feedback rerun
IGCResponseModel? _lastResponse;
final List<PangeaMatchState> _openMatches = [];
final List<PangeaMatchState> _closedMatches = [];
final List<PangeaMatchState> _matches = [];
StreamController<PangeaMatchState> matchUpdateStream =
StreamController.broadcast();
String? get currentText => _currentText;
List<PangeaMatchState> get openMatches => _openMatches;
ValueNotifier<PangeaMatchState?> activeMatch = ValueNotifier(null);
List<PangeaMatchState> get recentAutomaticCorrections => _closedMatches
.reversed
.takeWhile(
(m) => m.updatedMatch.status == PangeaMatchStatusEnum.automatic,
String? get currentText => _currentText;
List<PangeaMatchState> get matches => _matches;
List<PangeaMatchState> get sortedMatches => _matches.sorted(
(a, b) =>
a.updatedMatch.match.offset.compareTo(b.updatedMatch.match.offset),
);
List<PangeaMatchState> get openMatches =>
_matches.where((m) => m.updatedMatch.status.isOpen).toList();
bool get hasOpenMatches => openMatches.isNotEmpty;
List<PangeaMatchState> get closedNormalizationCorrections => _matches
.where((m) => m.updatedMatch.status == PangeaMatchStatusEnum.automatic)
.toList();
List<PangeaMatchState> get openNormalizationMatches => _matches
.where(
(match) =>
match.updatedMatch.status.isOpen &&
match.updatedMatch.match.isNormalizationError(),
)
.toList();
List<PangeaMatchState> get openAutomaticMatches => _openMatches
.where((match) => match.updatedMatch.match.isNormalizationError())
.toList();
PangeaMatchState? get currentlyOpenMatch {
final RegExp pattern = RegExp(r'span_card_overlay_.+');
final String? matchingKey = MatrixState.pAnyState
.getMatchingOverlayKeys(pattern)
.firstOrNull;
if (matchingKey == null) return null;
final parts = matchingKey.split('_');
if (parts.length != 5) return null;
final offset = int.tryParse(parts[3]);
final length = int.tryParse(parts[4]);
if (offset == null || length == null) return null;
return _openMatches.firstWhereOrNull(
(match) =>
match.updatedMatch.match.offset == offset &&
match.updatedMatch.match.length == length,
);
}
IGCRequestModel _igcRequest(
String text,
List<PreviousMessage> prevMessages,
@ -83,6 +76,7 @@ class IgcController {
void dispose() {
matchUpdateStream.close();
activeMatch.dispose();
}
void clear() {
@ -90,132 +84,99 @@ class IgcController {
_currentText = null;
_lastRequest = null;
_lastResponse = null;
_openMatches.clear();
_closedMatches.clear();
_matches.clear();
MatrixState.pAnyState.closeAllOverlays();
}
void clearMatches() {
_openMatches.clear();
_closedMatches.clear();
}
void clearMatches() => _matches.clear();
void clearCurrentText() => _currentText = null;
void _filterPreviouslyIgnoredMatches() {
for (final match in _openMatches) {
if (IgcRepo.isIgnored(match.updatedMatch)) {
updateOpenMatch(match, PangeaMatchStatusEnum.ignored);
void setActiveMatch({PangeaMatchState? match}) {
if (match != null) {
final isValidMatch = _matches.any((m) => m == match);
if (!isValidMatch) {
throw "setActiveMatch called with invalid match";
}
}
if (_matches.isEmpty) {
throw "setActiveMatch called without open matches";
}
match ??= openMatches.firstOrNull ?? _matches.first;
if (match.updatedMatch.status == PangeaMatchStatusEnum.open) {
updateMatchStatus(match, PangeaMatchStatusEnum.viewed);
}
activeMatch.value = match;
}
PangeaMatchState? getMatchByOffset(int offset) =>
_openMatches.firstWhereOrNull(
(match) => match.updatedMatch.match.isOffsetInMatchSpan(offset),
);
void clearActiveMatch() => activeMatch.value = null;
PangeaMatchState? getMatchByOffset(int offset) => matches.firstWhereOrNull(
(match) => match.updatedMatch.match.isOffsetInMatchSpan(offset),
);
void setSpanData(PangeaMatchState matchState, SpanData spanData) {
final openMatch = _openMatches.firstWhereOrNull(
final openMatch = openMatches.firstWhereOrNull(
(m) => m.originalMatch == matchState.originalMatch,
);
matchState.setMatch(spanData);
_openMatches.remove(openMatch);
_openMatches.add(matchState);
_matches.remove(openMatch);
_matches.add(matchState);
}
void updateMatch(PangeaMatchState match, PangeaMatchStatusEnum status) {
PangeaMatchState updated;
switch (status) {
case PangeaMatchStatusEnum.accepted:
case PangeaMatchStatusEnum.automatic:
updated = updateOpenMatch(match, status);
case PangeaMatchStatusEnum.ignored:
IgcRepo.ignore(match.updatedMatch);
updated = updateOpenMatch(match, status);
case PangeaMatchStatusEnum.undo:
updated = updateClosedMatch(match, status);
default:
throw "updateMatch called with unsupported status: $status";
}
matchUpdateStream.add(updated);
}
PangeaMatchState updateOpenMatch(
PangeaMatchState matchState,
PangeaMatchStatusEnum status,
) {
final PangeaMatchState openMatch = _openMatches.firstWhere(
(m) => m.originalMatch == matchState.originalMatch,
orElse: () =>
throw StateError('No open match found while updating match.'),
void updateMatchStatus(PangeaMatchState match, PangeaMatchStatusEnum status) {
final PangeaMatchState currentMatch = _matches.firstWhere(
(m) => m.originalMatch == match.originalMatch,
orElse: () => throw StateError('No match found while updating match.'),
);
matchState.setStatus(status);
_openMatches.remove(openMatch);
_closedMatches.add(matchState);
final selectedChoice = match.updatedMatch.match.selectedChoice;
match.setStatus(status);
if (status == PangeaMatchStatusEnum.undo) {
match.resetChoices();
}
_matches.remove(currentMatch);
_matches.add(match);
switch (status) {
case PangeaMatchStatusEnum.accepted:
case PangeaMatchStatusEnum.automatic:
final choice = matchState.updatedMatch.match.selectedChoice;
if (choice == null) {
if (selectedChoice == null) {
throw ArgumentError('acceptMatch called with a null selectedChoice.');
}
_applyReplacement(
matchState.updatedMatch.match.offset,
matchState.updatedMatch.match.length,
choice.value,
match.updatedMatch.match.offset,
match.updatedMatch.match.length,
selectedChoice.value,
);
case PangeaMatchStatusEnum.ignored:
case PangeaMatchStatusEnum.undo:
final selectedValue = selectedChoice?.value;
if (selectedValue == null) {
throw StateError(
'Cannot update match without a selectedChoice value.',
);
}
final currentOffset = match.updatedMatch.match.offset;
final currentLength = match.updatedMatch.match.length;
final replacement = match.originalMatch.match.errorSpan;
_applyReplacement(currentOffset, currentLength, replacement);
case PangeaMatchStatusEnum.open:
case PangeaMatchStatusEnum.viewed:
break;
default:
throw ArgumentError(
'updateOpenMatch called with unsupported status: $status',
);
}
return matchState;
}
PangeaMatchState updateClosedMatch(
PangeaMatchState matchState,
PangeaMatchStatusEnum status,
) {
final closedMatch = _closedMatches.firstWhere(
(m) => m.originalMatch == matchState.originalMatch,
orElse: () =>
throw StateError('No closed match found while updating match.'),
);
matchState.setStatus(status);
_closedMatches.remove(closedMatch);
final selectedValue = matchState.updatedMatch.match.selectedChoice?.value;
if (selectedValue == null) {
throw StateError('Cannot update match without a selectedChoice value.');
}
final replacement = matchState.originalMatch.match.fullText.characters
.getRange(
matchState.originalMatch.match.offset,
matchState.originalMatch.match.offset +
matchState.originalMatch.match.length,
)
.toString();
_applyReplacement(
matchState.originalMatch.match.offset,
selectedValue.characters.length,
replacement,
);
return matchState;
matchUpdateStream.add(match);
}
Future<void> acceptNormalizationMatches() async {
final matches = openAutomaticMatches;
final matches = openNormalizationMatches;
if (matches.isEmpty) return;
final expectedSpans = matches.map((m) => m.originalMatch).toSet();
@ -237,7 +198,7 @@ class IgcController {
try {
for (final match in matches) {
match.selectBestChoice();
updateMatch(match, PangeaMatchStatusEnum.automatic);
updateMatchStatus(match, PangeaMatchStatusEnum.automatic);
}
// If no updates arrive (edge case), auto-timeout after a short delay
@ -262,22 +223,26 @@ class IgcController {
if (_currentText == null) {
throw StateError('_applyReplacement called with null _currentText');
}
final start = _currentText!.characters.take(offset);
final end = _currentText!.characters.skip(offset + length);
final updatedText = start + replacement.characters + end;
_currentText = updatedText.toString();
for (final list in [_openMatches, _closedMatches]) {
for (final matchState in list) {
final match = matchState.updatedMatch.match;
final updatedMatch = match.copyWith(
fullText: _currentText,
offset: match.offset > offset
? match.offset + replacement.characters.length - length
: match.offset,
);
matchState.setMatch(updatedMatch);
}
final lengthOffset = replacement.characters.length - length;
for (final matchState in _matches) {
final match = matchState.updatedMatch.match;
final updatedMatch = match.copyWith(
fullText: _currentText,
offset: match.offset > offset
? match.offset + lengthOffset
: match.offset,
length: match.offset == offset && match.length == length
? replacement.characters.length
: match.length,
);
matchState.setMatch(updatedMatch);
}
}
@ -377,13 +342,8 @@ class IgcController {
status: PangeaMatchStatusEnum.open,
original: match,
);
if (match.status == PangeaMatchStatusEnum.open) {
_openMatches.add(matchState);
} else {
_closedMatches.add(matchState);
}
_matches.add(matchState);
}
_filterPreviouslyIgnoredMatches();
_isFetching = false;
return true;
}

View file

@ -7,7 +7,6 @@ import 'package:http/http.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_model.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import '../../common/network/requests.dart';
@ -20,30 +19,8 @@ class _IgcCacheItem {
const _IgcCacheItem({required this.data, required this.timestamp});
}
class _IgnoredMatchCacheItem {
final PangeaMatch match;
final DateTime timestamp;
String get spanText => match.match.fullText.characters
.skip(match.match.offset)
.take(match.match.length)
.toString();
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is _IgnoredMatchCacheItem && other.spanText == spanText;
}
@override
int get hashCode => spanText.hashCode;
_IgnoredMatchCacheItem({required this.match, required this.timestamp});
}
class IgcRepo {
static final Map<String, _IgcCacheItem> _igcCache = {};
static final Map<String, _IgnoredMatchCacheItem> _ignoredMatchCache = {};
static const Duration _cacheDuration = Duration(minutes: 10);
static Future<Result<IGCResponseModel>> get(
@ -125,37 +102,4 @@ class IgcRepo {
data: response,
timestamp: DateTime.now(),
);
static void ignore(PangeaMatch match) {
_setCachedIgnoredSpan(match);
}
static bool isIgnored(PangeaMatch match) {
final cached = _getCachedIgnoredSpan(match);
return cached != null;
}
static PangeaMatch? _getCachedIgnoredSpan(PangeaMatch match) {
final cacheKeys = [..._ignoredMatchCache.keys];
for (final key in cacheKeys) {
final entry = _ignoredMatchCache[key]!;
if (DateTime.now().difference(entry.timestamp) >= _cacheDuration) {
_ignoredMatchCache.remove(key);
}
}
final cacheEntry = _IgnoredMatchCacheItem(
match: match,
timestamp: DateTime.now(),
);
return _ignoredMatchCache[cacheEntry.hashCode.toString()]?.match;
}
static void _setCachedIgnoredSpan(PangeaMatch match) {
final cacheEntry = _IgnoredMatchCacheItem(
match: match,
timestamp: DateTime.now(),
);
_ignoredMatchCache[cacheEntry.hashCode.toString()] = cacheEntry;
}
}

View file

@ -25,11 +25,16 @@ class PangeaMatch {
? json[_matchKey] as Map<String, dynamic>
: json;
final statusEntry = json[_statusKey] as String?;
return PangeaMatch(
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)
status: isV1Format && statusEntry != null
? PangeaMatchStatusEnum.values.firstWhere(
(status) => status.name == statusEntry,
orElse: () => PangeaMatchStatusEnum.open,
)
: PangeaMatchStatusEnum.open,
);
}

View file

@ -1,5 +1,6 @@
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_status_enum.dart';
import 'package:fluffychat/pangea/choreographer/igc/span_choice_type_enum.dart';
import 'package:fluffychat/pangea/choreographer/igc/span_data_model.dart';
class PangeaMatchState {
@ -41,10 +42,28 @@ class PangeaMatchState {
throw Exception('No choices available to select best choice from.');
}
selectChoice(
updatedMatch.match.choices!.indexWhere((c) => c.isBestCorrection),
updatedMatch.match.choices!.indexWhere((c) => c.type.isSuggestion),
);
}
void resetChoices() {
if (_match.choices == null) {
throw Exception('No choices available to reset.');
}
final resetChoices = _match.choices!
.map(
(c) => SpanChoice(
value: c.value,
type: c.type,
feedback: c.feedback,
selected: false,
timestamp: null,
),
)
.toList();
setMatch(_match.copyWith(choices: resetChoices));
}
Map<String, dynamic> toJson() {
return {
'originalMatch': _original.toJson(),

View file

@ -1,26 +1,26 @@
enum PangeaMatchStatusEnum {
open,
ignored,
accepted,
automatic,
undo,
unknown;
viewed,
undo;
static PangeaMatchStatusEnum fromString(String status) {
final String lastPart = status.toString().split('.').last;
switch (lastPart) {
case 'open':
return PangeaMatchStatusEnum.open;
case 'ignored':
return PangeaMatchStatusEnum.ignored;
case 'accepted':
return PangeaMatchStatusEnum.accepted;
case 'automatic':
return PangeaMatchStatusEnum.automatic;
case 'undo':
return PangeaMatchStatusEnum.undo;
default:
return PangeaMatchStatusEnum.unknown;
}
}
bool get isOpen => switch (this) {
open => true,
viewed => true,
undo => true,
_ => false,
};
double get underlineOpacity => switch (this) {
open => 0.8,
_ => 0.25,
};
double get igcButtonOpacity => switch (this) {
open => 0.8,
accepted => 0.8,
automatic => 0.8,
_ => 0.25,
};
}

View file

@ -259,24 +259,32 @@ extension SpanDataTypeEnumExt on ReplacementTypeEnum {
/// Returns the underline color for this replacement type.
/// Used to visually distinguish different error categories in the text field.
Color underlineColor() {
Color get color {
// IT start and auto-apply types use primary color
if (this == ReplacementTypeEnum.itStart || isAutoApply) {
if (this == ReplacementTypeEnum.itStart) {
return AppConfig.primaryColor;
}
// Grammar errors use warning/orange
// Mint green
if (isAutoApply) {
return Color.fromARGB(255, 152, 255, 152);
}
// Grammar errors use Coral / warm pink
if (isGrammarType) {
return AppConfig.warning;
return Color.fromARGB(255, 245, 122, 138);
}
// Word choice uses blue
// Word choice uses Sky blue
if (isWordChoiceType) {
return Colors.blue;
return Color.fromARGB(255, 135, 206, 235);
}
// Style and fluency use teal
// Style and fluency use Lavender
switch (this) {
case ReplacementTypeEnum.style:
case ReplacementTypeEnum.fluency:
return Colors.teal;
return Color.fromARGB(255, 188, 139, 194);
case ReplacementTypeEnum.translation:
return Color.fromARGB(255, 255, 126, 0); // Amber
default:
// Other/unknown use error color
return AppConfig.error;

View file

@ -0,0 +1,105 @@
import 'dart:math';
import 'package:flutter/material.dart';
class SegmentedCircularProgress extends StatelessWidget {
final List<Segment> segments;
final double strokeWidth;
final Widget? child;
const SegmentedCircularProgress({
super.key,
required this.segments,
this.strokeWidth = 4,
this.child,
});
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _SegmentedPainter(segments: segments, strokeWidth: strokeWidth),
child: child,
);
}
}
class Segment {
final double value; // relative value
final Color color;
final double opacity;
Segment(this.value, this.color, {this.opacity = 1.0});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Segment &&
runtimeType == other.runtimeType &&
value == other.value &&
color == other.color &&
opacity == other.opacity;
@override
int get hashCode => value.hashCode ^ color.hashCode ^ opacity.hashCode;
}
class _SegmentedPainter extends CustomPainter {
final List<Segment> segments;
final double strokeWidth;
final double gapFactor = 1.4;
const _SegmentedPainter({required this.segments, this.strokeWidth = 10});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..isAntiAlias = true;
final rect = Offset.zero & size;
final arcRect = rect.deflate(strokeWidth / 2);
final radius = arcRect.width / 2;
final center = arcRect.center;
if (segments.isEmpty) return;
if (segments.length == 1) {
final segment = segments.first;
paint.color = segment.color.withAlpha((segment.opacity * 255).ceil());
canvas.drawCircle(center, radius, paint);
return;
}
final total = segments.fold<double>(0, (sum, s) => sum + s.value);
paint.strokeCap = StrokeCap.round;
final baseCapAngle = strokeWidth / radius;
final capAngle = baseCapAngle * gapFactor;
double startAngle = -pi / 2;
for (final segment in segments) {
final rawSweep = (segment.value / total) * 2 * pi;
final sweep = rawSweep - capAngle;
if (sweep <= 0) {
startAngle += rawSweep;
continue;
}
paint.color = segment.color.withAlpha((segment.opacity * 255).ceil());
canvas.drawArc(arcRect, startAngle + capAngle / 2, sweep, false, paint);
startAngle += rawSweep;
}
}
@override
bool shouldRepaint(covariant _SegmentedPainter oldDelegate) {
return oldDelegate.segments != segments ||
oldDelegate.strokeWidth != strokeWidth;
}
}

View file

@ -1,33 +1,32 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
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/assistance_state_enum.dart';
import 'package:fluffychat/pangea/choreographer/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/choreographer_state_extension.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/error_handler.dart';
import 'package:fluffychat/pangea/common/widgets/choice_array.dart';
import 'package:fluffychat/pangea/common/widgets/feedback_dialog.dart';
import '../../../widgets/matrix.dart';
import '../../common/widgets/choice_array.dart';
import 'package:fluffychat/widgets/matrix.dart';
class SpanCard extends StatefulWidget {
final PangeaMatchState match;
final Choreographer choreographer;
final VoidCallback showNextMatch;
final Future Function(String) onFeedbackSubmitted;
final VoidCallback close;
const SpanCard({
super.key,
required this.match,
required this.choreographer,
required this.showNextMatch,
required this.onFeedbackSubmitted,
required this.close,
});
@override
@ -37,35 +36,70 @@ class SpanCard extends StatefulWidget {
class SpanCardState extends State<SpanCard> {
final ScrollController scrollController = ScrollController();
double? _previousOffset;
Offset _slideFrom = const Offset(0.1, 0); // default slide from right
@override
void initState() {
super.initState();
widget.choreographer.addListener(_onAssistanceStateChange);
}
@override
void dispose() {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.choreographer.igcController.clearActiveMatch();
});
scrollController.dispose();
widget.choreographer.removeListener(_onAssistanceStateChange);
super.dispose();
}
SpanChoice? get _selectedChoice =>
widget.match.updatedMatch.match.selectedChoice;
ValueNotifier<PangeaMatchState?> get _activeMatch =>
widget.choreographer.igcController.activeMatch;
void _onChoiceSelect(int index) {
widget.match.selectChoice(index);
setState(() {});
void _onAssistanceStateChange() {
if (widget.choreographer.assistanceState != AssistanceStateEnum.fetched) {
widget.close();
}
}
void _updateMatch(PangeaMatchStatusEnum status) {
Future<void> _onChoiceSelect(
PangeaMatchState match,
int index,
PangeaMatchStatusEnum status,
) async {
final choice = match.updatedMatch.match.choices?[index];
final correct = choice?.type.isSuggestion == true;
final selected = choice?.selected == true;
match.selectChoice(index);
setState(() {});
if (!correct && !selected) return;
await Future.delayed(
Duration(milliseconds: 600),
() => _updateMatch(match, status),
);
}
Future<void> _updateMatch(
PangeaMatchState match,
PangeaMatchStatusEnum status,
) async {
try {
widget.choreographer.igcController.updateMatch(widget.match, status);
widget.showNextMatch();
final igc = widget.choreographer.igcController;
igc.updateMatchStatus(match, status);
if (!status.isOpen) {
igc.hasOpenMatches ? igc.setActiveMatch() : widget.close();
}
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
level: SentryLevel.warning,
data: {"match": widget.match.toJson()},
data: {"match": match.toJson()},
);
widget.choreographer.clearMatches(e);
return;
@ -89,172 +123,185 @@ class SpanCardState extends State<SpanCard> {
@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,
return StreamBuilder(
stream: widget.choreographer.igcController.matchUpdateStream.stream,
builder: (context, _) => SizedBox(
height: 200.0,
child: ValueListenableBuilder(
valueListenable: _activeMatch,
builder: (context, match, _) {
if (match == null) return SizedBox();
final newOffset = match.updatedMatch.match.offset.toDouble();
if (_previousOffset != null) {
if (newOffset < _previousOffset!) {
// Moving backward slide from left
_slideFrom = const Offset(-0.1, 0);
} else if (newOffset > _previousOffset!) {
// Moving forward slide from right
_slideFrom = const Offset(0.1, 0);
}
}
_previousOffset = newOffset;
return Column(
mainAxisSize: .min,
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,
child: SingleChildScrollView(
controller: scrollController,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12.0,
horizontal: 24.0,
),
child: Column(
spacing: 12.0,
SizedBox(
height: 40.0,
child: Row(
children: [
ChoicesArray(
isLoading: false,
choices: widget.match.updatedMatch.match.choices
?.map(
(e) => Choice(
text: e.value,
color: e.selected ? e.type.color : null,
isGold: e.type.isSuggestion,
),
)
.toList(),
onPressed: (value, index) => _onChoiceSelect(index),
selectedChoiceIndex:
widget.match.updatedMatch.match.selectedChoiceIndex,
id: widget.match.hashCode.toString(),
langCode: MatrixState
.pangeaController
.userController
.userL2Code!,
IconButton(
icon: const Icon(Icons.close),
color: Theme.of(context).iconTheme.color,
onPressed: widget.close,
),
Expanded(
child: Text(
match.updatedMatch.match.type.displayName(context),
textAlign: TextAlign.center,
style: BotStyle.text(
context,
big: true,
).copyWith(fontWeight: FontWeight.bold),
),
),
IconButton(
icon: const Icon(Icons.flag_outlined),
color: Theme.of(context).iconTheme.color,
onPressed: _showFeedbackDialog,
),
const SizedBox(),
_SpanCardFeedback(widget.match.updatedMatch.match),
],
),
),
),
),
),
_SpanCardButtons(
onAccept: () => _updateMatch(PangeaMatchStatusEnum.accepted),
onIgnore: () => _updateMatch(PangeaMatchStatusEnum.ignored),
selectedChoice: _selectedChoice,
),
],
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, animation) {
final slideAnimation = Tween<Offset>(
begin: _slideFrom,
end: Offset.zero,
).animate(animation);
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: slideAnimation,
child: child,
),
);
},
child: _MatchContent(
key: ValueKey(match.hashCode),
match: match,
scrollController: scrollController,
onChoiceSelect: _onChoiceSelect,
onUpdateMatch: _updateMatch,
),
),
),
],
);
},
),
),
);
}
}
class _SpanCardFeedback extends StatelessWidget {
final SpanData? span;
const _SpanCardFeedback(this.span);
class _MatchContent extends StatelessWidget {
final PangeaMatchState match;
final ScrollController scrollController;
final Future<void> Function(PangeaMatchState, int, PangeaMatchStatusEnum)
onChoiceSelect;
final Future<void> Function(PangeaMatchState, PangeaMatchStatusEnum)
onUpdateMatch;
@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: [
span == null || span!.selectedChoice == null
? defaultContent
: Text(
span!.selectedChoice!.feedbackToDisplay(context),
style: BotStyle.text(context),
),
],
);
}
}
class _SpanCardButtons extends StatelessWidget {
final VoidCallback onAccept;
final VoidCallback onIgnore;
final SpanChoice? selectedChoice;
const _SpanCardButtons({
required this.onAccept,
required this.onIgnore,
required this.selectedChoice,
const _MatchContent({
super.key,
required this.match,
required this.scrollController,
required this.onChoiceSelect,
required this.onUpdateMatch,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(color: Theme.of(context).cardColor),
padding: const EdgeInsets.only(top: 12.0),
child: Row(
spacing: 10.0,
children: [
Expanded(
child: Opacity(
opacity: 0.8,
child: TextButton(
style: TextButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.primary.withAlpha(25),
),
onPressed: onIgnore,
child: Center(child: Text(L10n.of(context).ignoreInThisText)),
final isOpen = match.updatedMatch.status.isOpen;
return Scrollbar(
controller: scrollController,
child: SingleChildScrollView(
controller: scrollController,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0),
child: Column(
spacing: 12.0,
children: [
Text(
match.updatedMatch.match.message ??
match.updatedMatch.match.type.defaultPrompt(context),
style: BotStyle.text(context),
),
),
isOpen
? ChoicesArray(
isLoading: false,
choices: match.updatedMatch.match.choices?.map((e) {
return Choice(
text: e.value,
color: e.selected ? e.type.color : null,
isGold: e.type.isSuggestion,
);
}).toList(),
onPressed: (value, index) => onChoiceSelect(
match,
index,
PangeaMatchStatusEnum.accepted,
),
selectedChoiceIndex:
match.updatedMatch.match.selectedChoiceIndex,
id: match.hashCode.toString(),
langCode: MatrixState
.pangeaController
.userController
.userL2Code!,
)
: Row(
spacing: 16.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Wrap(
spacing: 8.0,
runSpacing: 4.0,
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(match.originalMatch.match.errorSpan),
const Icon(Icons.arrow_forward, size: 16.0),
Text(
match
.updatedMatch
.match
.selectedChoice
?.value ??
L10n.of(context).nothingFound,
),
],
),
),
IconButton(
icon: const Icon(Symbols.undo),
onPressed: () =>
onUpdateMatch(match, PangeaMatchStatusEnum.undo),
),
],
),
],
),
Expanded(
child: Opacity(
opacity: selectedChoice != null ? 1.0 : 0.5,
child: TextButton(
onPressed: selectedChoice != null ? onAccept : null,
style: TextButton.styleFrom(
backgroundColor:
(selectedChoice?.color ??
Theme.of(context).colorScheme.primary)
.withAlpha(50),
side: selectedChoice != null
? BorderSide(
color: selectedChoice!.color,
style: BorderStyle.solid,
width: 2.0,
)
: null,
),
child: Text(L10n.of(context).replace),
),
),
),
],
),
),
);
}

View file

@ -18,7 +18,8 @@ extension SpanChoiceExt on SpanChoiceTypeEnum {
// ignore: deprecated_member_use_from_same_package
this == SpanChoiceTypeEnum.bestCorrection ||
// ignore: deprecated_member_use_from_same_package
this == SpanChoiceTypeEnum.bestAnswer;
this == SpanChoiceTypeEnum.bestAnswer ||
this == SpanChoiceTypeEnum.alt;
String defaultFeedback(BuildContext context) {
switch (this) {

View file

@ -123,7 +123,7 @@ class SpanData {
offset >= this.offset && offset <= this.offset + length;
SpanChoice? get bestChoice {
return choices?.firstWhereOrNull((choice) => choice.isBestCorrection);
return choices?.firstWhereOrNull((choice) => choice.type.isSuggestion);
}
int get selectedChoiceIndex {
@ -166,7 +166,7 @@ class SpanData {
}
final correctChoice = choices
?.firstWhereOrNull((c) => c.isBestCorrection)
?.firstWhereOrNull((c) => c.type.isSuggestion)
?.value;
final l2Code =
@ -279,15 +279,13 @@ class SpanChoice {
return data;
}
String feedbackToDisplay(BuildContext context) {
String displayFeedback(BuildContext context) {
if (feedback == null) {
return type.defaultFeedback(context);
}
return feedback!;
}
bool get isBestCorrection => type.isSuggestion;
Color get color => type.color;
// override == operator and hashcode

View file

@ -1,9 +1,17 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/choreographer/assistance_state_enum.dart';
import 'package:fluffychat/pangea/choreographer/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/choreographer_state_extension.dart';
import 'package:fluffychat/pangea/choreographer/igc/pangea_match_state_model.dart';
import 'package:fluffychat/pangea/choreographer/igc/replacement_type_enum.dart';
import 'package:fluffychat/pangea/choreographer/igc/segmented_circular_progress.dart';
import 'package:fluffychat/pangea/learning_settings/settings_learning.dart';
class StartIGCButton extends StatefulWidget {
@ -11,7 +19,6 @@ class StartIGCButton extends StatefulWidget {
final Choreographer choreographer;
final AssistanceStateEnum initialState;
final Color initialForegroundColor;
final Color initialBackgroundColor;
const StartIGCButton({
super.key,
@ -19,7 +26,6 @@ class StartIGCButton extends StatefulWidget {
required this.choreographer,
required this.initialState,
required this.initialForegroundColor,
required this.initialBackgroundColor,
});
@override
@ -28,57 +34,75 @@ class StartIGCButton extends StatefulWidget {
class _StartIGCButtonState extends State<StartIGCButton>
with TickerProviderStateMixin {
AnimationController? _spinController;
late Animation<double> _rotation;
late final AnimationController _spinController;
late final Animation<double> _rotation;
late StreamSubscription _matchSubscription;
late AnimationController _segmentController;
AnimationController? _colorController;
late Animation<Color?> _iconColor;
late Animation<Color?> _backgroundColor;
AssistanceStateEnum? _prevState;
bool _shouldStop = false;
List<Segment> _prevSegments = [];
List<Segment> _currentSegments = [];
final Duration _animationDuration = const Duration(milliseconds: 300);
@override
void initState() {
super.initState();
_spinController =
AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
)..addStatusListener((status) {
if (status == AnimationStatus.completed) {
if (_shouldStop) {
_spinController?.stop();
_spinController?.value = 0;
} else {
_spinController?.forward(from: 0);
AnimationController(vsync: this, duration: _animationDuration)
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
if (_shouldStop) {
_spinController.stop();
_spinController.value = 0;
} else {
_spinController.forward(from: 0);
}
}
}
});
});
_rotation = Tween(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(parent: _spinController!, curve: Curves.linear));
).animate(CurvedAnimation(parent: _spinController, curve: Curves.linear));
_colorController = AnimationController(
_segmentController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
duration: _animationDuration,
);
_currentSegments = _segmentsForState(
widget.initialState,
widget.choreographer.igcController.activeMatch.value,
overrideColor: widget.initialForegroundColor,
);
_prevSegments = List.from(_currentSegments);
_segmentController.forward(from: 0.0);
_prevState = widget.initialState;
_iconColor = AlwaysStoppedAnimation(widget.initialForegroundColor);
_backgroundColor = AlwaysStoppedAnimation(widget.initialBackgroundColor);
_colorController!.forward(from: 0.0);
widget.choreographer.addListener(_handleStateChange);
widget.choreographer.igcController.activeMatch.addListener(_updateSegments);
_matchSubscription = widget
.choreographer
.igcController
.matchUpdateStream
.stream
.listen((_) => _updateSegments());
}
@override
void dispose() {
widget.choreographer.removeListener(_handleStateChange);
_spinController?.dispose();
_colorController?.dispose();
_spinController.dispose();
_segmentController.dispose();
_matchSubscription.cancel();
super.dispose();
}
@ -86,43 +110,115 @@ class _StartIGCButtonState extends State<StartIGCButton>
final prev = _prevState;
final current = widget.choreographer.assistanceState;
_prevState = current;
if (!mounted || prev == current) return;
final newIconColor = current.stateColor(context);
final newBgColor = current.backgroundColor(context);
final oldIconColor = _iconColor.value;
final oldBgColor = _backgroundColor.value;
// Create tweens from current new colors
_iconColor = ColorTween(
begin: oldIconColor,
end: newIconColor,
).animate(_colorController!);
_backgroundColor = ColorTween(
begin: oldBgColor,
end: newBgColor,
).animate(_colorController!);
_colorController!.forward(from: 0.0);
if (current == AssistanceStateEnum.fetching) {
_shouldStop = false;
_spinController!.forward(from: 0.0);
_spinController.forward(from: 0.0);
} else if (prev == AssistanceStateEnum.fetching) {
_shouldStop = true;
}
_updateSegments();
}
void _updateSegments() {
final activeMatch = widget.choreographer.igcController.activeMatch.value;
final assistanceState = widget.choreographer.assistanceState;
final newSegments = _segmentsForState(assistanceState, activeMatch);
if (_segmentsEqual(newSegments, _currentSegments)) return;
_prevSegments = List.from(_currentSegments);
_currentSegments = List.from(newSegments);
_segmentController.forward(from: 0.0);
}
List<Segment> _segmentsForState(
AssistanceStateEnum state,
PangeaMatchState? activeMatch, {
Color? overrideColor,
}) {
switch (state) {
case AssistanceStateEnum.noSub:
case AssistanceStateEnum.noMessage:
case AssistanceStateEnum.notFetched:
case AssistanceStateEnum.fetching:
final segmentPercent = (100 - 5 * 5) / 5; // size of each segment
return List.generate(5, (_) {
return Segment(
segmentPercent,
overrideColor ?? state.stateColor(context),
);
});
case AssistanceStateEnum.fetched:
case AssistanceStateEnum.complete:
final matches = widget.choreographer.igcController.sortedMatches;
if (matches.isEmpty) {
return [Segment(100, AppConfig.success)];
}
final segmentPercent = 100 / matches.length;
return matches.map((m) {
final isActiveMatch =
m.originalMatch.match.offset ==
activeMatch?.originalMatch.match.offset &&
m.originalMatch.match.length ==
activeMatch?.originalMatch.match.length;
final opacity = isActiveMatch
? 1.0
: m.updatedMatch.status.igcButtonOpacity;
return Segment(
segmentPercent,
m.updatedMatch.status.isOpen
? m.updatedMatch.match.type.color
: AppConfig.success,
opacity: opacity,
);
}).toList();
case AssistanceStateEnum.error:
break;
}
return [];
}
List<Segment> _getAnimatedSegments(double t) {
final maxLength = max(_prevSegments.length, _currentSegments.length);
return List.generate(maxLength, (i) {
final prev = i < _prevSegments.length
? _prevSegments[i]
: Segment(0, _currentSegments[i].color, opacity: 0);
final curr = i < _currentSegments.length
? _currentSegments[i]
: Segment(0, _prevSegments[i].color, opacity: 0);
return Segment(
lerpDouble(prev.value, curr.value, t)!,
Color.lerp(prev.color, curr.color, t)!,
opacity: lerpDouble(prev.opacity, curr.opacity, t)!,
);
}).where((s) => s.value > 0).toList();
}
bool _segmentsEqual(List<Segment> a, List<Segment> b) {
if (a.length != b.length) return false;
for (int i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
@override
Widget build(BuildContext context) {
if (_colorController == null || _spinController == null) {
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: Listenable.merge([_colorController!, _spinController!]),
builder: (context, child) {
final enableFeedback =
widget.choreographer.assistanceState.allowsFeedback;
return ListenableBuilder(
listenable: widget.choreographer,
builder: (_, _) {
final assistanceState = widget.choreographer.assistanceState;
final enableFeedback = assistanceState.allowsFeedback;
return Tooltip(
message: enableFeedback ? L10n.of(context).check : "",
child: Material(
@ -141,41 +237,34 @@ class _StartIGCButtonState extends State<StartIGCButton>
barrierDismissible: false,
)
: null,
child: Stack(
alignment: Alignment.center,
children: [
Container(
height: 40.0,
width: 40.0,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _backgroundColor.value,
),
),
AnimatedBuilder(
animation: _rotation,
builder: (context, child) {
return Transform.rotate(
angle: _rotation.value * 2 * 3.14159,
child: child,
);
},
child: Icon(
Icons.autorenew_rounded,
size: 36,
color: _iconColor.value,
),
),
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _backgroundColor.value,
),
),
Icon(size: 16, Icons.check, color: _iconColor.value),
],
child: Container(
width: 40,
height: 40,
padding: const EdgeInsets.all(2.0),
child: AnimatedBuilder(
animation: Listenable.merge([_rotation, _segmentController]),
builder: (context, _) {
final segments = _getAnimatedSegments(
_segmentController.value,
);
return Transform.rotate(
angle: _rotation.value * 2 * pi,
child: SegmentedCircularProgress(
strokeWidth: 3,
segments: segments,
child: AnimatedOpacity(
duration: _animationDuration,
opacity: assistanceState.showIcon ? 1.0 : 0.0,
child: Icon(
size: 18,
Icons.check,
color: assistanceState.stateColor(context),
),
),
),
);
},
),
),
),
),

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/config/app_config.dart';
@ -26,35 +27,24 @@ class PangeaTextController extends TextEditingController {
bool get exceededMaxLength => text.length >= ChoreoConstants.maxLength;
TextStyle _underlineStyle(Color color) => TextStyle(
decoration: TextDecoration.underline,
decorationColor: color,
decorationThickness: 5,
TextStyle _underlineStyle(Color color, bool isSelected) => TextStyle(
decoration: isSelected ? null : TextDecoration.underline,
decorationColor: isSelected ? null : color,
decorationThickness: isSelected ? null : 5,
backgroundColor: isSelected ? color : null,
);
Color _underlineColor(PangeaMatch match) {
final status = match.status;
final opacity = status.underlineOpacity;
final alpha = (255 * opacity).ceil();
// Automatic corrections use primary color
if (match.status == PangeaMatchStatusEnum.automatic) {
return AppConfig.primaryColor;
if (status == PangeaMatchStatusEnum.automatic) {
return AppConfig.primaryColor.withAlpha(alpha);
}
// Use type-based coloring
return match.match.type.underlineColor();
}
TextStyle _textStyle(
PangeaMatch match,
TextStyle? existingStyle,
bool isOpenMatch,
) {
double opacityFactor = 1.0;
if (!isOpenMatch) {
opacityFactor = 0.4;
}
final alpha = (255 * opacityFactor).round();
final style = _underlineStyle(_underlineColor(match).withAlpha(alpha));
return existingStyle?.merge(style) ?? style;
return match.match.type.color.withAlpha(alpha);
}
void setSystemText(String newText, EditTypeEnum type) {
@ -76,7 +66,7 @@ class PangeaTextController extends TextEditingController {
void _onUndo(PangeaMatchState match) {
try {
choreographer.igcController.updateMatch(
choreographer.igcController.updateMatchStatus(
match,
PangeaMatchStatusEnum.undo,
);
@ -117,7 +107,7 @@ class PangeaTextController extends TextEditingController {
return TextSpan(
style: style,
children: [
..._buildTokenSpan(defaultStyle: style),
..._buildTokenSpan(style),
TextSpan(text: parts[1], style: style),
],
);
@ -126,11 +116,15 @@ class PangeaTextController extends TextEditingController {
TextSpan _buildPaywallSpan(TextStyle? style) => TextSpan(
text: text,
style: style?.merge(
_underlineStyle(const Color.fromARGB(187, 132, 96, 224)),
_underlineStyle(const Color.fromARGB(187, 132, 96, 224), false),
),
);
InlineSpan _buildMatchSpan(PangeaMatchState match, TextStyle style) {
InlineSpan _buildMatchSpan(
PangeaMatchState match,
bool isSelected,
TextStyle? existingStyle,
) {
final span = choreographer.igcController.currentText!.characters
.getRange(
match.updatedMatch.match.offset,
@ -138,39 +132,43 @@ class PangeaTextController extends TextEditingController {
)
.toString();
if (match.updatedMatch.status == PangeaMatchStatusEnum.automatic) {
final originalText = match.originalMatch.match.fullText.characters
.getRange(
match.originalMatch.match.offset,
match.originalMatch.match.offset + match.originalMatch.match.length,
)
.toString();
// If selected, do full highlight with match color.
// If open, do underline with high opacity match color.
// Otherwise (viewed / accepted), do underline with lower opacity match color.
final matchColor = _underlineColor(match.updatedMatch);
final underlineStyle = _underlineStyle(matchColor, isSelected);
final textStyle = existingStyle != null
? existingStyle.merge(underlineStyle)
: underlineStyle;
final originalText = match.originalMatch.match.fullText.characters
.getRange(
match.originalMatch.match.offset,
match.originalMatch.match.offset + match.originalMatch.match.length,
)
.toString();
if (match.updatedMatch.status == PangeaMatchStatusEnum.automatic) {
return AutocorrectSpan(
transformTargetId:
"autocorrection_${match.updatedMatch.match.offset}_${match.updatedMatch.match.length}",
currentText: span,
originalText: originalText,
onUndo: () => _onUndo(match),
style: style,
style: textStyle,
);
} else {
return TextSpan(text: span, style: style);
return TextSpan(text: span, style: textStyle);
}
}
/// Returns a list of [TextSpan]s used to display the text in the input field
/// with the appropriate styling for each error match.
List<InlineSpan> _buildTokenSpan({TextStyle? defaultStyle}) {
final textSpanMatches =
[
...choreographer.igcController.openMatches,
...choreographer.igcController.recentAutomaticCorrections,
]..sort(
(a, b) => a.updatedMatch.match.offset.compareTo(
b.updatedMatch.match.offset,
),
);
List<InlineSpan> _buildTokenSpan(TextStyle? defaultStyle) {
final textSpanMatches = choreographer.igcController.matches.sorted(
(a, b) =>
a.updatedMatch.match.offset.compareTo(b.updatedMatch.match.offset),
);
final currentText = choreographer.igcController.currentText!;
final spans = <InlineSpan>[];
@ -185,16 +183,12 @@ class PangeaTextController extends TextEditingController {
}
final openMatch =
choreographer.igcController.currentlyOpenMatch?.updatedMatch.match;
final style = _textStyle(
match.updatedMatch,
defaultStyle,
openMatch != null &&
openMatch.offset == match.updatedMatch.match.offset &&
openMatch.length == match.updatedMatch.match.length,
);
choreographer.igcController.activeMatch.value?.updatedMatch.match;
final isSelected =
openMatch?.offset == match.updatedMatch.match.offset &&
openMatch?.length == match.updatedMatch.match.length;
spans.add(_buildMatchSpan(match, style));
spans.add(_buildMatchSpan(match, isSelected, defaultStyle));
cursor =
match.updatedMatch.match.offset + match.updatedMatch.match.length;
}

View file

@ -121,9 +121,11 @@ class PangeaAnyState {
return box?.hasSize == true ? box : null;
}
bool isOverlayOpen(RegExp regex) {
bool isOverlayOpen({RegExp? regex, String? overlayKey}) {
return entries.any(
(element) => element.key != null && regex.hasMatch(element.key!),
(element) =>
element.key != null &&
(regex?.hasMatch(element.key!) == true || element.key == overlayKey),
);
}

View file

@ -222,19 +222,16 @@ class OverlayUtil {
PangeaMatchState match,
Choreographer choreographer,
BuildContext context,
VoidCallback showNextMatch,
Future Function(String) onFeedbackSubmitted,
) {
MatrixState.pAnyState.closeAllOverlays();
showPositionedCard(
overlayKey:
"span_card_overlay_${match.updatedMatch.match.offset}_${match.updatedMatch.match.length}",
overlayKey: 'span-card-overlay',
context: context,
cardToShow: SpanCard(
match: match,
choreographer: choreographer,
showNextMatch: showNextMatch,
onFeedbackSubmitted: onFeedbackSubmitted,
close: () => MatrixState.pAnyState.closeOverlay('span-card-overlay'),
),
maxHeight: 325,
maxWidth: 325,