Skip to content

Haptics in the study loop, on both platforms - #233

Merged
jvsena42 merged 4 commits into
mainfrom
feat/study-haptics
Sep 4, 2026
Merged

Haptics in the study loop, on both platforms#233
jvsena42 merged 4 commits into
mainfrom
feat/study-haptics

Conversation

@jvsena42

@jvsena42 jvsena42 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

The study loop had no haptics at all. It now buzzes where the loop actually turns — and, just as importantly, does not buzz where a tap changed nothing.

Where it fires

Moment Pattern
Flip a card, grade one, give up, open the microphone Tick
A correct typed Check, a correct spoken attempt, the daily goal, the end of a session Success
A missed Check, a mispronounced word Warning
A listen that produced no answer Failure

The one design decision worth reviewing

The buzz is decided in StudySessionViewModel, never fired on tap by a screen. Whether a tap did anything is known there and nowhere else: a grade arriving while the previous one is still writing, a Check on an untypable card and a second reveal are all ignored, and buzzing for one of those tells the reader something happened when nothing did. So it rides the ordinary effect flow as StudySessionEffect.Haptic(StudyHaptic), and the platforms only map it.

Three consequences:

  • Four patterns, because that is what both platforms can distinguish. Warning and Failure are .warning and .error on iOS; Android has one "that did not work" pattern and both land on Reject.
  • The last card's grade does not tick. It and the completion's Success would land a few milliseconds apart and read as one smeared buzz rather than two events.
  • Haptics are tryEmited, not emitted from a launched coroutine — one that has to queue for buffer space is better dropped than fired late against the next card. (Which is why the new tests runCurrent() before the first tap.)

Verified

An emulator has no actuator, so "did it buzz" was read back from adb shell dumpsys vibrator_manager, which names the calling package, the constant and the pattern the framework played. Journeys 03 and 09 re-run on emulator-5554; full table in journeys/RESULTS.md.

  • Flip → constant=6 (ContextClick) → Prebaked=TICK, attributed to com.github.jvsena42.loopky
  • Tapping the already-flipped card again → no second entry
  • Grading "Spanish Nouns" through to the end → 15 ticks and then constant=16 (Confirm), not 16 ticks
  • Typing a wrong answer and checking it → constant=17 (Reject)

Plus :shared:jvmTest (a new StudySessionHapticsTest), detektAll and :composeApp:assembleDebug.

iOS is written but unrun. There is no macOS on this machine — no xcodebuildmcp, no simulator — so Haptics.swift has not been compiled, let alone felt. It owes a run, and RESULTS.md says so.

🤖 Generated with Claude Code

https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye

jvsena42 and others added 4 commits September 4, 2026 12:12
A `StudySessionEffect.Haptic(StudyHaptic)` on the ordinary effect flow, emitted
where the loop actually turns: the flip, a grade, a checked or given-up answer,
the microphone opening, a spoken attempt, a failed listen, the daily goal and the
end of the session.

Emitted here rather than fired on tap by the screens because whether a tap *did*
anything is known here and nowhere else — a grade arriving while the previous one
is still writing, a Check on an untypable card and a second reveal are all
ignored, and buzzing for one of those tells the reader something happened when
nothing did.

Four patterns, sized to what both platforms can distinguish. The last card's
grade deliberately does not tick: it and the completion's Success would land a
few milliseconds apart and read as one smeared buzz rather than two events.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
Maps `StudyHaptic` onto `HapticFeedbackType` and performs it from the route's
effect collector. Android offers one "that did not work" pattern, so Warning and
Failure both land on Reject; the distinction between them is iOS's to make.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
The same effect through UIKit's generators — a light impact for a tick, and the
notification generator's success/warning/error for the other three. The Taptic
Engine is warmed on appear so the session's first buzz lands with the tap.

Compared rather than pattern-matched: a Kotlin enum crosses as an object with
class properties, not as a Swift enum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
Adds the "decided in the ViewModel, never on tap" rule to CLAUDE.md, and the
`dumpsys vibrator_manager` steps that are the only way to assert on a buzz from
an emulator — including the one that catches a regression: tapping an
already-flipped card must produce no entry at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
@jvsena42
jvsena42 enabled auto-merge September 4, 2026 15:14
@jvsena42
jvsena42 merged commit 09a1a27 into main Sep 4, 2026
4 checks passed
@jvsena42
jvsena42 deleted the feat/study-haptics branch September 4, 2026 15:16
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