Skip to content

feat: hear one sentence, write it down, be told what you missed (M11, 4/4) - #38

Merged
widgetii merged 3 commits into
mainfrom
feat/m11-practice-screen
Oct 20, 2026
Merged

widgetii merged 3 commits into
mainfrom
feat/m11-practice-screen

Conversation

@widgetii

Copy link
Copy Markdown
Member

The screen the rest of M11 existed to make possible: play one phrase, type what
you heard, and see where you were wrong — 听力 and 听写 in the same place as the
text. Fourth of four.

What it does

A text with a narration grows a headphones button in the reader's toolbar. It
opens a sheet that offers to listen to the recording once, on this device, and
then steps through the text one sentence at a time:

  • A progress strip showing which sentences are untried, passed, or still work.
  • A transport for the current phrase, with the speed picker and a repeat
    menu — how many times each phrase plays and how long a gap it leaves.
  • A writing field that holds focus throughout, and a transcript that stays
    hidden until the attempt is checked. That is not a preference: the answer
    being on screen is the one thing that makes dictation worthless, so it is
    revealed by checking or by giving up, and by nothing else.
  • Per-character marking, grades, and a filter that drills only what still needs
    it.

The three kinds of wrong

This is the reason to build it inside HanReader rather than run a
general-purpose dictation tool beside it. The readings are already open, so:

Typed Expected Marked Because
真 针 homophone both zhen1 — heard right, wrong candidate from the IME
朵 多 tone slip duo3 against duo1 — the syllable was heard, the tone was not
anything else misheard no reading in common

The score written down is the listening one, counting a homophone as heard,
so an input-method slip does not keep a sentence in the drill. A tone slip
counts against it, because tones are what an ear loses first.

Shape

  • PracticeModel — phases (no audio / needs alignment / aligning / unavailable
    / ready), the replay filter, navigation, checking, grading, and running
    alignment with progress and cancellation.
  • PracticeScreen, PracticeComponents, DictationMarkedView,
    PracticeSheet — the views. A sheet rather than a mode of the reader:
    practice hides the text, which is the opposite of what the reader is for, and
    sharing one surface would mean every change to either reasoning about the
    other.
  • DictionaryService.readings(forCharacters:) — one actor hop for every
    character a marking needs, rather than one per character.
  • ReaderColor.dictation(_:) / .dictationFill(_:) — three distinct colours
    for the three kinds of wrong, because flattening them to one red throws away
    the only thing this says that a plain diff could not.

Every shortcut is a ⌘ chord. WorkAudioBook puts these on bare Space, arrows and
R, which it can because its writing box is a separate panel that must be focused
deliberately. Here the field holds focus throughout and the learner is typing
through a Chinese input method, where a bare Space commits a candidate and a
bare arrow moves between them.

A bug the tests did not find

Under the replay filter, passing a phrase dropped it from the list the instant
it passed. The strip then showed nothing as current, and moving on counted from
the top of what was left — so passing phrase 1 and pressing ⌘→ landed on phrase
3. The phrase being worked on now stays in the list until the reader moves off
it. Caught on the running app, fixed, and pinned by a test.

Verification

19 tests across two suites, and every one of 20 planted mutants dies —
including the one that records the strict score instead of the listening score,
which only a homophone can tell apart, so there is a test with a dictionary that
knows 真 and 针 are both zhen1.

On the running app, against 听力64 and its narration:

  • 32 phrases aligned at full confidence; phrase 6 lands exactly on
    「卖主是位六十多岁的老太太,她叫我给个价,我就说五角钱一个。」
  • Typed with a deliberate 事 for 是 and 朵 for 多: 24 of 26 correct, 1
    homophone, 1 tone, 96% heard
    , each marked in its own colour with the typed
    character above the expected one.
  • A grade persists, the needs-work filter holds, ⌘P plays and stops the phrase,
    and a drill of two repeats with a two-second gap plays twice and stops.

swift test green (465 + the UI target), make lint clean, and both app
targets built — macOS and generic/platform=iOS Simulator.

What is not here

M11 is not ticked. The plan puts a phrase-boundary editor in this stage and
it is not built: no waveform strip with draggable handles, no tap-along mode.
Until it exists a Mac too old for SpeechTranscriber has no way to place
boundaries by hand, so that screen now says so plainly rather than promising
it. The editor is the next PR.

🤖 Generated with Claude Code

… 4/4)

M11, fourth of four. The screen the rest of the milestone existed to make
possible: play one phrase, type what you heard, and see where you were
wrong — 听力 and 听写 in the same place as the text.

The transcript is hidden until the attempt is checked. That is not a
preference: the answer being on screen is the one thing that makes
dictation worthless, so it is revealed by checking or by giving up, and by
nothing else.

Marking tells the three kinds of wrong apart, which is the whole reason to
build this inside HanReader rather than run a general-purpose dictation
tool beside it. The readings are already open, so a character that sounds
exactly like the right one — 真 for 针, both zhen1 — is an input-method
slip rather than a listening mistake, and the score written down is the
listening one, so it does not keep the sentence in the drill. A character
with the right syllable and the wrong tone is marked apart from one that
was simply not heard; tones are what an ear loses first.

Every shortcut is a ⌘ chord. WorkAudioBook puts these on bare Space,
arrows and R, which it can because its writing box is a separate panel
that must be focused deliberately. Here the field holds focus throughout
and the learner is typing through a Chinese input method, where a bare
Space commits a candidate and a bare arrow moves between them.

A sheet rather than a mode of the reader: practice hides the text, which
is the opposite of what the reader is for, and sharing one surface would
mean every change to either reasoning about the other.

Found on the running app and fixed here: under the replay filter, passing
a phrase dropped it from the list the instant it passed, so moving on
counted from the top of what was left and skipped the phrase that came
next. The phrase being worked on now stays in the list until the reader
moves off it.

Verified against 听力64 and its narration: 32 phrases aligned at full
confidence, phrase 6 landing on 「卖主是位六十多岁的老太太,她叫我给个价,
我就说五角钱一个。」; typed with a deliberate 事 for 是 and 朵 for 多, the
screen reports 24 of 26 correct, one homophone, one tone, 96% heard; a
grade persists, the filter holds, and a drill of two repeats with a
two-second gap plays twice and stops.

M11 is not ticked yet. The phrase-boundary editor the plan puts in this
stage is not here, and until it is, a Mac too old for SpeechTranscriber
has no way to place boundaries by hand — so that screen now says so
rather than promising it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add sentence-by-sentence listening and dictation practice

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add a reader-launched practice sheet with phrase playback, hidden transcripts, grading, and
 needs-work drills.
• Mark homophones, tone slips, and mishearings separately; score homophones as correctly heard.
• Test practice state and scoring, including a fix for skipped phrases after passing.
Diagram

graph TD
  Reader["Reader screen"] --> Sheet["Practice sheet"] --> Model["Practice model"] --> Aligner["Speech aligner"] --> Library[("Text library")]
  Model --> Dictionary["Dictionary service"]
  Model --> Playback["Phrase playback"]
  Model --> Feedback["Marked feedback"]
  Model --> Library
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Embed practice as a reader mode
  • ➕ Avoids a separate sheet and keeps navigation on one surface.
  • ➖ Couples the reader to transcript-hiding rules and practice state.
  • ➖ Makes ownership of the shared playback transport less clear.

Recommendation: Keep the separate, document-scoped sheet: hiding the answer is fundamental to dictation but contrary to the reader’s normal behavior. The remaining architectural gap is manual phrase-boundary editing; this PR correctly describes that limitation rather than presenting automatic alignment as universally available.

Files changed (13) +1571 / -1

Enhancement (8) +1114 / -0
DesignTokens.swiftAdd practice status and dictation outcome colours +29/-0

Add practice status and dictation outcome colours

• Defines progress-strip colours and distinct text and fill colours for character-level marking outcomes.

Sources/HanReaderUI/Design/DesignTokens.swift

PracticeModel.swiftCoordinate a document-scoped practice session +321/-0

Coordinate a document-scoped practice session

• Manages audio and alignment phases, phrase navigation, checking, listening-score persistence, grades, and playback. Keeps a newly passed phrase visible until navigation moves away, preventing the needs-work filter from skipping the next phrase.

Sources/HanReaderUI/Models/PracticeModel.swift

DictationMarkedView.swiftRender character-level dictation feedback +221/-0

Render character-level dictation feedback

• Displays typed-versus-expected characters, outcome colours, accessible descriptions, counts, and the listening score in a wrapping layout.

Sources/HanReaderUI/Screens/DictationMarkedView.swift

PracticeComponents.swiftAdd phrase progress and practice controls +189/-0

Add phrase progress and practice controls

• Provides the progress strip, phrase transport, speed and repeat controls, grading buttons, and keyboard shortcuts compatible with Chinese input methods.

Sources/HanReaderUI/Screens/PracticeComponents.swift

PracticeScreen.swiftBuild the listening and dictation screen +231/-0

Build the listening and dictation screen

• Presents alignment setup and obstacles or the active practice workflow. Keeps the transcript hidden until checking or giving up, then shows feedback or the answer.

Sources/HanReaderUI/Screens/PracticeScreen.swift

PracticeSheet.swiftOwn practice as a reader sheet +65/-0

Own practice as a reader sheet

• Creates and loads a document-scoped practice model in a separate sheet, then clears phrase playback and pauses the shared transport when the sheet closes.

Sources/HanReaderUI/Screens/PracticeSheet.swift

ReaderScreen.swiftLaunch practice from texts with narration +23/-0

Launch practice from texts with narration

• Adds a headphones toolbar action that opens the practice sheet and is disabled when no audio is attached.

Sources/HanReaderUI/Screens/ReaderScreen.swift

DictionaryService.swiftBatch and cache character readings for marking +35/-0

Batch and cache character readings for marking

• Adds cached single-character readings and a deduplicating batch API so checking an attempt needs one dictionary-actor hop.

Sources/HanReaderUI/Services/DictionaryService.swift

Tests (3) +430 / -0
PracticeCheckingTests.swiftTest reveal, scoring, persistence, and grading +124/-0

Test reveal, scoring, persistence, and grading

• Verifies transcript visibility and attempt recording, including a homophone fixture that distinguishes listening accuracy from strict accuracy.

Tests/HanReaderUITests/PracticeCheckingTests.swift

PracticeModelTests.swiftTest practice navigation and alignment states +164/-0

Test practice navigation and alignment states

• Covers resume position, needs-work filtering, navigation bounds, unavailable states, and the regression where passing a phrase skipped its successor.

Tests/HanReaderUITests/PracticeModelTests.swift

PracticeTestSupport.swiftProvide isolated practice test fixtures +142/-0

Provide isolated practice test fixtures

• Builds an in-memory library, silent playback dependencies, sample phrases, and a dictionary that recognizes a known homophone pair.

Tests/HanReaderUITests/PracticeTestSupport.swift

Documentation (2) +27 / -1
CHANGELOG.mdDocument listening practice for the next release +19/-0

Document listening practice for the next release

• Adds release notes for on-device phrase alignment, dictation feedback, and needs-work drilling.

CHANGELOG.md

README.mdDescribe dictation and its remaining milestone work +8/-1

Describe dictation and its remaining milestone work

• Adds listening practice to the feature list, removes the outdated audio milestone qualifier, and notes that M11 still lacks a phrase-boundary editor.

README.md

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Changing phrases keeps the old audio playing ✓ Resolved
Description
select() clears the playback segment without pausing the transport. When the learner moves to
another phrase during playback, clearing the segment removes its stop time, so the old recording
continues past that phrase while the new one is displayed.
Code

Sources/HanReaderUI/Models/PracticeModel.swift[165]

+        playback.clearSegment()
Evidence
Navigation calls select(), while clearSegment() cancels the drill and removes the engine stop
time but does not pause the engine.

Sources/HanReaderUI/Models/PracticeModel.swift[159-179]
Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[25-43]
Sources/HanReaderUI/Screens/PracticeComponents.swift[18-21]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Navigating during phrase playback removes its boundary without stopping the sound.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel.swift[159-166]
## Recommended Fix
Pause the shared playback transport before clearing its segment when selection changes. Verify both direct strip selection and next/previous navigation during playback.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Opening practice loses the narration position ✓ Resolved
Description
PracticeModel.load() reloads the reader’s shared PlaybackModel without a start position,
stopping playback and resetting its playhead to zero, and closing practice does not restore the
previous position. When phrase playback stops, the still-present reader’s isPlaying observer can
save the practice position as the text’s resume point, so narration no longer resumes where the
reader left off.
Code

Sources/HanReaderUI/Models/PracticeModel.swift[R93-98]

+            let file = AudioAttachment.file(for: track, in: services.locations)
+            guard FileManager.default.fileExists(atPath: file.path) else {
+                phase = .noAudio
+                return
+            }
+            playback.load(file)
Evidence
PlaybackModel.load stops playback and sets playhead = max(0, time ?? 0), while ReaderScreen
had loaded the same file with startingAt: model.storedAudioTime. The reader saves
playback.playhead when isPlaying becomes false, and finishSegmentPass sets isPlaying = false
after each phrase pass; the sheet only clears and pauses on dismissal, without restoring the earlier
playhead.

Sources/HanReaderPlayback/PlaybackModel.swift[204-226]
Sources/HanReaderUI/Screens/ReaderScreen.swift[123-148]
Sources/HanReaderUI/Screens/ReaderScreen.swift[370-392]
Sources/HanReaderPlayback/PlaybackModel+Phrase.swift[54-58]
Sources/HanReaderUI/Models/PracticeModel.swift[89-101]
Sources/HanReaderPlayback/PlaybackModel.swift[199-218]
Sources/HanReaderUI/Screens/PracticeSheet.swift[48-62]
Sources/HanReaderUI/Screens/ReaderScreen.swift[103-136]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Practice reloads the reader’s shared playback transport, resets its playhead, and can cause phrase positions to be saved as the reader’s resume point. Ending practice does not return the transport to its previous position.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel.swift[89-101]
- Sources/HanReaderUI/Screens/PracticeSheet.swift[48-63]
- Sources/HanReaderUI/Screens/ReaderScreen.swift[123-148]
## Recommended Fix
Capture `playback.playhead` before practice changes the transport, avoid reloading the file if it is already loaded, and seek back to the captured position when practice closes. Prevent `ReaderScreen` from saving practice-driven transport changes as the reader’s resume position, for example with an `isPractising` guard in its `isPlaying` and `observedTime` handlers.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Stopped alignment can still save phrases 🐞 Bug ☼ Reliability
Description
runAlignment() checks cancellation after listening but not after the suspending savePhrases
call. If Stop is pressed after that check while the database write is running, the write can commit
and the task can reload phrases and set .ready despite the cancellation.
Code

Sources/HanReaderUI/Models/PracticeModel.swift[R288-292]

+            try await services.library.savePhrases(placed, for: textID)
+            phrases = try await services.library.phrases(for: textID)
+            attempts = try await services.library.attempts(for: textID)
+            current = firstUnfinished
+            phase = .ready
Evidence
The sole cancellation check precedes alignment and persistence; savePhrases performs its
replacement transaction without a cancellation check.

Sources/HanReaderUI/Models/PracticeModel.swift[269-294]
Sources/HanReaderPersistence/LibraryRepository+Listening.swift[32-79]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Stopping alignment during persistence does not stop its saved result or subsequent ready-state update.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel.swift[277-294]
- Sources/HanReaderPersistence/LibraryRepository+Listening.swift[32-79]
## Recommended Fix
Check cancellation immediately before committing phrases and before publishing the result. If Stop must guarantee no saved alignment, make the persistence transaction cancellation-aware before it commits.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. The practice sheet overflows phone screens ✓ Resolved
Description
PracticeSheet gives its entire navigation view an unconditional minimum width of 620 points. On
the supported iOS target, a phone narrower than that cannot fit the sheet's content, leaving
practice controls horizontally clipped or out of view.
Code

Sources/HanReaderUI/Screens/PracticeSheet.swift[R42-45]

+        .frame(
+            minWidth: 620,
+            idealWidth: 760,
+            minHeight: 520,
Evidence
The width constraint is unconditional, while the package explicitly supports iOS and the reader
presents this sheet there.

Sources/HanReaderUI/Screens/PracticeSheet.swift[39-47]
Package.swift[29-34]
Sources/HanReaderUI/Screens/ReaderScreen.swift[176-184]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The fixed sheet minimum is wider than a supported phone display.
## Fix Focus Areas
- Sources/HanReaderUI/Screens/PracticeSheet.swift[39-47]
## Recommended Fix
Apply the wide minimum only where the available presentation width supports it, and allow the practice content and controls to fit a compact iOS sheet.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. An empty submission records a failed attempt ✓ Resolved
Description
The writing field's .onSubmit calls model.check() without the nonempty guard applied to the
Check button. Pressing Return with an empty field therefore reveals and records a zero-score
attempt, although the visible Check action is disabled.
Code

Sources/HanReaderUI/Screens/PracticeScreen.swift[R88-92]

+                .onSubmit { Task { await model.check() } }
+            HStack(spacing: 12) {
+                Button("Check") { Task { await model.check() } }
+                    .keyboardShortcut(.return, modifiers: .command)
+                    .disabled(model.typed.isEmpty)
Evidence
The field submits unconditionally, while only the button is disabled for empty input; check()
proceeds to comparison, revelation, and persistence without checking the input.

Sources/HanReaderUI/Screens/PracticeScreen.swift[83-95]
Sources/HanReaderUI/Models/PracticeModel.swift[193-215]
Sources/HanReaderCore/Listening/DictationComparison.swift[154-177]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Return bypasses the Check button's empty-answer guard and persists an unintended attempt.
## Fix Focus Areas
- Sources/HanReaderUI/Screens/PracticeScreen.swift[83-92]
- Sources/HanReaderUI/Models/PracticeModel.swift[193-215]
## Recommended Fix
Guard empty answers inside `check()` so every invocation path obeys the same rule; keep revealing without an attempt as the separate Show the answer action.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (3)
6. Showing the answer first still records a pass ✓ Resolved
Description
reveal() sets isRevealed = true, but check() does not look at that flag. The Check button
stays enabled, and the revealed transcript has .textSelection(.enabled). A learner who shows the
answer and then types or pastes it gets a 100% listening score saved through recordAttempt, so the
phrase drops out of the needs-work drill even though it was never heard.
Code

Sources/HanReaderUI/Models/PracticeModel.swift[R224-226]

+    func reveal() {
+        isRevealed = true
+    }
Evidence
check() always records outcome.listeningAccuracy. The Check button is only disabled when typed
is empty. The revealed text can be selected and copied.

Sources/HanReaderUI/Models/PracticeModel.swift[193-220]
Sources/HanReaderUI/Screens/PracticeScreen.swift[89-111]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
After 'Show the answer', checking still saves a passing score, so giving up can clear a phrase from the drill.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel.swift[193-226]
- Sources/HanReaderUI/Screens/PracticeScreen.swift[89-93]
## Recommended Fix
In `reveal()`, save an attempt with accuracy 0 (or a flag that marks it as given up). Alternatively, have `check()` skip saving, or save a failing score, when `isRevealed` was already true before the check. Disable Check after a reveal.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. A cancelled alignment can leave a run you can't stop ✓ Resolved
Description
cancelAlignment() clears alignTask before the cancelled task exits, while that task’s completion
and progress callbacks can still change the shared handle, phase, and progress without checking
which run they belong to. If the learner starts another alignment before the old task finishes, the
old run can clear the new task’s handle or overwrite its state, leaving Stop unable to cancel the
new run and allowing another recognition pass to start while it is still running.
Code

Sources/HanReaderUI/Models/PracticeModel.swift[R244-253]

+        alignTask = Task { [weak self] in
+            await self?.runAlignment(installingModel: installingModel)
+            self?.alignTask = nil
+        }
+    }
+
+    func cancelAlignment() {
+        alignTask?.cancel()
+        alignTask = nil
+        phase = phrases.isEmpty ? .needsAlignment : .ready
Evidence
The task closure clears alignTask without checking whether it still owns the handle, and its
progress callbacks also write model-wide state without checking run identity. Because
cancelAlignment() clears the handle and resets the phase immediately, the learner can start a
replacement alignment while the cancelled listen call is still running, allowing the old task’s
later writes to affect the replacement.

Sources/HanReaderUI/Models/PracticeModel.swift[242-300]
Sources/HanReaderUI/Screens/PracticeScreen.swift[135-151]
Sources/HanReaderUI/Models/PracticeModel.swift[242-254]
Sources/HanReaderUI/Models/PracticeModel.swift[269-275]
Sources/HanReaderUI/Models/PracticeModel.swift[293-305]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A cancelled alignment task can mutate a replacement run’s handle, phase, or progress after the learner starts another alignment.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel.swift[242-305]
## Recommended Fix
Give each alignment run a token, such as a UUID or generation counter. Guard completion, cancellation, and progress updates so they apply only to the active run; clear `alignTask` only when it still holds that run’s handle. Have `runAlignment` check cancellation or the run token before each state write.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Moving on during a check can score the wrong phrase 🐞 Bug ≡ Correctness
Description
check() captures phrase before awaiting services.dictionary.readings but reads the computed
text and mutable typed after the await. If the learner edits the answer or selects another
phrase while the lookup is in flight, it can compare changed values, save the score under the
original phrase.index, and show a result or revealed answer for the newly selected phrase.
Code

Sources/HanReaderUI/Models/PracticeModel.swift[R199-208]

+        let readings = await services.dictionary.readings(
+            forCharacters: Array(text) + Array(typed),
+        )
+        let outcome = DictationComparison.check(
+            typed: typed,
+            against: text,
+            readings: { readings[$0] ?? [] },
+        )
+        result = outcome
+        isRevealed = true
Evidence
text is computed from current, and select() changes typed; both values are read after the
await at line 199, while phrase.index was captured beforehand. The UI starts checks in
asynchronous tasks and leaves phrase selection available during the suspension, and persistence
accepts the answer and index independently.

Sources/HanReaderUI/Models/PracticeModel.swift[151-166]
Sources/HanReaderUI/Models/PracticeModel.swift[193-220]
Sources/HanReaderUI/Models/PracticeModel.swift[193-216]
Sources/HanReaderUI/Screens/PracticeScreen.swift[83-92]
Sources/HanReaderUI/Screens/PracticeComponents.swift[18-21]
Sources/HanReaderPersistence/LibraryRepository+Listening.swift[175-210]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`check()` can compare values changed during a dictionary lookup, save the attempt under the original phrase index, and display a result for a newly selected phrase.
## Fix Focus Areas
- Sources/HanReaderUI/Models/PracticeModel.swift[193-220]
## Recommended Fix
Snapshot the phrase index, expected text, and typed answer before the first await. Use only those snapshots for comparison and persistence. After the await, update `result` and `isRevealed` only if the current phrase and typed answer still match the snapshots; otherwise, do not show the attempt as the current phrase’s result.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

9. ⌘→ is bound to two buttons at once ✓ Resolved
Description
GradeRow's 'Next phrase' button registers .keyboardShortcut(.rightArrow, modifiers: .command),
the same chord as the transport's next button in PhraseTransport. Both are on screen whenever a
result is showing. Which button handles the key is then left to SwiftUI, and a later change to
either button's action will silently affect only one of them.
Code

Sources/HanReaderUI/Screens/PracticeComponents.swift[R176-178]

+            Button("Next phrase") { model.advance(by: 1) }
+                .keyboardShortcut(.rightArrow, modifiers: .command)
+                .font(.caption)
Evidence
Both buttons register ⌘→ and both are inside the same practice VStack when model.result is
non-nil.

Sources/HanReaderUI/Screens/PracticeComponents.swift[80-87]
Sources/HanReaderUI/Screens/PracticeScreen.swift[55-72]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Two visible buttons share the ⌘→ shortcut.
## Fix Focus Areas
- Sources/HanReaderUI/Screens/PracticeComponents.swift[176-178]
## Recommended Fix
Delete `.keyboardShortcut(.rightArrow, modifiers: .command)` from GradeRow's 'Next phrase' button and leave the transport's button as the only one with the chord.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can open the rule or relationship behind a finding in one click

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread Sources/HanReaderUI/Models/PracticeModel.swift
Comment thread Sources/HanReaderUI/Models/PracticeModel.swift
Comment thread Sources/HanReaderUI/Screens/PracticeSheet.swift Outdated
Comment thread Sources/HanReaderUI/Screens/PracticeScreen.swift Outdated
Comment thread Sources/HanReaderUI/Models/PracticeModel.swift
Comment thread Sources/HanReaderUI/Models/PracticeModel.swift
Comment thread Sources/HanReaderUI/Models/PracticeModel.swift
Comment thread Sources/HanReaderUI/Models/PracticeModel.swift
Comment thread Sources/HanReaderUI/Screens/PracticeComponents.swift
CI only. Nested inside `nonisolated enum PracticeTestSupport`, the spy
classes took this target's `MainActor` default isolation for their members
while their implicit initialiser stayed nonisolated. Xcode 26.6 rejects
that outright — "default initializer cannot be both nonisolated and main
actor-isolated" — and Swift 6.4 locally accepts it, so it was green here
and red there.

Moved to file scope and marked `@MainActor`, which is what `SpyEngine` in
the playback tests already does and what the only caller, an already
MainActor-isolated helper, wants anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nine findings, all real.

**The two that change behaviour a learner would notice.**

Showing the answer and then typing it recorded a pass. `reveal()` set the
flag and `check()` never looked at it, so a reader who gave up, read the
transcript and copied it scored 100% and the phrase left the drill. Checking
after looking now marks the attempt — that is what was asked for — and
records nothing.

Moving to another phrase cleared the segment without stopping the sound.
Clearing removes the stop time, so the transport sailed past the phrase it
was playing and on into the next one while the screen showed a different
phrase entirely.

**Two races, both real.**

`check()` read `text` and `typed` after awaiting the dictionary, so a reader
who kept typing or moved on mid-lookup could have the new answer marked
against the old phrase and saved under its index. Both are read before the
await now, and the result is dropped if the phrase changed underneath.

A cancelled alignment kept running to its next suspension point and its
callbacks still wrote to shared state, so a stopped run finishing late could
clear the handle of the run that replaced it — leaving a second alignment
going that nothing could stop. Runs now carry a generation and only the
current one may write. The same check after `savePhrases` stops a run that
was cancelled during the write from announcing itself ready.

**And four smaller ones.**

Return in an empty writing field checked anyway, revealing the answer and
writing down a zero through a control the screen was showing as disabled.
Opening practice reset the reader's position in the narration to zero and
never put it back. The sheet's 620-point minimum width does not fit a phone,
so it now applies only where there is room. `GradeRow` bound ⌘→ to a second
button already carrying it, leaving SwiftUI to choose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@widgetii
widgetii merged commit 11f439e into main Oct 20, 2026
8 checks passed
@widgetii
widgetii deleted the feat/m11-practice-screen branch October 20, 2026 10:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant