Compare commits

..

19 Commits

Author SHA1 Message Date
thejayman77 f8aa27e6cb Doc cleanup: handoff + load-listener comment match shipped bullseye audio
Codex re-audit of 0cb315f was functional-green (81 tests, clean build,
device-green) with two non-runtime doc findings:
- SOUND_DESIGN_HANDOFF.md still described the retired "Level on lock,
  once, then silence" behavior. Now documents immediate bullseye
  alignment (enter <=0.2 / exit >=0.35 spatial hysteresis, no dwell) and
  the looping level.wav, with the velocity lock kept separate for
  lime/label/haptic.
- The SoundPool load listener's comment still said "one-shot Level";
  updated to describe the pending-loop-start path.

No code behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:47:23 -04:00
thejayman77 0cb315fdcc Audio: instant bullseye cue on alignment, decoupled from the held lock
Three-way consensus (Jay/Codex/Claude): one sound, split timing not identity.
The bullseye sound now means 'centered right now' and fires the instant you're
aligned - even a fast pass over center - so the zone that needs the most help
locating always announces itself. It no longer waits on the velocity+dwell
lock, which was the delay near center.

- SurfaceTickPolicy: outside the center zone, proximity ticks (unchanged
  anchor scheduler). Inside, emit ALIGNED every frame -> caller loops level.wav.
  Entry is immediate (no dwell, no velocity gate). Alignment uses SPATIAL
  hysteresis (enter <=0.2, rearm only after leaving >=0.35) - not a time
  debounce, so a legitimate quick re-crossing still announces. Ticks resume
  immediately on exit.
- Alignment error = hypot(stable pitch, stable roll) - the SAME axes that drive
  the bubble, so the sound can't lag the visual (Codex's implementation note).
- Player: level.wav loops while aligned (ensureLevelLooping, idempotent),
  stopped before every restart and on exit; pending-load queue retained.
- The held-level confirmation (persistent lime, label, haptic) stays on the
  velocity-aware LockDetector; the locating sound is fully decoupled from it.

Tests: immediate cue on fast crossing, one start / ticks-suppressed while
inside, stop+resume-ticks on exit, spatial-hysteresis rearm, tick reactivity.
81 tests passing; assembleDebug clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:18:04 -04:00
thejayman77 604d61ebb2 Voice init-race fix + velocity-aware Surface lock
Voice (final P1 from audit): the policy is no longer consumed until TTS is
ready, so a phrase can't be queued and then spoken stale after init. When
ready, the policy evaluates the CURRENT reading (silent if moving, correct
instruction if settled). VoiceSpeaker drops its pending-text queue and just
exposes isReady; the policy is remember(speaker) so each VOICE session starts
fresh. Covers all of Codex's cancellation cases (movement, unlock, invalid
placement, foreground loss, mode change, shutdown).

Velocity-aware Surface lock (Jay's observation + Codex): the fixed 400 ms
dwell made a slowly-arriving bubble wait, delaying the level sound, while a
fast crossing could still lock. LockDetector now takes a movement rate:
- error <=0.2 deg AND rate <=0.3 deg/s -> lock after a short 175 ms confirm;
- faster than that -> dwell never accumulates (a center fly-through never
  locks); slowing to a stop inside the zone starts a fresh confirmation;
- 0.35 deg exit hysteresis and the single shared lock (sound/lime/haptic)
  unchanged.
LevelPipeline feeds SettlingDetector.movementRateDegreesPerSecond (was
discarded); Edge passes null -> classic fixed 400 ms dwell. Simple gated
machine, no adaptive-dwell formula.

Tests: slow-entry-locks-promptly, fast-traversal-never-locks, intermediate-
speed-no-accumulate, slowing-inside-zone-fresh-dwell, exit hysteresis,
feedback debounce, Edge fixed-dwell fallback. 82 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 10:27:47 -04:00
thejayman77 3ebb36f265 Address Codex audit of ticking (36c0cfa): dynamic + init-race fixes
P1 - Tick cadence now reacts immediately to changing tilt. The due time is
recomputed every update from the last-tick anchor plus the CURRENT interval,
so dropping from 5deg to 0.4deg accelerates to the fast cadence at once
instead of waiting out the stale slow interval. Anchor advances by whole
intervals (no drift) and resyncs to now if a full interval behind (no burst).
Added far->near and near->far transition tests.

P1 - One-shot cues no longer lost to async loading. SonarSoundPool queues a
Level asked for before its sample loads and plays it on load-complete (and
cancels the queued Level on unlock/gate). VoiceSpeaker queues the latest
phrase requested before TTS finishes init and speaks it on ready. The policy
fires these once, so they can't rely on retries.

P2 - Unlock stops the level.wav tail BEFORE playing the resumed tick (order
was reversed, allowing a brief overlap).

P2 - Audio-mode preference write moved from the screen's rememberCoroutineScope
to container.applicationScope, so a quick navigation can't cancel it (matches
the earlier persistence hardening).

Cleanup: AudioAssistMode and LevelPipeline comments now describe ticking, not
the retired radar pings/homing bands; AUDIO_DESIGN_LOG cadence principle
reconciled with the 125 ms continuous-tick near rate.

78 tests passing; assembleDebug clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 10:01:52 -04:00
thejayman77 36c0cfa5ab Audio: Surface proximity ticking + Voice settled-change; stage Edge staircase
Replaces the hands-free audio assist after extensive on-device iteration
(full journey + rejected approaches in AUDIO_DESIGN_LOG.md; current design
in SOUND_DESIGN_HANDOFF.md, from Jay's sound-design sessions).

Surface (TONES) = continuous proximity ticking: tick.wav repeats faster as
you near level (axis-agnostic warmer/colder, the rate is the message).
- Exponential rate in stable tilt error, clamped 1.5/s (>=~5 deg) to 8/s
  (<=~0.4 deg), hard-capped; constant loudness (acceleration is the signal).
- Deadline scheduler off a monotonic clock (SystemClock.elapsedRealtime):
  advances a next-tick deadline, so no drift and no catch-up bursts; <=1 tick
  per sensor update; immediate tick on re-entry (enable/return FACE_UP/unlock).
- Lock reuses LockDetector.isLocked (dwell + hysteresis, no second audio
  threshold): stop ticking, play level.wav once, silence while held. The
  1.39s level stream is retained and stopped on unlock/disable so a fresh
  tick can't overlap its tail.
- Pure SurfaceTickPolicy (unit-tested: anchors/clamps, monotonic rates,
  gating, immediate re-entry, no catch-up burst, level-once, resume on unlock).

Voice (VOICE) = settled-change announcer: one correction on the dominant
axis, silent while moving, speaks again on settle only if direction changed,
crossed coarse->fine, or reached lock. No periodic repeat. (VoiceGuidancePolicy,
unit-tested.)

Assets: tick.wav + level.wav downsampled to mono 44.1kHz in res/raw (from
Jay's SoundQ-derived 96k masters). AudioAssistMode adds OFF/TONES/VOICE.

Retired the settle-gated two-ding packet scheduler (AudioAssistStateMachine +
its ding assets) - superseded by ticking for Surface; logged as tested/rejected.

Edge staircase (1-D, future): 5 pitch-contour masters staged in
sonar-staircase-v3/ for when Edge mode is built.

76 tests passing; assembleDebug clean; blessed on-device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 09:43:18 -04:00
thejayman77 e5d15c920f Serialize app-scope writes via Main.immediate (Codex hardening note)
Non-blocking follow-up from the audit: applicationScope ran on
Dispatchers.Default, so two very fast conflicting persistence writes
(recalibrate then immediately Reset) had no ordering guarantee. Main.immediate
launches the coroutine body synchronously in call order, so the DataStore
edits enqueue deterministically; DataStore still does the IO on its own
dispatcher. Only lightweight persistence uses this scope.

64 tests passing; assembleDebug clean; runs on device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:21:57 -04:00
thejayman77 daf19c34b3 Address Codex audit: Tools cards, 4-point validation, durable persistence
P1 - Tools screen no longer exposes dead 'coming with the feature build'
cards. Replaced the stale Calibration and Units/Feedback cards with a
single working Settings card that opens the Settings screen.

P1 - Detailed (4-point) calibration now validates rotation quality, not
just the final mean. A 4-point set holds two independent 180-flip bias
estimates - the (0,180) and (90,270) pairs; a malformed turn makes them
diverge even when the mean stays small (which the 3-degree magnitude
guard can't see). isRotationConsistent rejects divergence beyond 0.4
degrees. The disagreement scales with the surface's true tilt, so it's
lenient near level and strict on a steeper permitted surface. Test
proves a deliberately 10-degree-over-rotated set is rejected and a clean
set passes.

P2 - Calibration persistence moved from the screen's rememberCoroutineScope
to a container-owned applicationScope, so the write survives an immediate
Done/back after 'saved'. Preference writes moved there too.

Cleanup: haptic copy is mode-neutral ('when the level locks'); Settings
no longer keeps the screen awake (calibration still does); calibration
method renamed Thoroughness -> Method with Standard/Detailed labels and
accurate 'Four positions, a quarter-turn apart' / 'Averages more sensor
noise' copy; lime now marks the active-calibration status (confirmed
state), consistent with lime = level/confirmed.

64 tests passing; assembleDebug clean; verified on device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:15:29 -04:00
thejayman77 85a8ce1136 Calibration UX overhaul + Settings screen (Claude, driving)
Fixes a real defect and reworks the calibration/settings flow per Jay's
on-device review.

Bug: the second capture hung on 'capturing'. The completion code wrote
capture=null (a LaunchedEffect key) then awaited the suspend
setSurfaceCalibration BEFORE setting phase=COMPLETE; at the suspension
Compose cancelled the effect mid-write, stranding the transition. The
effect body is now fully synchronous and persistence runs on an
independent scope.launch. Verified end-to-end on device.

Settings screen (new): reached from the Level header gear (was: gear
jumped straight into calibration). Calibration is a sleek button in its
own section; Units, Haptics, and Reduce-motion prefs get a home; Done is
pinned to the bottom while the list scrolls independently.

Calibration screen: two stages. OVERVIEW shows the current correction
(Pitch/Roll offsets), the Thoroughness selector, and Calibrate / Reset
to phone defaults / Done. CAPTURING shows ONLY capture + Cancel; Cancel
restores the prior calibration (saved value is never touched mid-flow).
Instructions centered; outlined buttons given visible borders.

Thoroughness: Simple (2-point, 0/180) or Thorough (4-point,
0/90/180/270). Generalized deriveSurfaceFromSamples averages a symmetric
rotation set - the true tilt sums to zero, so the mean is the device
bias; 4-point also cancels each axis twice and averages more noise. Two
new unit tests (2-point equivalence, 4-point bias recovery).

61 tests passing; assembleDebug clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:37:35 -04:00
thejayman77 721fccd5ce Surface polish: motion fix, instrument redesign, readout typography (Codex + Claude)
Driven by on-device iteration on a Galaxy S24+ (live ADB install/
screenshot loop), gated by a real-device motion pass at animator
scale 1x and frame stats (p95 8ms, 0.85% legacy jank).

Motion (Codex): X/Y axes animate in independent concurrent effects.
The prior sequential animateTo calls starved Y under ~50Hz
retargeting - diagonal movement traced an L and the second axis
caught up seconds late. Reduced-motion/system-disabled paths snap
both axes.

Instrument (Claude, on Codex's base):
- Fluorescent yellow-green fluid (matches real spirit-level dye);
  amber retained for guidance text.
- Bubble as a true void: borderless symmetric gradient orb,
  translucent interior (marks read through it), soft bright
  refraction band, contact shadow, fluorescent halo, window-style
  reflection + glint. Velocity-squash experiment tried and removed -
  shape fidelity beats the flourish.
- Bubble (0.12R) nests inside the 1-degree target ring; ring turns
  lime on VISUAL containment of the drawn bubble (lock label/pulse/
  haptic remain on LockDetector truth).
- Machined bezel, etched cardinal/45 ticks, per-ring degree labels
  via the same SurfaceGuidance mapping. dp-scaled strokes replace
  raw-pixel hairlines; contrast lifted for real screens.
- Red center target dot beneath the translucent bubble (Codex).

Text hierarchy: guidance suppressed while locked (no correction
advice under a 'Flat within' verdict), demoted to titleLarge, and
raised into the empty status line's slot. Primary readout optically
dead-centered via an invisible leading degree twin; degree symbol
.55em at TextDim with cap-height baseline shift (.38 - metrics pair,
resize together).

59 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 07:34:58 -04:00
thejayman77 30417a5f98 Slice 5: Audio Level Assist + fixed guidance slot (Codex)
- AudioAssistStateMachine (core/audio, pure, tested): proximity bands
  with enter/exit hysteresis (close 1.0/1.2, near 3.0/3.4 degrees),
  cadence preserved across band changes (no threshold double-ping),
  lock cue driven solely by LockDetector's fireFeedback transition -
  no second lock threshold, and the haptic debounce paces the ping.
- Silence gates: disabled, backgrounded, settling, invalid placement,
  or outside FACE_UP all reset and mute; all five unit-tested.
- SonarSoundPool: locally synthesized WAV pings via SoundPool with
  USAGE_ASSISTANCE_SONIFICATION; zero audio-focus APIs (verified by
  grep); player exists only while enabled and resumed, released on
  composition disposal.
- Persisted SONAR ON/OFF header control using the existing
  audioCueEnabled preference; off by default; state shown as text,
  never color alone.
- Guidance slot fixed at 56dp so settling/correction/level copy no
  longer shifts the instrument (Slice 4 polish note).

59 tests passing.

Audited-by: Claude (no findings)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:43:07 -04:00
thejayman77 027b9b96b3 Slice 4: actionable adjustment guidance (Codex)
- SurfaceAdjustmentInfo: high-side instruction sourced exclusively from
  SurfaceGuidance.from(displayed pitch, displayed roll).highLabel - the
  deadbanded axes supply the label hysteresis, so wording cannot flap
  at direction boundaries.
- Shared SettlingDetector exposed through the pipeline as the sole
  motion gate; strong guidance defers behind a quiet Settling state.
- RiseRun (core/sensors, pure): tan-based mm/m and in/ft, derived from
  the same deadbanded magnitude as the numeric readout; pinned at
  45 degrees = 1000 mm/m = 12 in/ft.
- Persisted MeasurementUnits preference in core/settings (metric
  default); Tools UI control remains deferred per plan.
- Guidance renders only in face-up Surface mode, hidden at level
  (no direction below the 0.1-degree deadband).

56 tests passing.

Audited-by: Claude (no findings)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:43:42 -04:00
thejayman77 422854de07 Slice 3: tactile bullseye instrument (Codex)
- SurfaceGuidance (core/sensors, pure, tested): the sole source of
  bubble position, high-side direction, and ring scale. Bubble and
  1/2/5-degree etched rings share one 5-degree visual range; the
  clamped target puts a saturated bubble exactly at the rim ring.
- SurfaceBullseye: Compose Canvas glass vial with crosshairs and
  amber bubble. Critically damped (no-overshoot) spring toward the
  pre-clamped target; velocity carries across retargets. Reduced
  motion (in-app pref or ANIMATOR_DURATION_SCALE == 0) snaps directly.
- Lock treatment: brief lime ring pulse on acquisition, persistent
  lime tolerance label while locked, neutral numeric readout. Never
  color-only; no full-screen lime state.
- Pipeline passes stable calibrated pitch/roll to the instrument;
  deadbanded readout remains authoritative. Bullseye renders only in
  the FACE_UP presentation.

53 tests passing.

Audited-by: Claude (1 finding raised and resolved)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:30:45 -04:00
thejayman77 d3854ac402 Slice 2: guided Surface calibration flow (Codex)
- SurfaceCalibrationScreen: two-sample guided flow from the Level header.
  Captures read SensorSource.gravity directly - stored calibration is
  never applied to either sample. Exact 180-degree same-plane guidance,
  clear-saved-calibration action, Surface persistence only.
- SettlingDetector (core/sensors, shared): 1.0 deg/s enter, 0.3 deg/s
  exit held 500 ms; mid-band movement resets the quiet dwell without
  leaving Settling (audit finding 1).
- StableSurfaceCapture (core/sensors): continuous 1.5 s settled window
  with the 5-degree calibration-surface guard; restarts on motion.
- SurfaceCalibrationValidation (core/sensors): 3-degree bound on the
  combined two-axis bias magnitude, unit-tested both sides of the
  boundary (audit finding 2).
- Lock feedback suppression is structural: the calibration route removes
  LevelScreen from composition, cancelling its sensor collection.

50 tests passing.

Audited-by: Claude (2 findings raised and resolved)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 14:51:28 -04:00
thejayman77 8d942a88b4 Slice 1: honest full-range Surface readings, self-gating lock (Codex)
- Surface tilt magnitude stays visible 0-180 degrees; never blanked by
  placement. Lock is self-gating (only near-zero magnitude can enter),
  so the placement gate now drives hints only. Edge gating unchanged.
- Pitch/Roll suppressed as ambiguous at >=80 degrees; explicit
  screen-down state past 90 degrees with the true magnitude retained.
- SurfacePresentationDetector classifies from the same deadbanded
  stable magnitude the UI displays, with 1-degree hysteresis at both
  boundaries and a direct face-up -> screen-down transition.
- Tests: angle sweep (30/80/near-vertical/screen-down), hysteretic
  boundary tests, and a sustained 30-degree run proving a steep phone
  never locks or fires feedback. 43 tests passing.

Audited-by: Claude (2 findings raised and resolved)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 12:10:05 -04:00
thejayman77 0c197894ff Revise Surface plan per audit: near-vertical spec, calibration guards, shared settling, pure guidance mapping (Codex) 2026-07-13 11:52:52 -04:00
thejayman77 abff397364 Add Surface Level attack plan (Codex) and Gradle daemon JVM pin 2026-07-13 10:29:04 -04:00
thejayman77 99c2b12192 Address Codex measurement-core review
- Calibration: still derived in angle space via the 180-degree flip, but now
  APPLIED in vector space as a reference-orientation rotation (Rodrigues
  alignment for Surface, Z-rotation for Edge), exact away from zero; tests
  at 30 degrees, cross-axis, and edge-polarity cases.
- Angle relative zero: stores the gravity direction vector; relative reading
  is the angle between directions, so cross-axis movement is honest.
- Edge mode: precise geometry documented (either long edge down, gravity
  along +/-X), placement-validity guard so e.g. Edge mode never locks on a
  phone lying flat; UI shows repositioning hints.
- Lock/readout coherence: locked label states the tolerance (exit threshold)
  so the rounded readout can never contradict it; pipeline acceptance tests
  pin the invariant.
- Sensor-vector contract: documented and pinned by SensorContractTest. The
  suggested negation of gravity/accelerometer fallbacks is NOT applied: per
  Android SensorEvent docs, a stationary flat device reads +9.81 on Z (the
  gravity reaction), matching the rotation-vector path as-is. The contract
  tests prove all paths agree.

38 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:45:53 -04:00
thejayman77 2a48d79c77 Scaffold Android project per BRIEF.md
Single-module Kotlin/Compose app with manual DI container. Pure-Kotlin
measurement math (orientation mapping, two-sample per-mode calibration,
EMA smoothing, lock hysteresis, display deadband) fully unit-tested.
Sensor fallback: game rotation vector -> gravity -> low-passed
accelerometer. Live Level (manual Surface|Edge) and Angle (hold-to-zero)
scaffolds; Ruler/Tools placeholders; stub Pro entitlement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:34:57 -04:00
thejayman77 bcda2856f7 Add authoritative v1 product & technical brief (Codex) 2026-07-11 18:25:38 -04:00
81 changed files with 6142 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
# Audio Assist — design log
Hands-free audio to level something without watching the screen. Setting cycles
**OFF / TONES / VOICE** from the Level header. Plays on `USAGE_MEDIA` (so the volume slider
controls it) and never takes audio focus.
> **Source of truth for the current design is [`SOUND_DESIGN_HANDOFF.md`]**, from Jay's
> dedicated sound-design sessions. This log records the *journey* — especially rejected
> approaches — so they don't get rediscovered. Where the two ever disagree, the handoff wins.
## Chosen direction (current)
Two modes / two mechanisms, one sound identity (all derived from a 500 Hz blip):
**SURFACE (2-D) — TONES.** One vocabulary: **faster ticks = approaching, the bullseye sound =
centered right now.** Timing is split, not sound identity:
- *Outside the center zone:* `tick.wav` (60 ms) repeats faster as you near level — axis-agnostic
"warmer/colder", rate is the message. No settle gate. Rate is exponential in the error,
clamped **1.5/s (≥~5°) → 8/s (≤~0.4°)**, hard-capped. **Constant loudness** (acceleration is
the signal). Scheduling is anchored to the last tick off a **monotonic clock**, due-time
recomputed each frame from the current interval → immediate reaction to changing tilt, no
drift, no catch-up burst, ≤1 tick per update.
- *Inside the center zone:* the ticks give way to the looping `level.wav` **immediately — no
dwell, no velocity gate — even on a fast pass**, because a momentary alignment is real,
locating information (that little zone needs the most help). It stops the instant you leave.
Alignment uses **spatial hysteresis** (enter ≤0.2°, rearm only after leaving ≥0.35°) — not a
time debounce, which would hide a legitimate quick re-crossing — and is measured from the
**same hypot(stable pitch, stable roll)** that drives the bubble, so the sound can't lag the
visual. `level.wav`'s stream id is retained and stopped before every restart / on exit.
- The stronger **"held level" confirmation — persistent lime, the "Flat within 0.35°" label,
and the haptic — is decided separately** by the velocity-aware `LockDetector` (≤0.2° + ≤0.3°/s
→ 175 ms confirm). The locating *sound* never waits on it; a fast fly-through gets the bullseye
cue but not the "you nailed it" confirmation.
**EDGE (1-D, future) — the "sonar staircase."** Direction + magnitude as a 4-blip pitch
contour (rising = raise it, falling = lower it, steepness = how far; level sounds flat), one
report per move→settle. Five pre-rendered assets staged in `sonar-staircase-v3/`; wired when
Edge mode is built.
**VOICE — deliberately separate, a SETTLED-CHANGE announcer (not a timer).** Speaks one
correction, then stays silent while moving; when it settles again it speaks only if the
dominant direction changed, it crossed coarse→fine, or it reached lock ("that's level"). A
settle still needing the same coarse nudge gets silence. No periodic repeat, no bed under it.
Implementation: pure `SurfaceTickPolicy` (monotonic deadline scheduler, exponential rate,
unit-tested) + `VoiceGuidancePolicy`; the SoundPool layer stays dumb (play a tick / play &
stop level). Sounds are Jay's SoundQ-derived blips (`tick.wav`, `level.wav`) downsampled to
mono 44.1 kHz in `res/raw`.
## Rejected approaches (and why)
1. **Two fixed proximity bands + lock ping** (original). Worked but coarse; the near/close jump
was audible ("a lower ding when close").
2. **Continuous cadence** — beep interval shrinks smoothly toward center (parking-sensor).
→ "gets a bit much." Monotonous over a long adjustment.
3. **Rising-pitch discrete beeps.** Better, but still same-note-ish and busy.
4. **Continuous glide tone** — one warm tone, pitch low→treble by closeness, resolving into the
aura. → "the constant sound works but it's too much; not clicking." A continuous tone gives
the ear **no silence to rest on**, so it fatigues; it also bypassed the tested scheduler.
5. **Radar doublet on a per-band cadence** — two sonar pings ~110 ms apart, repeating on a
1.3/0.9/0.6 s cadence while off-level. The literal two-ping "pew-pew" read as UI/radar beeps,
not sonar, and the continued cadence was still a clock. Kept the bands/pitch, dropped the
cadence in favor of one packet per settle.
6. **Continuous forcefield aura bed** (looping CC0 ambience while locked). Seamless on a PC
media player but **clicked at the loop point through Android SoundPool**. Pulled; a gapless
bed needs AudioTrack/ExoPlayer.
7. **Settle-gated two-ding "status packet"** (banded pitch + gap, one packet per move→settle,
fuller arrival ding at lock). Built, unit-tested, on-device tested. Coherent, but on the
bench the settle-then-report model felt sparse/laggy for the fiddly 2-D surface adjustment —
you want live "warmer/colder" while you're actually moving it, not a report after you stop.
Superseded by continuous ticking for Surface. (The settle-then-report shape lives on for
VOICE and for future EDGE, where a discrete per-settle report fits the 1-D task.)
## Guiding principles (learned)
- Cue *character* decides tolerance more than raw rate. As **discrete report cues**, ~180 ms
spacing felt alarm-like and ~600 ms felt calm — but as a **continuous proximity tick** an
8/s (125 ms) near rate reads as pleasant acceleration, not an alarm (it's a Geiger/parking
sensor, and the ear expects it to quicken as you close in). Judge by feel on the phone.
- **Volume** is a poor information channel (phone volume + room noise make loudness
unreliable) — let rate/pitch carry the message and keep loudness constant.
- Keep the SoundPool layer dumb; put decisions in tested pure logic.
- The **centered** sound needs source loudness/spectrum presence, not just a bigger volume
number.
- No reverb/comb tails on phone speakers — they smear and lengthen fatigue.
- Reuse the real lock state for the centered handoff; never invent a second audio threshold.
+185
View File
@@ -0,0 +1,185 @@
# On the Level — v1 Product & Technical Brief
**Status:** Ready for final Claude review before implementation
**Date:** 2026-07-11
**Companion audit:** [`AUDIT.md`](AUDIT.md)
## Product decision
On the Level is an offline-first Android pocket-tool app for quick, trustworthy
level and angle work. It should feel like a responsive physical instrument—not
a novelty simulation—while keeping displayed measurements stable and honest.
v1 is deliberately small: a level, an angle meter, a calibrated screen ruler,
and the settings necessary to make them dependable. There is no account,
backend, advertising, camera/AR measurement, cloud sync, analytics dependency,
or subscription in v1.
## Audience and promise
For everyday DIY, hanging pictures, shelving, small home jobs, and quick rough
measurements. The app is not a substitute for a certified or safety-critical
instrument. Screen-ruler copy must state that it is for short, rough
measurements only.
## Navigation and layout
Use a simple three-destination bottom navigation: **Level**, **Angle**, and
**Tools**. The app is portrait-locked in v1. The UI is edge-to-edge and must
correctly handle system insets and back gestures.
### 1. Level — default destination
- Header: `LEVEL`, live/calibrated status, calibration/settings action.
- Explicit mode selector: **Surface** | **Edge**. Do not auto-switch modes in
v1; unexpected switching undermines confidence while positioning the phone.
- **Surface:** phone lying screen-up on a surface; use a two-axis cross-vial
visualization with meaningful pitch and roll readings.
- **Edge:** phone held upright on its long edge against a wall, shelf, or
picture frame; use a single, prominent vial/readout for that plane.
- The central visual is tactile: its bubble has restrained spring/damping,
settles naturally, and responds immediately to device movement.
- A locked state is a brief, accessible success reaction with a debounced
system-respecting haptic/audio cue. It is not the persistent normal UI.
- Keep the screen on while this destination is visible.
### 2. Angle
- Large primary angle readout.
- Secondary pitch, roll, and percent-grade readings.
- **Hold to zero** sets a relative reference and is always free.
- Percent grade is `tan(pitch) × 100`; define a sensible cap or infinity
presentation near a vertical surface.
- Target-angle alerts and saved named references are Pro features.
### 3. Tools
- **Screen Ruler**: a Pro tool with a clear preview and calm, intentional
paywall—not an interruption in the free level/angle flow.
- Calibration entry point.
- Units, haptic/audio, and motion/accessibility preferences.
## Visual and interaction direction
Use the concept board's deep graphite base, warm amber bubble, and lime level
lock as direction rather than a literal spec. Numeric measurements remain
near-white and must be readable in bright conditions. Never convey accuracy,
state, or purchase status by color alone.
The visual vial may be expressive, but it must never contradict the numeric
reading. Provide an in-app reduced-motion option and respect disabled system
animations. Respect device haptic settings; do not force vibration.
## Measurement modes and calibration
Only promise calibration for the two physical orientations explicitly supported
in v1:
1. **Surface** — screen-up, flat contact plane.
2. **Long Edge** — upright, long-edge contact plane.
Store a reference orientation for each supported mode—not a scalar angle offset.
The calibration flow uses two samples: place the device, record; rotate it 180°
on the same plane, record; derive the reference/bias from both samples. This
reduces fixed device bias without requiring the user to start on a known-level
surface. Calibration UI must give exact, mode-specific placement instructions.
Screen-ruler calibration is separate from sensor calibration. Offer calibration
against a standard credit-card width (85.60 mm) and a conventional ruler option.
Store scale with the active display/configuration characteristics and request
recalibration whenever a material display change makes the prior scale suspect.
## Sensor and measurement architecture
Use a small native Android/Kotlin Compose application. Keep one app module for
v1, with a manual application container rather than premature modularization or
Hilt.
```text
app/
core/design/ theme, typography, tactile animation primitives
core/sensors/ sensor source, device-axis mapping, measurement math
core/settings/ DataStore preferences and calibration persistence
core/billing/ entitlement abstraction and Play Billing implementation
feature/level/ state and UI for Surface / Edge level modes
feature/angle/ relative-zero logic and UI
feature/ruler/ ruler calibration and UI
feature/tools/ tools and settings UI
```
Require an accelerometer in the manifest. Select sensors in this order:
1. `TYPE_GAME_ROTATION_VECTOR`
2. `TYPE_GRAVITY`
3. A low-pass-filtered `TYPE_ACCELEROMETER`
The app does not need north/heading. Map device-frame sensor values explicitly
for the portrait-locked UI and for each measurement mode. Handle unavailable
sensors with a clear unsupported-device state.
Maintain three distinct values:
1. **Raw sensor value** — received from the selected sensor source.
2. **Stable calibrated measurement** — reference-orientation calibration plus
a defined EMA/low-pass filter. This is the sole input to numeric readouts
and level-lock detection.
3. **Animated display value** — a critically damped spring derived from the
stable value and used only to move the vial/bubble.
Readouts use 0.1° resolution and a display deadband to prevent flickering. Lock
behavior starts at no more than 0.2°, exits at at least 0.35°, has a short dwell
time, and has a multi-second haptic debounce. Exact constants remain tunable,
but lock and readout must always use the same stable calibrated measurement.
Register sensors only while a relevant screen is foregrounded; unregister them
when backgrounded.
## Monetization
Use one permanent, non-consumable **On the Level Pro** Google Play product. Do
not use a subscription. The free product is complete:
- Surface and Edge level modes
- Angle measurement, pitch/roll, degree/grade display
- Hold-to-zero
- Calibration and basic feedback/unit preferences
Pro adds deliberate, additive measurement tools:
- Calibrated Screen Ruler
- Target-angle haptic/audio alerts
- Saved and named angle references
Expose entitlement as `StateFlow<Entitlement>` from `core/billing`. Billing
initialization must be asynchronous/lazy: free tools render immediately and
never block on Play services. Cache a previously Play-confirmed entitlement for
offline job-site use, refresh owned purchases when possible (including resume),
and never grant from an arbitrary preference value or a pending purchase.
## Testability and release gates
Keep all math—device-axis mapping, calibration, smoothing, hysteresis, grade,
and lock transitions—in pure Kotlin. Place sensor I/O behind an injected
`SensorSource` so tests can use synthetic and recorded real-device traces.
Before release, verify:
- Surface and Edge readings across supported orientations and screen rotation
conditions.
- Two-sample calibration improves repeatability and remains isolated per mode.
- Numeric readings, vial animation, and lock feedback stay consistent.
- Haptic debounce, system haptic settings, reduced motion, and bright-light
readability.
- Sensor absence, app backgrounding, and screen wake behavior.
- Pro purchase, restore, pending purchase, offline cached entitlement, and
billing-unavailable behavior without degrading free tools.
- Ruler calibration, display/configuration changes, and honest rough-measure
wording.
## Explicitly out of scope for v1
- Camera-assisted or AR distance measurement
- Accounts, server verification, cloud storage, sharing, or collaboration
- Ads, subscription plans, consumable credits, or trial metering
- Automatic flat/edge mode switching
- Support claims for every device edge, case, or external/foldable display
+48
View File
@@ -0,0 +1,48 @@
# On the Level
Offline-first Android pocket toolset: a trustworthy level, angle meter, and
calibrated screen ruler (Pro). Kotlin + Jetpack Compose, single module, no backend.
- **[BRIEF.md](BRIEF.md)** — the authoritative v1 product & technical brief
- **[AUDIT.md](AUDIT.md)** — audit of the brief; the amendments it lists are incorporated
- **resources/** — concept board (visual direction)
## Building
Open in Android Studio, or:
```sh
./gradlew assembleDebug # build
./gradlew testDebugUnitTest # measurement-math unit tests
```
## Scaffold status
The project skeleton follows BRIEF.md §Sensor and measurement architecture.
Implemented and tested:
- `core/sensors` — sensor selection (game rotation vector → gravity → low-passed
accelerometer), device-frame gravity stream, and the pure measurement math:
orientation mapping, two-sample per-mode calibration (derived in angle space,
**applied in vector space** so it stays correct away from zero), EMA smoothing,
lock hysteresis/dwell/debounce, display deadband, placement validity, and the
sensor-vector contract (all sources emit device-frame world-up; flat = +9.81 on Z,
pinned by `SensorContractTest`). All unit-tested.
- `core/settings` — DataStore preferences and per-mode calibration persistence.
- `core/billing` — entitlement interface with a stub (free-tier) implementation.
- `feature/level`, `feature/angle` — live numeric scaffolds wired to the real
sensor pipeline, with manual Surface|Edge selection, placement-validity hints,
lock state (locked label states the tolerance so it can never contradict the
rounded readout), and directional hold-to-zero (vector reference, not magnitude
subtraction).
- `feature/ruler`, `feature/tools` — static placeholders.
Not yet implemented (deliberately — see TODO markers):
- The tactile vial visuals and the spring-animated **display value** (value 3 of 3
in the brief); scaffold screens render the deadbanded stable value directly.
- Guided calibration flow UI (math and persistence are done).
- Edge-mode readout counter-rotation on the portrait-locked activity.
- Play Billing implementation of `ProEntitlementRepository`.
- Screen ruler measurement UI and per-display scale persistence.
- Audio cue, reduced-motion behaviors, preferences UI.
+147
View File
@@ -0,0 +1,147 @@
# On The Level — Audio Feedback Handoff
Handoff from the sound-design session (2026-07-16) to the app implementation.
The design is final and approved by Jay; assets are ready to ship.
Two modes, two mechanisms, **one sound identity** — every sound derives from
the same 500 Hz blip:
- **Edge mode (1-D)**: sonar staircase, one discrete report per settle event.
- **Surface mode (2-D)**: continuous proximity ticking — tick rate rises as
you approach the bullseye; the moment you're on center, the ticks give way to
the looping `level.wav` bullseye sound. One vocabulary: faster ticks = closer,
bullseye sound = centered right now.
The staircase does NOT ship in surface mode. All earlier discussion of 2-D
dominant-axis mapping is dead — surface is axis-agnostic warmer/colder by
design, because leveling a surface shouldn't require decoding anything a
bubble level wouldn't ask of you.
## Surface mode: proximity ticking
Assets: `tick.wav` (in AudioGenerator's `outputs/surface-proximity/`) — the
same 500 Hz blip shortened to 60 ms with a soft release. The app plays this
single asset and schedules repetitions; rate is computed, not baked into
loops. Arrival = the shared `level.wav` (already in this repo under
`sonar-staircase-v3/`).
Rules — these are the difference between this and the old rejected
"annoying mode":
1. Map total error → tick rate **exponentially**, ~1.5 ticks/s (far) to
~8 ticks/s (near). Hard cap at 8/s; beyond ~10/s it fuses into a buzz.
2. **Loudness stays constant** as rate rises. The acceleration is the signal;
volume ramping is what turns it into a panic siren.
3. On entering the center zone: stop ticking and **immediately** start the
looping `level.wav` — no dwell, no velocity gate, even on a fast pass. A
momentary alignment is real locating information; that little zone needs the
most help. `level.wav` loops while you rest on center and stops the instant
you leave (ticks resume).
4. **Spatial hysteresis** on the zone (enter ≤0.2°, rearm only after leaving
≥0.35°) — *not* a time debounce, which would hide a legitimate quick
re-crossing. Alignment is measured from the same hypot(pitch, roll) that
drives the bubble, so the sound can't lag the visual.
5. Ticking runs continuously outside the zone, no settle gate — the realtime
rate IS the feedback. (The settle-then-sound rule applies to edge mode.)
6. The stronger **"held level" confirmation — persistent lime, the on-screen
label, and the haptic — is decided separately** by the velocity-aware lock
(≤0.2° and moving slowly for ~175 ms). The locating *sound* never waits on
it: a fast fly-through gets the bullseye cue but not the "you nailed it."
Rejected candidates kept for reference in AudioGenerator: the sustained
"energy field" loops (`bullseye-field-*.wav`) and the alternate ticks
(`outputs/tick-candidates/`). Do not ship them.
## Edge mode: the "sonar staircase"
Leveling feedback is a short train of four sonar blips whose **pitch contour is
the message**:
| Reading | Sound | Pitch steps (semitones per blip) |
|---|---|---|
| Level | flat train — all four blips at the same pitch | 0, 0, 0, 0 |
| Slightly low (raise it a little) | gentle rising staircase | 0, +1, +2, +3 |
| Very low (raise it a lot) | steep rising staircase | 0, +3, +6, +9 |
| Slightly high | gentle falling staircase | 0, 1, 2, 3 |
| Very high | steep falling staircase | 0, 3, 6, 9 |
Why this works:
- **Direction** = contour direction (rising = go higher; falling = go lower).
Never invert this mapping.
- **Magnitude** = contour steepness. Humans are poor at absolute pitch but
excellent at relative pitch, so a staircase is decodable without a reference.
- **Level literally sounds "flat."** The metaphor is self-explaining, and the
flat train doubles as the reward/confirmation state.
- **Constant rhythm at every zone.** All five sounds share the same 175 ms blip
cadence — a far-off reading is exactly as fast as a near one. Direction is
audible by blip 2 (~200 ms), full reading by ~525 ms; the rest is decay.
## When to play it (edge mode)
Staircase feedback fires **only after movement followed by settling** (same
trigger as the app's voice feedback). One staircase per settle event.
## The assets
`outputs/sonar-staircase-v3/` (copy into the app bundle):
| File | Duration |
|---|---|
| `level.wav` | 1.39 s |
| `slightly-low.wav` | 1.36 s |
| `very-low.wav` | 1.32 s |
| `slightly-high.wav` | 1.42 s |
| `very-high.wav` | 1.50 s |
All are 96 kHz stereo PCM-16 WAV, peak-normalized to 0.85. Downsampling to
44.1/48 kHz for the bundle is fine. The 500 Hz fundamental sits comfortably in
phone-speaker range, including the 9 st variant (~297 Hz).
## Anatomy of the sound (for regeneration or runtime synthesis)
- **Blip**: a single pure 500 Hz sine blip (100% tonal purity), ~165 ms
including its natural decay, extracted from Jay's licensed SoundQ file
(`~/Downloads/SoundQ Audio/UIBeep_Beep Sonar_PSE_BW-BD3HL_OmEo5.wav`,
segment 515680 ms). 4 ms attack fade, 30 ms release fade.
- **Train**: 4 blips, onsets every 175 ms, pitched per the table above.
Pitch shifts are plain resampling (varispeed — pitch and length change
together). On Android, `SoundPool.setRate()` reproduces this exactly, and
its 0.52.0 range covers the full ±12 semitones used here.
- **Tail**: NOT reverb. The decay is *rhythmic*: the final blip repeats 4 more
times at the same 175 ms cadence, each repeat 8 dB, holding the last blip's
pitch (the tail "holds up the answer"). Jay specifically rejected a smooth
reverb tail — the pulse must survive into silence. This matches the source
file's own decay behavior (measured: repeats every ~180 ms, ~6 dB/repeat).
Generator script: `build_staircase.py` in this folder (AudioGenerator).
Every parameter above is a named constant at the top. Run with
`.venv/bin/python build_staircase.py`; outputs to `outputs/sonar-staircase-v3/`.
## Implementation notes
1. **Hysteresis on tier boundaries (important).** With discrete tiers, a
reading sitting at the slightly/very threshold will flip-flop between
sounds on consecutive settles and feel indecisive. Require ~15% past a
boundary before switching tiers. Same for the level/slightly boundary.
2. **Three tiers is deliberate — don't add a fourth.** Absolute-judgment
limits make >3 discrete steepness levels hard to tell apart in the field.
If finer feedback is ever wanted, synthesize the blips at runtime instead
(enveloped sine oscillators via AudioTrack or Oboe) and make steepness
*continuous* — proportional to the error angle. Continuous mapping
sidesteps the categorization limit entirely; users only compare successive
readings ("shallower than last time = converging").
3. **Interruption**: if a new settle event lands while a staircase is still
playing its tail, cut it and play the new reading — the tail is decoration,
the fresh reading is information.
4. **Haptic pairing (optional)**: a light haptic tap synced to the level.wav
blips makes the "arrived" state feel physical; the tool is usually pressed
against a surface, so it lands well.
## Licensing
The blip derives from Jay's SoundQ "Lifetime License" pack — perpetual
royalty-free use, which for SFX marketplaces normally covers embedding in
apps; standalone redistribution of the sounds is what's prohibited. If any
doubt ever arises: the blip is a mathematically pure 500 Hz sine with simple
envelopes, so a from-scratch synthesized replacement (zero licensing) is
trivial and indistinguishable — the design carries the value, not the sample.
+168
View File
@@ -0,0 +1,168 @@
# Surface Level — Implementation Attack Plan
**Status:** Audited; ready for implementation after this revision.
**Scope:** Surface Level only. Do not change Angle, Edge, ruler, or billing in this stage.
## Outcome
Build a trustworthy two-axis surface-level tool for a phone lying flat,
screen-up, on a surface. It must feel like a physical bullseye vial while
giving clearer, more actionable information than a physical level.
The user should be able to answer three questions at a glance or by sound:
1. Is this surface level?
2. How far off is it?
3. Which side/corner needs adjustment, and by roughly how much?
## Existing foundation to preserve
- Unified device-frame world-up sensor contract.
- Raw sensor -> stable measurement -> animated display separation.
- Vector-space Surface calibration model and two-sample calibration math.
- EMA smoothing, lock hysteresis/dwell/debounce, deadband, and current tests.
- The current numeric Surface reading, pitch/roll values, and haptic lock hook.
## Product decisions to implement
- Surface means the phone is intended to lie flat, screen-up, on the plane being
measured. Its level-lock behavior is valid only in that geometry.
- Numeric measurements are never hidden because the phone is steeply oriented.
Surface tilt magnitude remains authoritative from 0 to 180 degrees. Outside
the ideal face-up geometry, show the actual magnitude and a quiet placement
hint; suppress level-lock feedback rather than replacing the reading with `—`.
- Above 80 degrees of tilt, de-emphasize/suppress Pitch and Roll because their
off-axis parameterization becomes noisy near vertical. Above 90 degrees,
identify the screen-down-side state rather than pretending the bullseye is a
useful placement tool. The magnitude remains visible in both cases.
- The central visualization is a bullseye vial. It has a finite visual range:
beyond that range the bubble rests at the rim, while numeric information remains
full-range and authoritative.
- Calibration corrects device/setup bias. It is not "make this desk level."
A later relative-reference feature is separate and out of scope here.
## Work sequence
### 1. Make measurement behavior honest at every angle
- Refactor Surface placement gating so it only controls the level-lock state,
lock haptic, and geometry-specific guidance.
- Keep the primary tilt magnitude visible from 0-180 degrees. Keep Pitch and
Roll visible only where their meaning is stable (below 80 degrees tilt).
- Add tests at flat, near level, 30 degrees, 80 degrees, near vertical, and
screen-down. Verify magnitude stays correct, off-axis values are suppressed
in their ambiguous range, and invalid placement never locks.
- Keep the existing lock tolerance semantics: a lock label states the tolerance
rather than pretending any non-zero value is exactly flat.
### 2. Add the guided Surface calibration flow
- Add a Surface Calibration route from the Level header.
- Explain that the phone may be calibrated on any firm, stable, *roughly level*
surface; it need not be perfectly level. Warn and ask for a flatter surface
when the measured tilt exceeds 5 degrees.
- Capture the two samples from the uncalibrated sensor path. Never derive a new
calibration from readings that an existing calibration has already corrected.
- Step 1: settle, collect a 1.5-second stable window, capture.
- Step 2: rotate the phone exactly 180 degrees in the same plane, settle, then
collect and capture another 1.5-second stable window.
- Use the shared settling detector: enter Settling above 1.0 degrees/second of
two-axis plane movement; leave it only after movement is at or below 0.3
degrees/second for 500 ms. Reject/retry windows that are not settled.
- Reject/retry a derived calibration bias above 3 degrees as evidence that the
device, setup, or turn was inconsistent. Suppress lock feedback during the flow.
- Persist only the resulting Surface calibration; never alter Edge calibration.
- State that the calibration applies to the current phone/case/contact setup.
- On completion, show a concise success result and a way to clear/re-run it.
### 3. Build the tactile bullseye instrument
- Use Compose Canvas (or an equivalent composable drawing layer) for a scalable
circular vial, etched rings, crosshairs, target zone, and bubble.
- Glass, fluid, and meniscus effects must improve depth/readability; avoid a
decorative or excessively glossy treatment.
- Bubble target position derives directly from stable calibrated pitch/roll.
- Derive bubble target, high/low instruction, and rise/run direction from one
pure SurfaceGuidance function. Its hysteretic next-state input must prevent
quadrant labels from flapping at an axis boundary. Use an edge label when the
minor axis is under 25% of the dominant axis; otherwise use a corner label.
- Bubble movement uses a critically damped spring (damping ratio >= 1) toward
that real target: no overshoot, random drift, autonomous wobble, or animation
that contradicts the number. Clamp the target to the vial's visual range before
applying the spring so the bubble settles at the rim.
- Etched rings are real visual calibration marks, not decoration: map them to
explicit degree values (initially 1, 2, and 5 degrees) using the same pure
measurement-to-position mapping as the bubble.
- Respect disabled system animations and the app's reduced-motion preference by
direct-positioning the deadbanded bubble value instead of running the spring.
- Show a large total tilt beside/below the vial, then Pitch and Roll below it.
- At level lock, use the restrained lime state and an explicit text label.
### 4. Add actionable adjustment information
- Derive a screen-relative high/low direction from calibrated Pitch and Roll.
Use language such as `High: lower-left`; do not claim a compass direction.
- Display slope as both degrees and a useful normalized rise/run value:
millimetres per metre and/or inches per foot based on the selected unit system.
- Treat exact board/counter dimensions and calculated shim/foot height as a later
Adjustment Assist feature, not a requirement of this stage.
- Use the shared settling detector from calibration. When it is settling, show a
subtle `Settling` state and defer strong correction guidance until the stable
measurement is trustworthy.
- Add a persisted units preference in core/settings for rise/run formatting;
the Tools settings UI remains deferred.
### 5. Add optional hands-free Audio Level Assist
- Surface-only, off by default, explicitly user-enabled.
- Use a soft sonar-like pulse with a gentle attack and naturally fading tail;
never a harsh alarm or rapid smoke-detector cadence.
- Silent while the shared settling detector is active or materially outside the
usable placement geometry.
- Near level: occasional low-key proximity pulses with hysteresis between audio
proximity bands, so band changes cannot chatter.
- Locked: one distinct soft lock ping; optionally provide a sparse repeat-while-
locked preference so a user adjusting a large plank can hear that it remains level.
- Reuse the existing lock dwell/hysteresis/debounce so sound cannot chatter.
- Use SoundPool with `USAGE_ASSISTANCE_SONIFICATION`; mix with media rather than
taking audio focus for intermittent pulses. Respect media volume, system sound
settings, app lifecycle, and the user's Audio Assist preference. Release audio
resources with the tool composable/flow collection.
## Surface acceptance criteria
- A stable phone on a known level plane achieves lock without repeated feedback.
- A 180-degree calibration from uncalibrated stable samples on a stable,
roughly-level plane improves repeatability without zeroing that plane's real
slope; inconsistent/moving captures are rejected.
- A screen-up phone at 30, 80, near 90, and screen-down angles always shows a
correct magnitude. Pitch/Roll are suppressed at their documented ambiguous
range, and the Surface tool never falsely claims a lock.
- Bubble direction, high/low instruction, and rise/run direction agree in all
four quadrants through one pure, unit-tested guidance function.
- Bubble position is a pure mapping of stable measurement plus a no-overshoot
spring. Its etched rings match that mapping's documented degree marks.
- Audio can guide a user to lock without viewing the screen and stays quiet when
disabled, settling, backgrounded, or outside the Surface geometry.
- Manual checks cover phone-with-case, phone-without-case, a firm tabletop, and
a large board adjusted by one person.
## Explicitly deferred
- Angle-screen redesign and signed relative-angle semantics.
- Edge/Plumb visual and geometry changes.
- Saved measurements, target-angle alerts, screen ruler, and Play Billing.
- Board dimension/shim calculator and automatic flat/edge switching.
## Questions for Claude's audit
1. Does the relaxed placement gate preserve truthful full-range Surface numbers
while preventing false lock feedback?
2. Is the two-sample calibration flow mathematically and UX-wise safe for the
stated use case, including phone cases and non-level calibration surfaces?
3. Is high/low mapping unambiguous and testable for all pitch/roll quadrants?
4. Does the bullseye animation remain derived from measurement truth and respect
reduced-motion/system-animation settings?
5. Can the audio state machine reuse lock semantics without audio-focus,
lifecycle, or repeated-feedback problems?
6. Is any proposed work outside this single-stage scope?
+73
View File
@@ -0,0 +1,73 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.onthelevel"
compileSdk = 37
defaultConfig {
applicationId = "com.jsjdesigns.onthelevel"
minSdk = 26
targetSdk = 37
versionCode = 1
versionName = "0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
// Declared now so core/billing's real implementation lands without build changes (BRIEF.md §Monetization).
implementation(libs.billing.ktx)
debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
}
+1
View File
@@ -0,0 +1 @@
# Add project specific ProGuard rules here.
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- BRIEF.md: an accelerometer is the minimum viable sensor; devices without one are unsupported. -->
<uses-feature
android:name="android.hardware.sensor.accelerometer"
android:required="true" />
<application
android:name=".OnTheLevelApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.OnTheLevel">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
tools:ignore="LockedOrientationActivity">
<!-- Portrait lock is a BRIEF.md v1 decision: sensor axes are remapped in code,
and edge mode rotates its own readout rather than the Activity. -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,117 @@
package com.onthelevel
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Adjust
import androidx.compose.material.icons.outlined.Handyman
import androidx.compose.material.icons.outlined.SquareFoot
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.foundation.layout.padding
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.onthelevel.core.design.OnTheLevelTheme
import com.onthelevel.feature.angle.AngleScreen
import com.onthelevel.feature.level.LevelScreen
import com.onthelevel.feature.level.SurfaceCalibrationScreen
import com.onthelevel.feature.ruler.RulerScreen
import com.onthelevel.feature.tools.SettingsScreen
import com.onthelevel.feature.tools.ToolsScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val container = (application as OnTheLevelApp).container
setContent {
OnTheLevelTheme {
AppRoot(container)
}
}
}
}
private data class TopDestination(
val route: String,
val label: String,
val icon: ImageVector,
)
private val topDestinations = listOf(
TopDestination("level", "Level", Icons.Outlined.Adjust),
TopDestination("angle", "Angle", Icons.Outlined.SquareFoot),
TopDestination("tools", "Tools", Icons.Outlined.Handyman),
)
@Composable
private fun AppRoot(container: AppContainer) {
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
Scaffold(
bottomBar = {
NavigationBar {
topDestinations.forEach { destination ->
NavigationBarItem(
selected = currentRoute == destination.route,
onClick = {
navController.navigate(destination.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(destination.icon, contentDescription = null) },
label = { Text(destination.label) },
)
}
}
},
) { padding ->
NavHost(
navController = navController,
startDestination = "level",
modifier = Modifier.padding(padding),
) {
composable("level") {
LevelScreen(container, onOpenSettings = {
navController.navigate("settings")
})
}
composable("angle") { AngleScreen(container) }
composable("tools") {
ToolsScreen(
onOpenRuler = { navController.navigate("ruler") },
onOpenSettings = { navController.navigate("settings") },
)
}
composable("ruler") { RulerScreen(container) }
composable("settings") {
SettingsScreen(
container,
onOpenSurfaceCalibration = { navController.navigate("surface-calibration") },
onBack = { navController.popBackStack() },
)
}
composable("surface-calibration") {
SurfaceCalibrationScreen(container, onBack = { navController.popBackStack() })
}
}
}
}
@@ -0,0 +1,40 @@
package com.onthelevel
import android.app.Application
import android.content.Context
import com.onthelevel.core.billing.ProEntitlementRepository
import com.onthelevel.core.billing.StubProEntitlementRepository
import com.onthelevel.core.sensors.AndroidSensorSource
import com.onthelevel.core.sensors.SensorSource
import com.onthelevel.core.settings.SettingsRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
/**
* Manual application container — deliberately no DI framework for an app this size
* (BRIEF.md §Sensor and measurement architecture).
*/
class AppContainer(context: Context) {
private val appContext = context.applicationContext
/**
* Outlives any single screen. Used for durable fire-and-forget persistence
* (e.g. saving a calibration): a screen-scoped coroutine would be cancelled if
* the user leaves the moment the write is launched.
*
* Main.immediate (not Default) so writes launched from UI callbacks enqueue in
* call order — two fast conflicting writes (recalibrate then Reset) then reach
* DataStore deterministically. Only lightweight persistence runs here; DataStore
* still performs the actual IO on its own dispatcher.
*/
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val sensorSource: SensorSource by lazy { AndroidSensorSource(appContext) }
val settings: SettingsRepository by lazy { SettingsRepository(appContext) }
val entitlement: ProEntitlementRepository by lazy { StubProEntitlementRepository() }
}
class OnTheLevelApp : Application() {
val container: AppContainer by lazy { AppContainer(this) }
}
@@ -0,0 +1,106 @@
package com.onthelevel.core.audio
import com.onthelevel.core.sensors.SurfacePresentation
import kotlin.math.pow
/**
* Pure scheduler for Surface TONES. One audible vocabulary: faster ticks mean "approaching",
* and the bullseye sound means "you are centered right now" (SOUND_DESIGN_HANDOFF.md + the
* three-way audio consensus).
*
* Two states, split by TIMING not by sound identity:
* - Outside the center zone: proximity ticks whose rate rises as you near level (exponential,
* clamped). Continuous "warmer/colder"; the rate is the message.
* - Inside the center zone: emit [Cue.ALIGNED] every frame so the caller holds the bullseye
* sound. Entry is IMMEDIATE — no dwell, no velocity gate — so even a fast pass over center
* is announced (that's the locating cue). Ticks are suppressed while aligned.
*
* Alignment uses SPATIAL hysteresis (enter ≤ [alignEnterDegrees], rearm only after leaving
* ≥ [alignExitDegrees]) — not a time debounce, which would hide a legitimate quick re-crossing.
* The error is the same hypot(pitch, roll) that drives the bubble, so the sound can't lag the
* visual. The stronger "held level" confirmation (lime/label/haptic) is decided separately by
* the velocity-aware LockDetector; this policy does not gate that.
*/
class SurfaceTickPolicy(
private val nearDegrees: Double = NEAR_DEGREES,
private val farDegrees: Double = FAR_DEGREES,
private val maxRatePerSecond: Double = MAX_RATE,
private val minRatePerSecond: Double = MIN_RATE,
private val alignEnterDegrees: Double = ALIGN_ENTER_DEGREES,
private val alignExitDegrees: Double = ALIGN_EXIT_DEGREES,
) {
enum class Cue { TICK, ALIGNED }
private var lastTickAtMillis: Long? = null
private var aligned = false
fun update(input: Input): Cue? {
if (!input.isEnabled || !input.isAppForeground || !input.placementOk ||
input.surfacePresentation != SurfacePresentation.FACE_UP
) {
reset()
return null
}
// Spatial hysteresis: enter the zone at the tight threshold, rearm only after
// swinging back out past the loose one. No time debounce.
if (aligned) {
if (input.errorDegrees >= alignExitDegrees) aligned = false
} else {
if (input.errorDegrees <= alignEnterDegrees) aligned = true
}
if (aligned) {
lastTickAtMillis = null // so ticks resume immediately the moment we exit
return Cue.ALIGNED
}
// Outside the zone: proximity ticks, anchored to the last tick, due-time recomputed
// every frame from the current interval (immediate reaction to changing tilt; no drift,
// no catch-up burst). At most one tick per update.
val now = input.nowMillis
val anchor = lastTickAtMillis
if (anchor == null) {
lastTickAtMillis = now // re-entry / just exited the zone: tick immediately
return Cue.TICK
}
val interval = intervalMillis(input.errorDegrees)
val due = anchor + interval
if (now >= due) {
lastTickAtMillis = if (now - due >= interval) now else due
return Cue.TICK
}
return null
}
/** Ticks/second: [maxRatePerSecond] at/inside [nearDegrees], [minRatePerSecond] at/beyond [farDegrees]. */
fun tickRatePerSecond(errorDegrees: Double): Double {
val t = ((errorDegrees - nearDegrees) / (farDegrees - nearDegrees)).coerceIn(0.0, 1.0)
return maxRatePerSecond * (minRatePerSecond / maxRatePerSecond).pow(t) // geometric = exponential
}
fun intervalMillis(errorDegrees: Double): Long = (1000.0 / tickRatePerSecond(errorDegrees)).toLong()
fun reset() {
lastTickAtMillis = null
aligned = false
}
data class Input(
val isEnabled: Boolean,
val isAppForeground: Boolean,
val placementOk: Boolean,
val surfacePresentation: SurfacePresentation?,
val errorDegrees: Double,
val nowMillis: Long,
)
companion object {
const val NEAR_DEGREES = 0.4
const val FAR_DEGREES = 5.0
const val MAX_RATE = 8.0
const val MIN_RATE = 1.5
const val ALIGN_ENTER_DEGREES = 0.2
const val ALIGN_EXIT_DEGREES = 0.35
}
}
@@ -0,0 +1,117 @@
package com.onthelevel.core.audio
import com.onthelevel.core.sensors.SurfacePresentation
import kotlin.math.abs
/**
* Pure policy for spoken Surface leveling guidance — a SETTLED-CHANGE announcer, not a
* timer. It talks like a person watching the bubble: ONE correction on the axis that's
* most off, naming the low side to RAISE, softened to "a little" when close, and "that's
* level" once.
*
* It speaks only when there's something new to say. While the board is MOVING it stays
* silent (but remembers movement happened); when it SETTLES again it re-assesses and
* speaks only if the dominant direction changed, the correction crossed coarse→fine, or
* it reached lock. A board that settles still needing the same coarse nudge gets silence —
* the person already has that instruction. There is no periodic repeat.
*
* Sign conventions (OrientationMath): pitch>0 = top edge high, roll>0 = right edge high.
* To level you raise the LOW side, so top-high -> raise bottom, right-high -> raise left.
*/
class VoiceGuidancePolicy(
private val fineThresholdDegrees: Double = FINE_THRESHOLD_DEGREES,
private val dominanceMarginDegrees: Double = DOMINANCE_MARGIN_DEGREES,
) {
enum class Direction { RAISE_LEFT, RAISE_RIGHT, RAISE_TOP, RAISE_BOTTOM }
sealed interface Phrase {
data class Raise(val direction: Direction, val fine: Boolean) : Phrase
data object Level : Phrase
}
private var spokenDirection: Direction? = null
private var spokenFine = false
private var levelAnnounced = false
// True right after movement, so the next settled frame re-assesses. Starts true so the
// very first settled reading gives an instruction.
private var awaitingSettledAssessment = true
fun update(input: Input): Phrase? {
if (!input.isEnabled || !input.isAppForeground || !input.placementOk ||
input.presentation != SurfacePresentation.FACE_UP
) {
reset()
return null
}
if (input.isLocked) {
spokenDirection = null // re-announce direction after any unlock
awaitingSettledAssessment = false
if (levelAnnounced) return null
levelAnnounced = true
return Phrase.Level
}
levelAnnounced = false
// Moving: stay silent, but note that we must re-assess once it settles. Preserve
// the last spoken direction/tier (do NOT fully reset — that caused the repeats).
if (input.isSettling) {
awaitingSettledAssessment = true
return null
}
// Settled and off-level: speak only on a meaningful change since the last settle.
val direction = dominantDirection(input.pitchDegrees, input.rollDegrees)
val fine = maxOf(abs(input.pitchDegrees), abs(input.rollDegrees)) <= fineThresholdDegrees
val speak = awaitingSettledAssessment && (
spokenDirection == null || // first instruction
direction != spokenDirection || // dominant direction changed
(fine && !spokenFine) // crossed coarse -> fine
)
awaitingSettledAssessment = false
return if (speak) {
spokenDirection = direction
spokenFine = fine
Phrase.Raise(direction, fine)
} else {
null
}
}
private fun dominantDirection(pitch: Double, roll: Double): Direction {
val pitchDir = if (pitch > 0) Direction.RAISE_BOTTOM else Direction.RAISE_TOP
val rollDir = if (roll > 0) Direction.RAISE_LEFT else Direction.RAISE_RIGHT
val prev = spokenDirection
val ambiguous = abs(abs(pitch) - abs(roll)) < dominanceMarginDegrees
if (ambiguous && prev != null) {
// Stay on whichever axis we're already coaching, to avoid flapping on a diagonal.
if (prev == Direction.RAISE_TOP || prev == Direction.RAISE_BOTTOM) return pitchDir
return rollDir
}
return if (abs(pitch) >= abs(roll)) pitchDir else rollDir
}
fun reset() {
spokenDirection = null
spokenFine = false
levelAnnounced = false
awaitingSettledAssessment = true
}
data class Input(
val isEnabled: Boolean,
val isAppForeground: Boolean,
val placementOk: Boolean,
val presentation: SurfacePresentation?,
val isSettling: Boolean,
val pitchDegrees: Double,
val rollDegrees: Double,
val isLocked: Boolean,
val nowMillis: Long,
)
companion object {
const val FINE_THRESHOLD_DEGREES = 1.0
const val DOMINANCE_MARGIN_DEGREES = 0.3
}
}
@@ -0,0 +1,55 @@
package com.onthelevel.core.billing
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Pro entitlement state (BRIEF.md §Monetization). One permanent non-consumable
* product; no subscription.
*/
data class Entitlement(
val isPro: Boolean,
val source: Source,
) {
enum class Source {
/** No confirmation yet — the default, and always safe: free tools are complete. */
NONE,
/** Confirmed by Google Play this session. */
PLAY_CONFIRMED,
/** Previously Play-confirmed, restored from local cache (offline job-site use). */
CACHED,
}
companion object {
val FREE = Entitlement(isPro = false, source = Source.NONE)
}
}
/**
* The ONLY doorway between billing and the rest of the app. Feature code observes
* [entitlement]; nothing outside core/billing may touch Play Billing types
* (AUDIT.md: Pro boundary containment).
*/
interface ProEntitlementRepository {
val entitlement: StateFlow<Entitlement>
/** Re-query owned purchases when possible (app resume, purchase flow completion). */
suspend fun refresh()
}
/**
* Scaffold stand-in: everyone is free tier. Replaced by the Play Billing
* implementation, which must:
* - initialize lazily/async — free tools never block on Play services
* - cache a Play-confirmed entitlement in DataStore for offline use
* - never grant from an arbitrary preference value or a PENDING purchase
*/
class StubProEntitlementRepository : ProEntitlementRepository {
private val state = MutableStateFlow(Entitlement.FREE)
override val entitlement: StateFlow<Entitlement> = state.asStateFlow()
override suspend fun refresh() = Unit
}
// TODO(billing): PlayBillingEntitlementRepository against the declared billing-ktx
// dependency, product id "on_the_level_pro", per the contract above.
@@ -0,0 +1,18 @@
package com.onthelevel.core.design
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalView
/**
* Levels are used hands-free; the screen must not time out mid-measurement
* (BRIEF.md §Level). Scoped to the composable, so leaving the screen releases it.
*/
@Composable
fun KeepScreenOn() {
val view = LocalView.current
DisposableEffect(view) {
view.keepScreenOn = true
onDispose { view.keepScreenOn = false }
}
}
@@ -0,0 +1,96 @@
package com.onthelevel.core.design
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
/**
* Palette from the concept board (resources/Bubble Level Concepts.dc.html):
* deep graphite glass, warm amber guidance, fluorescent spirit fluid, and lime
* for the locked state.
* The design is dark-only in v1 — this is an instrument, not a document.
* Per BRIEF.md, color never carries state alone; labels and numbers always accompany it.
*/
object LevelColors {
val Graphite = Color(0xFF0A0A0B)
val GraphiteRaised = Color(0xFF15181D)
val PanelStroke = Color(0x12FFFFFF)
val Panel = Color(0x0AFFFFFF)
val Amber = Color(0xFFFFC44E)
val AmberHighlight = Color(0xFFFFEEB0)
val AmberDeep = Color(0xFFE0961E)
// Fluorescent yellow-green is both familiar from physical spirit levels and
// substantially more legible than amber against the graphite vial.
val VialLime = Color(0xFFD9FF38)
val VialHighlight = Color(0xFFF7FFD5)
val VialDeep = Color(0xFF86B800)
val VialTarget = Color(0xFFFF6868)
/** Stronger neutral surface reserved for live numeric instrument panels. */
val ReadoutPanel = Color(0xFF242931)
val LimeLock = Color(0xFFCBEF5C)
val LimeLockText = Color(0xFFEAFFC2)
val Cream = Color(0xFFEDEBE6)
val TextPrimary = Color(0xFFF5F4F1)
val TextDim = Color(0x6BFFFFFF)
val TextFaint = Color(0x55FFFFFF)
}
private val DarkScheme = darkColorScheme(
primary = LevelColors.Amber,
onPrimary = LevelColors.Graphite,
secondary = LevelColors.LimeLock,
onSecondary = LevelColors.Graphite,
background = LevelColors.Graphite,
onBackground = LevelColors.TextPrimary,
surface = LevelColors.GraphiteRaised,
onSurface = LevelColors.TextPrimary,
surfaceVariant = LevelColors.GraphiteRaised,
onSurfaceVariant = LevelColors.TextDim,
outline = LevelColors.PanelStroke,
)
// TODO(design): bundle Space Grotesk + IBM Plex Mono per the concept board.
// Until then: system sans for labels, platform monospace for all numeric readouts
// so digits don't jitter horizontally as values change.
val ReadoutFontFamily = FontFamily.Monospace
private val LevelTypography = Typography(
displayLarge = TextStyle(
fontFamily = ReadoutFontFamily,
fontWeight = FontWeight.Light,
fontSize = 96.sp,
letterSpacing = (-2).sp,
),
headlineMedium = TextStyle(
fontFamily = ReadoutFontFamily,
fontWeight = FontWeight.Normal,
fontSize = 28.sp,
),
labelSmall = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp,
letterSpacing = 2.sp,
),
)
@Composable
fun OnTheLevelTheme(content: @Composable () -> Unit) {
// Dark-only by design; isSystemInDarkTheme() intentionally unused in v1.
MaterialTheme(
colorScheme = DarkScheme,
typography = LevelTypography,
content = content,
)
}
@@ -0,0 +1,102 @@
package com.onthelevel.core.sensors
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.emptyFlow
/**
* Sensor selection order (BRIEF.md): GAME_ROTATION_VECTOR → GRAVITY → low-passed
* ACCELEROMETER. Game rotation vector is deliberate — it excludes the magnetometer,
* which lies near the steel this app is used against (AUDIT.md finding 1). No
* heading is needed; only the gravity direction matters.
*/
class AndroidSensorSource(context: Context) : SensorSource {
private val sensorManager: SensorManager? =
context.getSystemService(SensorManager::class.java)
private val selected: Pair<Sensor, SensorSource.Kind>? = sensorManager?.let { sm ->
sm.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR)
?.let { it to SensorSource.Kind.GAME_ROTATION_VECTOR }
?: sm.getDefaultSensor(Sensor.TYPE_GRAVITY)
?.let { it to SensorSource.Kind.GRAVITY }
?: sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
?.let { it to SensorSource.Kind.ACCELEROMETER }
}
override val isAvailable: Boolean = selected != null
override val kind: SensorSource.Kind = selected?.second ?: SensorSource.Kind.NONE
override val gravity: Flow<GravitySample> = if (selected == null || sensorManager == null) {
emptyFlow()
} else {
callbackFlow {
val (sensor, sensorKind) = selected
val rotationMatrix = FloatArray(9)
// Raw-accelerometer fallback only: strip linear acceleration before the
// measurement-layer EMA sees the sample. TODO(tune) against recorded traces.
var lp: Triple<Double, Double, Double>? = null
val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
val sample = when (sensorKind) {
SensorSource.Kind.GAME_ROTATION_VECTOR -> {
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
// Converted to the GravitySample contract (device-frame
// world-up); see RotationVectorMath and SensorContractTest.
val (x, y, z) = RotationVectorMath.worldUpDeviceFrame(rotationMatrix)
GravitySample(
x = x * STANDARD_GRAVITY,
y = y * STANDARD_GRAVITY,
z = z * STANDARD_GRAVITY,
timestampNanos = event.timestamp,
)
}
SensorSource.Kind.GRAVITY -> GravitySample(
x = event.values[0].toDouble(),
y = event.values[1].toDouble(),
z = event.values[2].toDouble(),
timestampNanos = event.timestamp,
)
else -> {
val prev = lp
val next = if (prev == null) {
Triple(
event.values[0].toDouble(),
event.values[1].toDouble(),
event.values[2].toDouble(),
)
} else {
Triple(
prev.first + ACCEL_LP_ALPHA * (event.values[0] - prev.first),
prev.second + ACCEL_LP_ALPHA * (event.values[1] - prev.second),
prev.third + ACCEL_LP_ALPHA * (event.values[2] - prev.third),
)
}
lp = next
GravitySample(next.first, next.second, next.third, event.timestamp)
}
}
trySend(sample)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
}
sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_GAME)
awaitClose { sensorManager.unregisterListener(listener) }
}
}
private companion object {
const val STANDARD_GRAVITY = 9.80665
const val ACCEL_LP_ALPHA = 0.15
}
}
@@ -0,0 +1,34 @@
package com.onthelevel.core.sensors
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* Quantizes a stable measurement to 0.1° for display, with hysteresis so the readout
* doesn't flicker between adjacent values when the input sits on a bucket boundary
* (BRIEF.md: "0.1° resolution and a display deadband").
*
* A displayed value of 0.1° owns the interval [0.05, 0.15]; the readout only moves
* once the input leaves that interval by more than [hysteresisMarginDegrees].
*/
class DisplayDeadband(
private val resolutionDegrees: Double = 0.1,
private val hysteresisMarginDegrees: Double = 0.03,
) {
private var displayed: Double? = null
fun update(stableDegrees: Double): Double {
val current = displayed
val next = if (current == null ||
abs(stableDegrees - current) > resolutionDegrees / 2 + hysteresisMarginDegrees
) {
(stableDegrees / resolutionDegrees).roundToInt() * resolutionDegrees
} else {
current
}
displayed = next
return next
}
fun reset() { displayed = null }
}
@@ -0,0 +1,26 @@
package com.onthelevel.core.sensors
import kotlin.math.exp
/**
* Time-constant-based exponential moving average — the "stable calibrated measurement"
* filter (BRIEF.md value 2 of 3). Using a time constant instead of a fixed alpha keeps
* smoothing identical across devices with different sensor rates.
*/
class Ema(private val tauSeconds: Double) {
private var value: Double? = null
fun update(sample: Double, dtSeconds: Double): Double {
val prev = value
val next = if (prev == null || dtSeconds <= 0.0) {
sample
} else {
val alpha = 1.0 - exp(-dtSeconds / tauSeconds)
prev + alpha * (sample - prev)
}
value = next
return next
}
fun reset() { value = null }
}
@@ -0,0 +1,38 @@
package com.onthelevel.core.sensors
/**
* THE SENSOR-VECTOR CONTRACT: world-up (the gravity REACTION, not the gravity force)
* expressed in the DEVICE coordinate frame, in m/s².
* Android convention: x → right edge, y → top edge, z → out of the screen.
*
* A phone lying flat screen-up on a level surface reads approximately (0, 0, +9.81).
* This is what Android's TYPE_ACCELEROMETER and TYPE_GRAVITY natively report at rest —
* per the SensorEvent docs, a stationary flat device reads +9.81 on Z ("acceleration of
* the device (0 m/s²) minus the force of gravity (9.81 m/s²)"). The rotation-vector
* path is converted to the same convention via [RotationVectorMath]. All three sources
* therefore agree without sign adjustment; SensorContractTest pins this.
*/
data class GravitySample(
val x: Double,
val y: Double,
val z: Double,
val timestampNanos: Long,
)
/**
* The two physical orientations v1 supports (BRIEF.md). Selected manually — never
* auto-switched.
*
* SURFACE: device lying flat, screen up, on the surface being measured. Gravity
* predominantly along +Z.
*
* EDGE: device standing upright on either LONG edge (the edges parallel to the device
* Y axis) against the surface being measured — like a torpedo level. Screen plane
* roughly vertical; gravity predominantly along ±X. The level reading is the tilt of
* the resting edge from horizontal; plumb lean (screen tilted from vertical) is a
* separate, secondary reading.
*
* Placement is validated with [OrientationMath.isPlacementValid]; readings and lock
* are suppressed when the device is not in the selected mode's geometry.
*/
enum class LevelMode { SURFACE, EDGE }
@@ -0,0 +1,97 @@
package com.onthelevel.core.sensors
import kotlin.math.abs
/**
* Level-lock state machine with hysteresis, dwell, and haptic debounce (BRIEF.md
* §Sensor and measurement architecture). Operates on the stable calibrated measurement —
* the same value the numeric readout shows, so the lock never disagrees with the number.
*
* Acquisition is VELOCITY-AWARE when a movement rate is supplied (Surface): a bubble eased
* gently into center (≤ [slowRateThresholdDegPerSec]) locks after a short [dwellMillis]
* confirmation, but a bubble flying across center never accumulates lock time — the dwell
* timer only runs while inside the zone AND moving slowly, and resets otherwise (so slowing
* to a stop inside the zone starts a fresh confirmation). This replaces a fixed long dwell,
* which forced even a slow, deliberate arrival to wait (and delayed the "level" sound).
*
* When no rate is supplied (`rateDegPerSec == null`, e.g. Edge), it falls back to the
* classic fixed [settledDwellMillis] with no velocity gate.
*
* The single lock drives sound, the lime visual, and the haptic together — there is no
* separate audio threshold. Time is injected (callers pass `nowMillis`) for testability.
*/
class LockDetector(
private val enterThresholdDegrees: Double = DEFAULT_ENTER_DEGREES,
private val exitThresholdDegrees: Double = DEFAULT_EXIT_DEGREES,
private val dwellMillis: Long = DEFAULT_DWELL_MILLIS,
private val settledDwellMillis: Long = DEFAULT_SETTLED_DWELL_MILLIS,
private val slowRateThresholdDegPerSec: Double = DEFAULT_SLOW_RATE_DEG_PER_SEC,
private val feedbackDebounceMillis: Long = DEFAULT_FEEDBACK_DEBOUNCE_MILLIS,
) {
companion object {
// BRIEF.md: enter at no more than 0.2°, exit at at least 0.35°. The exit threshold
// doubles as the tolerance stated next to the locked label (Codex review).
const val DEFAULT_ENTER_DEGREES = 0.2
const val DEFAULT_EXIT_DEGREES = 0.35
const val DEFAULT_DWELL_MILLIS = 175L // velocity-gated: short, because a fast crossing can't accumulate
const val DEFAULT_SETTLED_DWELL_MILLIS = 400L // no-velocity fallback (Edge)
const val DEFAULT_SLOW_RATE_DEG_PER_SEC = 0.3
const val DEFAULT_FEEDBACK_DEBOUNCE_MILLIS = 3_000L
}
init {
require(exitThresholdDegrees > enterThresholdDegrees) {
"Hysteresis requires exit > enter threshold"
}
}
data class Result(
val isLocked: Boolean,
/** True exactly once per lock acquisition, and only outside the debounce window. */
val fireFeedback: Boolean,
)
private var locked = false
private var withinEnterSinceMillis: Long? = null
private var lastFeedbackAtMillis: Long? = null
fun update(stableTiltDegrees: Double, rateDegPerSec: Double?, nowMillis: Long): Result {
val magnitude = abs(stableTiltDegrees)
var fire = false
if (!locked) {
val inZone = magnitude <= enterThresholdDegrees
// With a velocity signal, require slow motion to accumulate; a fast center
// crossing (rate above the threshold) never builds lock time.
val slowEnough = rateDegPerSec == null || rateDegPerSec <= slowRateThresholdDegPerSec
val requiredDwell = if (rateDegPerSec == null) settledDwellMillis else dwellMillis
if (inZone && slowEnough) {
val since = withinEnterSinceMillis ?: nowMillis.also { withinEnterSinceMillis = it }
if (nowMillis - since >= requiredDwell) {
locked = true
val last = lastFeedbackAtMillis
if (last == null || nowMillis - last >= feedbackDebounceMillis) {
fire = true
lastFeedbackAtMillis = nowMillis
}
}
} else {
// Out of zone or moving too fast: reset, so a fresh slow-and-centered spell
// starts the confirmation dwell over.
withinEnterSinceMillis = null
}
} else if (magnitude >= exitThresholdDegrees) {
locked = false
withinEnterSinceMillis = null
}
return Result(isLocked = locked, fireFeedback = fire)
}
fun reset() {
locked = false
withinEnterSinceMillis = null
// lastFeedbackAtMillis survives reset on purpose: switching modes must not
// defeat the haptic debounce.
}
}
@@ -0,0 +1,98 @@
package com.onthelevel.core.sensors
import kotlin.math.abs
import kotlin.math.acos
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sqrt
import kotlin.math.tan
/**
* Pure measurement math. No Android types — everything here is unit-testable
* with synthetic and recorded gravity vectors (BRIEF.md §Testability).
*
* Angle sign conventions (documented so calibration and UI agree):
* - Surface pitch: positive when the TOP edge of the device is higher.
* - Surface roll: positive when the RIGHT edge of the device is higher.
* - Edge level: positive when the end the device Y axis points toward is higher.
*/
object OrientationMath {
/** Angle between gravity and the screen normal — the single honest "how far off flat" number. */
fun surfaceTiltMagnitudeDegrees(g: GravitySample): Double {
val n = norm(g)
if (n == 0.0) return 0.0
return Math.toDegrees(acos((g.z / n).coerceIn(-1.0, 1.0)))
}
fun surfacePitchDegrees(g: GravitySample): Double =
Math.toDegrees(atan2(g.y, g.z))
fun surfaceRollDegrees(g: GravitySample): Double =
Math.toDegrees(atan2(g.x, g.z))
/**
* Edge mode: device standing on a long edge (gravity mostly along ±x).
* The reading is the deviation of the resting edge from horizontal —
* the component of gravity along the device Y axis.
*/
fun edgeLevelDegrees(g: GravitySample): Double {
val n = norm(g)
if (n == 0.0) return 0.0
return Math.toDegrees(asin((g.y / n).coerceIn(-1.0, 1.0)))
}
/** Secondary edge reading: how far the screen leans from vertical (plumb). */
fun edgePlumbLeanDegrees(g: GravitySample): Double {
val n = norm(g)
if (n == 0.0) return 0.0
return Math.toDegrees(asin((g.z / n).coerceIn(-1.0, 1.0)))
}
/**
* Percent grade = tan(angle) × 100. Returns signed infinity at/beyond
* [VERTICAL_GRADE_CUTOFF_DEGREES]; UI renders that as "∞" (BRIEF.md §Angle).
*/
fun percentGrade(angleDegrees: Double): Double {
if (abs(angleDegrees) >= VERTICAL_GRADE_CUTOFF_DEGREES) {
return if (angleDegrees > 0) Double.POSITIVE_INFINITY else Double.NEGATIVE_INFINITY
}
return tan(Math.toRadians(angleDegrees)) * 100.0
}
const val VERTICAL_GRADE_CUTOFF_DEGREES = 89.5
/**
* Unsigned angle between two gravity directions — the directional basis for
* relative zero in the angle meter. Unlike subtracting tilt magnitudes, this
* accounts for the axis of movement: zeroing at 10° pitch and moving to 10°
* roll reports the true ~14° orientation change, not 0°.
*/
fun angleBetweenDegrees(a: GravitySample, b: GravitySample): Double {
val na = norm(a)
val nb = norm(b)
if (na == 0.0 || nb == 0.0) return 0.0
val cosine = (a.x * b.x + a.y * b.y + a.z * b.z) / (na * nb)
return Math.toDegrees(acos(cosine.coerceIn(-1.0, 1.0)))
}
/**
* True when the device is physically in the selected mode's geometry (within
* [PLACEMENT_TOLERANCE_DEGREES] of it). Guards against, e.g., Edge mode reading
* "level" for a phone lying flat on a table — asin(gy) is near zero there too,
* but the measurement is meaningless and must not lock.
*/
fun isPlacementValid(g: GravitySample, mode: LevelMode): Boolean {
val n = norm(g)
if (n == 0.0) return false
return when (mode) {
LevelMode.SURFACE -> g.z / n >= PLACEMENT_MIN_COS
LevelMode.EDGE -> abs(g.x) / n >= PLACEMENT_MIN_COS
}
}
const val PLACEMENT_TOLERANCE_DEGREES = 45.0 // TODO(tune) against real handling
private val PLACEMENT_MIN_COS = cos(Math.toRadians(PLACEMENT_TOLERANCE_DEGREES))
private fun norm(g: GravitySample): Double = sqrt(g.x * g.x + g.y * g.y + g.z * g.z)
}
@@ -0,0 +1,15 @@
package com.onthelevel.core.sensors
import kotlin.math.tan
/** Converts a positive surface tilt into the conventional rise-over-run units. */
object RiseRun {
fun millimetersPerMeter(angleDegrees: Double): Double =
tan(Math.toRadians(angleDegrees)) * MILLIMETERS_PER_METER
fun inchesPerFoot(angleDegrees: Double): Double =
tan(Math.toRadians(angleDegrees)) * INCHES_PER_FOOT
private const val MILLIMETERS_PER_METER = 1_000.0
private const val INCHES_PER_FOOT = 12.0
}
@@ -0,0 +1,26 @@
package com.onthelevel.core.sensors
/**
* Pure conversion from a rotation matrix to the device-frame world-up direction,
* split out of AndroidSensorSource so the sensor-contract tests can prove the
* rotation-vector path matches the gravity/accelerometer convention.
*/
object RotationVectorMath {
/**
* [rotationMatrix] is the row-major 3×3 device→world matrix produced by
* SensorManager.getRotationMatrixFromVector. World-up expressed in the device
* frame is Rᵀ·(0,0,1) — the matrix's third row. Multiplied by standard gravity
* this matches what TYPE_GRAVITY reports for the same orientation.
*/
fun worldUpDeviceFrame(rotationMatrix: FloatArray): Triple<Double, Double, Double> {
require(rotationMatrix.size >= 9) { "Expected a 3x3 rotation matrix" }
return Triple(
rotationMatrix[6].toDouble(),
rotationMatrix[7].toDouble(),
rotationMatrix[8].toDouble(),
)
}
const val STANDARD_GRAVITY = 9.80665
}
@@ -0,0 +1,24 @@
package com.onthelevel.core.sensors
import kotlinx.coroutines.flow.Flow
/**
* The seam between Android sensor I/O and the pure measurement math. Tests inject
* fakes emitting synthetic or recorded traces (BRIEF.md §Testability).
*/
interface SensorSource {
/** False on devices with no usable tilt sensor: show the unsupported-device state. */
val isAvailable: Boolean
/** Which physical sensor backs [gravity]; surfaced in the header status line. */
val kind: Kind
/**
* Device-frame gravity stream. Cold: registering happens on collection and
* unregistering on cancellation, so lifecycle-aware collection automatically
* satisfies "sensors registered only while a relevant screen is foregrounded".
*/
val gravity: Flow<GravitySample>
enum class Kind { GAME_ROTATION_VECTOR, GRAVITY, ACCELEROMETER, NONE }
}
@@ -0,0 +1,70 @@
package com.onthelevel.core.sensors
import kotlin.math.hypot
/** Shared two-axis motion gate for calibration, guidance, and Audio Assist. */
class SettlingDetector(
private val enterRateDegreesPerSecond: Double = ENTER_RATE_DEGREES_PER_SECOND,
private val exitRateDegreesPerSecond: Double = EXIT_RATE_DEGREES_PER_SECOND,
private val settledDwellMillis: Long = SETTLED_DWELL_MILLIS,
) {
data class Result(val isSettling: Boolean, val movementRateDegreesPerSecond: Double)
private var lastPitchDegrees: Double? = null
private var lastRollDegrees: Double? = null
private var lastTimestampNanos: Long? = null
private var settledSinceMillis: Long? = null
private var isSettling = true
fun update(pitchDegrees: Double, rollDegrees: Double, timestampNanos: Long): Result {
val previousPitch = lastPitchDegrees
val previousRoll = lastRollDegrees
val previousTimestamp = lastTimestampNanos
lastPitchDegrees = pitchDegrees
lastRollDegrees = rollDegrees
lastTimestampNanos = timestampNanos
if (previousPitch == null || previousRoll == null || previousTimestamp == null) {
return Result(isSettling = true, movementRateDegreesPerSecond = 0.0)
}
val dtSeconds = (timestampNanos - previousTimestamp) / 1e9
if (dtSeconds <= 0.0) {
reset()
return Result(isSettling = true, movementRateDegreesPerSecond = 0.0)
}
val rate = hypot(pitchDegrees - previousPitch, rollDegrees - previousRoll) / dtSeconds
val nowMillis = timestampNanos / 1_000_000
when {
rate > enterRateDegreesPerSecond -> {
isSettling = true
settledSinceMillis = null
}
isSettling && rate > exitRateDegreesPerSecond -> {
// A mid-band hand nudge is not enough to leave Settling, but it is
// enough to prove the signal has not been continuously quiet for
// the required dwell window.
settledSinceMillis = null
}
isSettling && rate <= exitRateDegreesPerSecond -> {
val since = settledSinceMillis ?: nowMillis.also { settledSinceMillis = it }
if (nowMillis - since >= settledDwellMillis) isSettling = false
}
}
return Result(isSettling = isSettling, movementRateDegreesPerSecond = rate)
}
fun reset() {
lastPitchDegrees = null
lastRollDegrees = null
lastTimestampNanos = null
settledSinceMillis = null
isSettling = true
}
companion object {
const val ENTER_RATE_DEGREES_PER_SECOND = 1.0
const val EXIT_RATE_DEGREES_PER_SECOND = 0.3
const val SETTLED_DWELL_MILLIS = 500L
}
}
@@ -0,0 +1,49 @@
package com.onthelevel.core.sensors
/** Uncalibrated, smoothed Surface sample used by guided calibration. */
data class SurfaceCalibrationSample(
val pitchDegrees: Double,
val rollDegrees: Double,
val tiltDegrees: Double,
val timestampNanos: Long,
val isSettling: Boolean,
)
/** Mean of one settled 1.5-second calibration capture. */
data class CapturedSurfaceSample(val pitchDegrees: Double, val rollDegrees: Double)
/** Collects a continuous settled capture on a roughly-level surface. */
class StableSurfaceCapture(
private val durationMillis: Long = CAPTURE_DURATION_MILLIS,
private val maximumTiltDegrees: Double = MAXIMUM_CALIBRATION_TILT_DEGREES,
) {
private var startedAtNanos: Long? = null
private var pitchTotal = 0.0
private var rollTotal = 0.0
private var count = 0
fun add(sample: SurfaceCalibrationSample): CapturedSurfaceSample? {
if (sample.isSettling || sample.tiltDegrees > maximumTiltDegrees) {
reset()
return null
}
val startedAt = startedAtNanos ?: sample.timestampNanos.also { startedAtNanos = it }
pitchTotal += sample.pitchDegrees
rollTotal += sample.rollDegrees
count += 1
if ((sample.timestampNanos - startedAt) / 1_000_000 < durationMillis || count < 2) return null
return CapturedSurfaceSample(pitchTotal / count, rollTotal / count)
}
fun reset() {
startedAtNanos = null
pitchTotal = 0.0
rollTotal = 0.0
count = 0
}
companion object {
const val CAPTURE_DURATION_MILLIS = 1_500L
const val MAXIMUM_CALIBRATION_TILT_DEGREES = 5.0
}
}
@@ -0,0 +1,36 @@
package com.onthelevel.core.sensors
import kotlin.math.hypot
/**
* Guards calibration persistence against a bad calibration.
*/
object SurfaceCalibrationValidation {
const val MAXIMUM_BIAS_MAGNITUDE_DEGREES = 3.0
const val PAIR_AGREEMENT_TOLERANCE_DEGREES = 0.4
/** The combined two-axis correction must be small; a large mean means something moved. */
fun isAcceptable(calibration: SurfaceCalibration): Boolean =
hypot(calibration.pitchBiasDegrees, calibration.rollBiasDegrees) <= MAXIMUM_BIAS_MAGNITUDE_DEGREES
/**
* A 4-point set (0°/90°/180°/270°) contains TWO independent 180°-flip bias
* estimates: the (0,180) pair and the (90,270) pair. A clean turn makes them
* agree; a malformed rotation makes them diverge even when the overall mean stays
* plausibly small — which the magnitude guard alone cannot detect. Reject when the
* two estimates disagree beyond tolerance.
*
* The disagreement scales with the surface's true tilt, so this is naturally
* lenient near level (where a turn error barely matters) and strict on a steeper
* permitted surface (where it matters most). Sets that aren't the 4-point shape
* carry no cross-check and pass here; the magnitude guard still applies.
*/
fun isRotationConsistent(samples: List<CapturedSurfaceSample>): Boolean {
if (samples.size != 4) return true
val pitchA = (samples[0].pitchDegrees + samples[2].pitchDegrees) / 2.0
val rollA = (samples[0].rollDegrees + samples[2].rollDegrees) / 2.0
val pitchB = (samples[1].pitchDegrees + samples[3].pitchDegrees) / 2.0
val rollB = (samples[1].rollDegrees + samples[3].rollDegrees) / 2.0
return hypot(pitchA - pitchB, rollA - rollB) <= PAIR_AGREEMENT_TOLERANCE_DEGREES
}
}
@@ -0,0 +1,44 @@
package com.onthelevel.core.sensors
import kotlin.math.abs
import kotlin.math.max
/** The one source of truth for Surface bubble placement and screen-relative direction. */
object SurfaceGuidance {
const val VISUAL_RANGE_DEGREES = 5.0
const val DIRECTION_DEADBAND_DEGREES = 0.1
const val CORNER_MINOR_AXIS_RATIO = 0.25
val RING_DEGREES = listOf(1.0, 2.0, 5.0)
data class Guidance(
val normalizedX: Double,
val normalizedY: Double,
val highLabel: String?,
)
fun from(pitchDegrees: Double, rollDegrees: Double): Guidance {
val x = visualPosition(rollDegrees)
val y = -visualPosition(pitchDegrees) // Compose Y grows downward; positive pitch means top is high.
return Guidance(x, y, highLabel(pitchDegrees, rollDegrees))
}
fun ringRadiusRatio(degrees: Double): Double = (degrees / VISUAL_RANGE_DEGREES).coerceIn(0.0, 1.0)
private fun visualPosition(degrees: Double): Double =
(degrees / VISUAL_RANGE_DEGREES).coerceIn(-1.0, 1.0)
private fun highLabel(pitch: Double, roll: Double): String? {
val pitchMagnitude = abs(pitch)
val rollMagnitude = abs(roll)
val dominant = max(pitchMagnitude, rollMagnitude)
if (dominant < DIRECTION_DEADBAND_DEGREES) return null
val topOrBottom = if (pitch >= 0.0) "top" else "bottom"
val leftOrRight = if (roll >= 0.0) "right" else "left"
val minor = minOf(pitchMagnitude, rollMagnitude)
return if (minor / dominant < CORNER_MINOR_AXIS_RATIO) {
if (pitchMagnitude >= rollMagnitude) "High: $topOrBottom edge" else "High: $leftOrRight edge"
} else {
"High: $topOrBottom-$leftOrRight"
}
}
}
@@ -0,0 +1,58 @@
package com.onthelevel.core.sensors
/**
* Surface-only presentation state. Tilt magnitude remains authoritative from 0180°;
* Pitch and roll become ill-conditioned near vertical and are deliberately suppressed.
*/
enum class SurfacePresentation {
FACE_UP,
NEAR_VERTICAL,
SCREEN_DOWN,
}
/**
* Hysteretic presentation classifier driven by the same deadbanded stable magnitude
* the UI displays. This prevents Pitch/Roll panels and placement copy from flapping
* around the 80° and 90° boundaries due to sensor noise.
*/
class SurfacePresentationDetector {
private var presentation = SurfacePresentation.FACE_UP
fun update(displayedTiltDegrees: Double): SurfacePresentation {
presentation = when (presentation) {
SurfacePresentation.FACE_UP -> {
if (displayedTiltDegrees > SCREEN_DOWN_ENTER_DEGREES) {
SurfacePresentation.SCREEN_DOWN
} else if (displayedTiltDegrees >= AXIS_SUPPRESSION_ENTER_DEGREES) {
SurfacePresentation.NEAR_VERTICAL
} else {
SurfacePresentation.FACE_UP
}
}
SurfacePresentation.NEAR_VERTICAL -> when {
displayedTiltDegrees > SCREEN_DOWN_ENTER_DEGREES -> SurfacePresentation.SCREEN_DOWN
displayedTiltDegrees < AXIS_SUPPRESSION_EXIT_DEGREES -> SurfacePresentation.FACE_UP
else -> SurfacePresentation.NEAR_VERTICAL
}
SurfacePresentation.SCREEN_DOWN -> {
if (displayedTiltDegrees <= SCREEN_DOWN_EXIT_DEGREES) {
SurfacePresentation.NEAR_VERTICAL
} else {
SurfacePresentation.SCREEN_DOWN
}
}
}
return presentation
}
fun reset() {
presentation = SurfacePresentation.FACE_UP
}
companion object {
const val AXIS_SUPPRESSION_ENTER_DEGREES = 80.0
const val AXIS_SUPPRESSION_EXIT_DEGREES = 79.0
const val SCREEN_DOWN_ENTER_DEGREES = 90.0
const val SCREEN_DOWN_EXIT_DEGREES = 89.0
}
}
@@ -0,0 +1,130 @@
package com.onthelevel.core.sensors
import com.onthelevel.core.sensors.Vec3Math.Vec3
import kotlin.math.acos
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.tan
/**
* Two-sample (180° flip) calibration, per mode (BRIEF.md §Measurement modes and calibration).
*
* DERIVATION — the surface's true tilt is fixed in the world frame; the device's own bias
* is fixed in the device frame. After rotating the device 180° about the CONTACT-PLANE
* NORMAL — on the same, unmoved surface — the true tilt appears negated in device
* readings while the bias does not move:
*
* reading₁ = tilt + bias
* reading₂ = -tilt + bias
* ⇒ bias = (reading₁ + reading₂) / 2
*
* Derivation happens in angle space near level, where the flip identity is exact to
* first order. Preconditions the calibration UI must enforce: (a) rotation about the
* contact-plane normal, (b) surface unmoved between samples, (c) surface within a few
* degrees of level.
*
* APPLICATION — the stored bias angles parameterize a per-mode REFERENCE ORIENTATION:
* the device-frame direction gravity reads when the device is truly level in that mode.
* Correction is applied in vector space — a fixed rotation of every gravity sample that
* maps the reference onto the mode's ideal axis — BEFORE any display angle is derived.
* This stays correct away from zero (see tests at 30°), unlike subtracting scalar
* offsets from derived angles.
*
* Biases are stored per mode and applied only to that mode's readings; a Surface
* calibration must never touch Edge readings (AUDIT.md finding 3).
*/
object TwoSampleCalibration {
/** Derive one axis's fixed device bias from two readings taken 180° apart. */
fun deriveBiasDegrees(reading1Degrees: Double, reading2Degrees: Double): Double =
(reading1Degrees + reading2Degrees) / 2.0
fun deriveSurface(
pitch1: Double, roll1: Double,
pitch2: Double, roll2: Double,
): SurfaceCalibration = SurfaceCalibration(
pitchBiasDegrees = deriveBiasDegrees(pitch1, pitch2),
rollBiasDegrees = deriveBiasDegrees(roll1, roll2),
)
/**
* Generalized bias derivation for a SYMMETRIC set of rotations about the surface
* normal — 2-point (0°/180°) or 4-point (0°/90°/180°/270°). The surface's true tilt
* is a sinusoid in rotation angle, so it sums to zero over any such symmetric set;
* the mean of the readings is therefore the fixed device bias. The 4-point set also
* cancels each axis with both a 180° opposite and a 90° pair, so it tolerates an
* imperfect turn better and averages out more sensor noise (thorough mode).
*/
fun deriveSurfaceFromSamples(samples: List<CapturedSurfaceSample>): SurfaceCalibration {
require(samples.isNotEmpty()) { "Calibration needs at least one captured sample" }
return SurfaceCalibration(
pitchBiasDegrees = samples.sumOf { it.pitchDegrees } / samples.size,
rollBiasDegrees = samples.sumOf { it.rollDegrees } / samples.size,
)
}
fun deriveEdge(
level1: Double,
level2: Double,
calibratedOnPositiveXEdge: Boolean = true,
): EdgeCalibration = EdgeCalibration(
levelBiasDegrees = deriveBiasDegrees(level1, level2),
calibratedOnPositiveXEdge = calibratedOnPositiveXEdge,
)
}
/**
* Surface-mode reference orientation, parameterized by the bias angles measured when the
* device is truly flat. [apply] rotates each gravity sample by the fixed rotation that
* aligns the reference direction with the screen normal (+Z).
*/
data class SurfaceCalibration(val pitchBiasDegrees: Double, val rollBiasDegrees: Double) {
fun apply(g: GravitySample): GravitySample {
if (pitchBiasDegrees == 0.0 && rollBiasDegrees == 0.0) return g
// Reference: the direction gravity reads on a truly level surface —
// atan2(y, z) = pitchBias and atan2(x, z) = rollBias by construction.
val reference = Vec3Math.normalize(
Vec3(
tan(Math.toRadians(rollBiasDegrees)),
tan(Math.toRadians(pitchBiasDegrees)),
1.0,
),
)
val axis = Vec3Math.cross(reference, Vec3Math.WORLD_UP_FLAT)
val axisNorm = Vec3Math.norm(axis)
if (axisNorm < 1e-12) return g
val unitAxis = Vec3(axis.x / axisNorm, axis.y / axisNorm, axis.z / axisNorm)
val angle = acos(Vec3Math.dot(reference, Vec3Math.WORLD_UP_FLAT).coerceIn(-1.0, 1.0))
val corrected = Vec3Math.rotate(Vec3(g.x, g.y, g.z), unitAxis, angle)
return g.copy(x = corrected.x, y = corrected.y, z = corrected.z)
}
companion object { val NONE = SurfaceCalibration(0.0, 0.0) }
}
/**
* Edge-mode reference orientation: a fixed rotation about the device Z axis that zeroes
* the level reading for the calibrated placement, leaving plumb lean untouched (lean is
* not part of "edge level" and must not be silently "calibrated" from a leaned placement).
*
* Valid for the long edge the calibration was performed on; [calibratedOnPositiveXEdge]
* records which (true = the device's right edge down, gravity along +X). The calibration
* flow must capture this from the placement it instructed.
*/
data class EdgeCalibration(
val levelBiasDegrees: Double,
val calibratedOnPositiveXEdge: Boolean = true,
) {
fun apply(g: GravitySample): GravitySample {
if (levelBiasDegrees == 0.0) return g
val gamma = Math.toRadians(
if (calibratedOnPositiveXEdge) -levelBiasDegrees else levelBiasDegrees,
)
val c = cos(gamma)
val s = sin(gamma)
return g.copy(x = g.x * c - g.y * s, y = g.x * s + g.y * c)
}
companion object { val NONE = EdgeCalibration(0.0) }
}
@@ -0,0 +1,41 @@
package com.onthelevel.core.sensors
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/** Minimal 3-vector helpers for calibration rotations. Pure Kotlin, module-internal. */
internal object Vec3Math {
data class Vec3(val x: Double, val y: Double, val z: Double)
val WORLD_UP_FLAT = Vec3(0.0, 0.0, 1.0)
fun norm(v: Vec3): Double = sqrt(v.x * v.x + v.y * v.y + v.z * v.z)
fun normalize(v: Vec3): Vec3 {
val n = norm(v)
return if (n == 0.0) v else Vec3(v.x / n, v.y / n, v.z / n)
}
fun dot(a: Vec3, b: Vec3): Double = a.x * b.x + a.y * b.y + a.z * b.z
fun cross(a: Vec3, b: Vec3): Vec3 = Vec3(
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x,
)
/** Rodrigues rotation of [v] by [angleRadians] about the UNIT axis [axis]. */
fun rotate(v: Vec3, axis: Vec3, angleRadians: Double): Vec3 {
val c = cos(angleRadians)
val s = sin(angleRadians)
val kxv = cross(axis, v)
val kdv = dot(axis, v)
return Vec3(
v.x * c + kxv.x * s + axis.x * kdv * (1 - c),
v.y * c + kxv.y * s + axis.y * kdv * (1 - c),
v.z * c + kxv.z * s + axis.z * kdv * (1 - c),
)
}
}
@@ -0,0 +1,13 @@
package com.onthelevel.core.settings
/**
* How the hands-free Surface audio assist speaks. Mutually exclusive: tones and voice
* would talk over each other, so it's one choice, not two toggles.
*
* OFF — silent.
* TONES — continuous proximity ticking that speeds up as you near level, then `level.wav`
* once at lock.
* VOICE — spoken corrections ("raise the right") + "that's level", announced only on a
* settled change. No bed underneath — speech stays sparse.
*/
enum class AudioAssistMode { OFF, TONES, VOICE }
@@ -0,0 +1,11 @@
package com.onthelevel.core.settings
/**
* How thorough the guided Surface calibration is. Both use a symmetric rotation set
* about the surface normal so the mean of the readings is the device bias; THOROUGH
* adds the 90°/270° pair to better tolerate an imperfect turn and average more noise.
*/
enum class CalibrationMode(val captureCount: Int) {
SIMPLE(2),
THOROUGH(4),
}
@@ -0,0 +1,7 @@
package com.onthelevel.core.settings
/** Display units for construction-oriented slope guidance. */
enum class MeasurementUnits {
METRIC,
IMPERIAL,
}
@@ -0,0 +1,124 @@
package com.onthelevel.core.settings
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.onthelevel.core.sensors.EdgeCalibration
import com.onthelevel.core.sensors.SurfaceCalibration
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
/**
* DataStore-backed preferences and calibration persistence (BRIEF.md core/settings).
* Calibration is stored PER MODE and applied only to that mode's readings.
*/
class SettingsRepository(context: Context) {
private val store = context.applicationContext.dataStore
val surfaceCalibration: Flow<SurfaceCalibration> = store.data.map { prefs ->
SurfaceCalibration(
pitchBiasDegrees = prefs[Keys.SURFACE_PITCH_BIAS] ?: 0.0,
rollBiasDegrees = prefs[Keys.SURFACE_ROLL_BIAS] ?: 0.0,
)
}
val edgeCalibration: Flow<EdgeCalibration> = store.data.map { prefs ->
EdgeCalibration(
levelBiasDegrees = prefs[Keys.EDGE_LEVEL_BIAS] ?: 0.0,
calibratedOnPositiveXEdge = prefs[Keys.EDGE_CAL_POSITIVE_X] ?: true,
)
}
val hapticsEnabled: Flow<Boolean> = store.data.map { it[Keys.HAPTICS_ENABLED] ?: true }
/** Off / Tones / Voice, migrating from the legacy audio-cue boolean if unset. */
val audioAssistMode: Flow<AudioAssistMode> = store.data.map { prefs ->
when (prefs[Keys.AUDIO_ASSIST_MODE]) {
AudioAssistMode.TONES.name -> AudioAssistMode.TONES
AudioAssistMode.VOICE.name -> AudioAssistMode.VOICE
AudioAssistMode.OFF.name -> AudioAssistMode.OFF
else -> if (prefs[Keys.AUDIO_CUE_ENABLED] == true) AudioAssistMode.TONES else AudioAssistMode.OFF
}
}
/** In-app reduced-motion preference; the system animator-scale signal is respected separately. */
val reducedMotion: Flow<Boolean> = store.data.map { it[Keys.REDUCED_MOTION] ?: false }
/** Kept in core so every future measurement tool formats slope consistently. */
val measurementUnits: Flow<MeasurementUnits> = store.data.map { prefs ->
when (prefs[Keys.MEASUREMENT_UNITS]) {
MeasurementUnits.IMPERIAL.name -> MeasurementUnits.IMPERIAL
else -> MeasurementUnits.METRIC
}
}
val calibrationMode: Flow<CalibrationMode> = store.data.map { prefs ->
when (prefs[Keys.CALIBRATION_MODE]) {
CalibrationMode.THOROUGH.name -> CalibrationMode.THOROUGH
else -> CalibrationMode.SIMPLE
}
}
suspend fun setSurfaceCalibration(calibration: SurfaceCalibration) {
store.edit {
it[Keys.SURFACE_PITCH_BIAS] = calibration.pitchBiasDegrees
it[Keys.SURFACE_ROLL_BIAS] = calibration.rollBiasDegrees
}
}
suspend fun clearSurfaceCalibration() = setSurfaceCalibration(SurfaceCalibration.NONE)
suspend fun setEdgeCalibration(calibration: EdgeCalibration) {
store.edit {
it[Keys.EDGE_LEVEL_BIAS] = calibration.levelBiasDegrees
it[Keys.EDGE_CAL_POSITIVE_X] = calibration.calibratedOnPositiveXEdge
}
}
suspend fun setHapticsEnabled(enabled: Boolean) {
store.edit { it[Keys.HAPTICS_ENABLED] = enabled }
}
suspend fun setAudioCueEnabled(enabled: Boolean) {
store.edit { it[Keys.AUDIO_CUE_ENABLED] = enabled }
}
suspend fun setAudioAssistMode(mode: AudioAssistMode) {
store.edit { it[Keys.AUDIO_ASSIST_MODE] = mode.name }
}
suspend fun setReducedMotion(enabled: Boolean) {
store.edit { it[Keys.REDUCED_MOTION] = enabled }
}
suspend fun setMeasurementUnits(units: MeasurementUnits) {
store.edit { it[Keys.MEASUREMENT_UNITS] = units.name }
}
suspend fun setCalibrationMode(mode: CalibrationMode) {
store.edit { it[Keys.CALIBRATION_MODE] = mode.name }
}
// TODO(ruler): screen-ruler scale keyed by display identity/characteristics (BRIEF.md).
private object Keys {
val SURFACE_PITCH_BIAS = doublePreferencesKey("surface_pitch_bias_deg")
val SURFACE_ROLL_BIAS = doublePreferencesKey("surface_roll_bias_deg")
val EDGE_LEVEL_BIAS = doublePreferencesKey("edge_level_bias_deg")
val EDGE_CAL_POSITIVE_X = booleanPreferencesKey("edge_cal_positive_x")
val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled")
val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled")
val AUDIO_ASSIST_MODE = stringPreferencesKey("audio_assist_mode")
val REDUCED_MOTION = booleanPreferencesKey("reduced_motion")
val MEASUREMENT_UNITS = stringPreferencesKey("measurement_units")
val CALIBRATION_MODE = stringPreferencesKey("calibration_mode")
}
}
@@ -0,0 +1,178 @@
package com.onthelevel.feature.angle
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.onthelevel.AppContainer
import com.onthelevel.core.design.KeepScreenOn
import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.sensors.Ema
import com.onthelevel.core.sensors.GravitySample
import com.onthelevel.core.sensors.OrientationMath
import com.onthelevel.feature.level.formatDegrees
import com.onthelevel.feature.level.performConfirmHaptic
import kotlinx.coroutines.flow.map
/**
* Scaffold Angle screen: live absolute/relative angle with hold-to-zero (always free).
*
* Relative zero stores the full gravity DIRECTION at the moment of zeroing, and the
* relative reading is the angle between the current and stored directions. Subtracting
* tilt magnitudes would lose the axis: zeroed at 10° pitch and moved to 10° roll, the
* device has genuinely rotated ~14°, and that is what this reports (Codex review).
* The relative reading is therefore unsigned.
*
* TODO(pro): target-angle alerts and saved named references behind the entitlement.
*/
@Composable
fun AngleScreen(container: AppContainer) {
KeepScreenOn()
val sensorSource = container.sensorSource
if (!sensorSource.isAvailable) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No usable tilt sensor on this device.", color = LevelColors.TextDim)
}
return
}
val readingFlow = remember {
// Smooth the vector components, then derive angles — keeps the smoothed
// gravity direction available for the vector-based zero reference.
val xEma = Ema(0.15)
val yEma = Ema(0.15)
val zEma = Ema(0.15)
var lastNanos: Long? = null
sensorSource.gravity.map { g ->
val dt = lastNanos?.let { (g.timestampNanos - it) / 1e9 } ?: 0.0
lastNanos = g.timestampNanos
GravitySample(
x = xEma.update(g.x, dt),
y = yEma.update(g.y, dt),
z = zEma.update(g.z, dt),
timestampNanos = g.timestampNanos,
)
}
}
val smoothed by readingFlow.collectAsStateWithLifecycle(initialValue = null)
// Zero reference: the smoothed gravity direction captured on long-press.
// Empty array = no reference set. DoubleArray keeps rememberSaveable happy.
var zeroReference by rememberSaveable { mutableStateOf(doubleArrayOf()) }
val view = LocalView.current
val primaryDegrees = smoothed?.let { s ->
if (zeroReference.size == 3) {
OrientationMath.angleBetweenDegrees(
s,
GravitySample(zeroReference[0], zeroReference[1], zeroReference[2], 0),
)
} else {
OrientationMath.surfaceTiltMagnitudeDegrees(s)
}
}
val isRelative = zeroReference.size == 3
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp, vertical = 16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("ANGLE", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
Text(
text = if (isRelative) "RELATIVE · HOLD TO RE-ZERO" else "HOLD TO ZERO",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextFaint,
)
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
smoothed?.let {
zeroReference = doubleArrayOf(it.x, it.y, it.z)
view.performConfirmHaptic()
}
},
onDoubleTap = { zeroReference = doubleArrayOf() },
)
},
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = primaryDegrees?.let(::formatDegrees) ?: "",
style = MaterialTheme.typography.displayLarge,
color = LevelColors.TextPrimary,
)
if (isRelative) {
Text(
text = "from saved orientation · double-tap to clear",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.Amber,
textAlign = TextAlign.Center,
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
val pitch = smoothed?.let(OrientationMath::surfacePitchDegrees)
SecondaryValue("PITCH", pitch?.let(::formatDegrees), Modifier.weight(1f))
SecondaryValue(
"ROLL",
smoothed?.let(OrientationMath::surfaceRollDegrees)?.let(::formatDegrees),
Modifier.weight(1f),
)
SecondaryValue(
"GRADE",
pitch?.let { formatGrade(OrientationMath.percentGrade(it)) },
Modifier.weight(1f),
)
}
}
}
@Composable
private fun SecondaryValue(label: String, value: String?, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(label, style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
Text(value ?: "", style = MaterialTheme.typography.headlineMedium, color = LevelColors.TextPrimary)
}
}
private fun formatGrade(grade: Double): String = when {
grade.isInfinite() -> ""
else -> String.format(java.util.Locale.US, "%.1f%%", grade)
}
@@ -0,0 +1,244 @@
package com.onthelevel.feature.level
import android.content.Context
import android.media.AudioAttributes
import android.media.SoundPool
import android.os.SystemClock
import android.speech.tts.TextToSpeech
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.onthelevel.R
import com.onthelevel.core.audio.SurfaceTickPolicy
import com.onthelevel.core.audio.VoiceGuidancePolicy
import com.onthelevel.core.settings.AudioAssistMode
import java.util.Locale
import kotlin.math.hypot
/**
* Hands-free Surface audio assist (SOUND_DESIGN_HANDOFF.md).
*
* TONES — proximity ticks that speed up as you near level; the moment you're on the
* bullseye (aligned zone, spatial hysteresis) the ticks give way to the looping `level.wav`
* bullseye sound — immediately, even on a fast pass. One vocabulary: faster ticks = closer,
* bullseye sound = centered now. The persistent lime/label/haptic "held level" is separate
* (velocity-aware LockDetector); the sound does not wait on it.
*
* VOICE — deliberately separate: spoken corrections + "that's level", settled-change only.
*
* Everything plays on the media stream (so the volume slider works) and never takes audio
* focus. Players/speakers exist only while enabled and foregrounded; released on dispose.
*/
@Composable
fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) {
val isForeground = rememberIsResumed()
val tickPolicy = remember { SurfaceTickPolicy() }
val context = LocalContext.current.applicationContext
val player = remember(mode, isForeground, context) {
if (mode != AudioAssistMode.OFF && isForeground) SonarSoundPool(context) else null
}
val speaker = remember(mode, isForeground, context) {
if (mode == AudioAssistMode.VOICE && isForeground) VoiceSpeaker(context) else null
}
// Fresh per speaker so each VOICE session re-evaluates from the current reading — and
// the policy is only consumed once the speaker is ready, so a phrase can't be queued
// and then go stale during TTS init (Codex).
val voicePolicy = remember(speaker) { VoiceGuidancePolicy() }
DisposableEffect(player) { onDispose { player?.release() } }
DisposableEffect(speaker) { onDispose { speaker?.shutdown() } }
LaunchedEffect(reading, mode, isForeground, player, speaker) {
val current = reading ?: return@LaunchedEffect
// TONES: proximity ticks outside the center zone; the bullseye sound the instant
// you're aligned. Alignment uses the SAME axes as the bubble (hypot of stable
// pitch/roll), so the sound can't lag the visual.
val alignError = hypot(
current.stableSurfacePitchDegrees ?: 0.0,
current.stableSurfaceRollDegrees ?: 0.0,
)
when (
tickPolicy.update(
SurfaceTickPolicy.Input(
isEnabled = mode == AudioAssistMode.TONES,
isAppForeground = isForeground,
placementOk = current.placementOk,
surfacePresentation = current.surfacePresentation,
errorDegrees = alignError,
nowMillis = SystemClock.elapsedRealtime(),
),
)
) {
// Aligned: hold the looping bullseye sound (idempotent — starts once).
SurfaceTickPolicy.Cue.ALIGNED -> player?.ensureLevelLooping()
// A tick means we're outside the zone: stop the bullseye first (order matters),
// then tick.
SurfaceTickPolicy.Cue.TICK -> {
player?.stopLevel()
player?.playTick()
}
// Outside the zone, no tick due: make sure the bullseye is stopped (covers exit).
null -> player?.stopLevel()
}
// Only run the voice policy once the speaker is ready — never consume its state
// for a phrase we can't speak yet. When ready, it evaluates the current reading,
// so nothing stale gets spoken after init finishes.
if (speaker?.isReady == true) {
val phrase = voicePolicy.update(
VoiceGuidancePolicy.Input(
isEnabled = true,
isAppForeground = isForeground,
placementOk = current.placementOk,
presentation = current.surfacePresentation,
isSettling = current.isSettling,
pitchDegrees = current.stableSurfacePitchDegrees ?: 0.0,
rollDegrees = current.stableSurfaceRollDegrees ?: 0.0,
isLocked = current.isLocked,
nowMillis = System.currentTimeMillis(),
),
)
if (phrase != null) speaker.speak(phrase)
}
}
}
@Composable
private fun rememberIsResumed(): Boolean {
val lifecycleOwner = LocalLifecycleOwner.current
var isResumed by remember(lifecycleOwner) {
mutableStateOf(lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED))
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, _ ->
isResumed = lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
return isResumed
}
private class SonarSoundPool(context: Context) {
private val loaded = mutableSetOf<Int>()
private val soundPool = SoundPool.Builder()
.setMaxStreams(4)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
)
.build()
private val tickSound: Int
private val levelSound: Int
private var levelStreamId: Int = 0
private var levelPending = false // Level asked for before the sample finished loading
init {
soundPool.setOnLoadCompleteListener { _, sampleId, status ->
if (status == 0) {
loaded += sampleId
// If ALIGNED arrived before the Level sample finished loading, start the loop
// the moment it lands (ensureLevelLooping set levelPending instead of dropping it).
if (sampleId == levelSound && levelPending) {
levelPending = false
startLevelLoop()
}
}
}
tickSound = soundPool.load(context, R.raw.tick, 1)
levelSound = soundPool.load(context, R.raw.level, 1)
}
/** One proximity tick — constant volume (the rate is the signal, not loudness). */
fun playTick() {
if (tickSound in loaded) soundPool.play(tickSound, TICK_VOLUME, TICK_VOLUME, 1, 0, 1f)
}
/**
* The bullseye sound, looped while you're aligned. Idempotent — safe to call every frame;
* starts once and keeps going. Queues if the sample hasn't loaded yet.
*/
fun ensureLevelLooping() {
if (levelStreamId != 0) return // already looping
if (levelSound in loaded) startLevelLoop() else levelPending = true
}
private fun startLevelLoop() {
levelStreamId = soundPool.play(levelSound, LEVEL_VOLUME, LEVEL_VOLUME, 2, -1, 1f)
}
fun stopLevel() {
levelPending = false // cancel a queued start (we left the zone before it loaded)
if (levelStreamId != 0) {
soundPool.stop(levelStreamId)
levelStreamId = 0
}
}
fun release() = soundPool.release()
private companion object {
const val TICK_VOLUME = 0.55f
const val LEVEL_VOLUME = 0.85f
}
}
/**
* On-device text-to-speech, structured so premium pre-recorded clips can be swapped in
* later without touching callers. Mixes as sonification and does not take audio focus.
* TODO(voice): prefer bundled clips (res/raw) when present, fall back to TTS.
*/
private class VoiceSpeaker(context: Context) {
/** The caller waits on this before consuming the voice policy, so nothing goes stale. */
var isReady = false
private set
private lateinit var tts: TextToSpeech
init {
tts = TextToSpeech(context.applicationContext) { status ->
if (status == TextToSpeech.SUCCESS) {
tts.language = Locale.US
tts.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
isReady = true
}
}
}
fun speak(phrase: VoiceGuidancePolicy.Phrase) {
if (isReady) tts.speak(textFor(phrase), TextToSpeech.QUEUE_FLUSH, null, "level-voice")
}
private fun textFor(phrase: VoiceGuidancePolicy.Phrase): String = when (phrase) {
VoiceGuidancePolicy.Phrase.Level -> "that's level"
is VoiceGuidancePolicy.Phrase.Raise -> {
val side = when (phrase.direction) {
VoiceGuidancePolicy.Direction.RAISE_LEFT -> "left"
VoiceGuidancePolicy.Direction.RAISE_RIGHT -> "right"
VoiceGuidancePolicy.Direction.RAISE_TOP -> "top"
VoiceGuidancePolicy.Direction.RAISE_BOTTOM -> "bottom"
}
if (phrase.fine) "raise the $side, just a little" else "raise the $side"
}
}
fun shutdown() {
tts.stop()
tts.shutdown()
}
}
@@ -0,0 +1,150 @@
package com.onthelevel.feature.level
import com.onthelevel.core.sensors.DisplayDeadband
import com.onthelevel.core.sensors.EdgeCalibration
import com.onthelevel.core.sensors.Ema
import com.onthelevel.core.sensors.GravitySample
import com.onthelevel.core.sensors.LevelMode
import com.onthelevel.core.sensors.LockDetector
import com.onthelevel.core.sensors.OrientationMath
import com.onthelevel.core.sensors.SurfaceCalibration
import com.onthelevel.core.sensors.SurfacePresentation
import com.onthelevel.core.sensors.SurfacePresentationDetector
import com.onthelevel.core.sensors.SettlingDetector
/**
* One reading through the raw → stable pipeline (BRIEF.md values 1 and 2 of 3).
* Value 3 — the spring-animated display value that moves the tactile vial — is
* NOT yet implemented; this scaffold renders the deadbanded stable value directly.
*/
data class LevelReading(
val mode: LevelMode,
/** Deadbanded stable magnitude for the big readout, in degrees. */
val displayPrimaryDegrees: Double,
/** Surface: pitch when it is meaningful. Edge: signed level deviation. */
val secondaryADegrees: Double?,
/** Surface: roll when it is meaningful. Edge: plumb lean. */
val secondaryBDegrees: Double?,
/** Stable calibrated Surface axes for the visual instrument; null outside Surface mode. */
val stableSurfacePitchDegrees: Double? = null,
val stableSurfaceRollDegrees: Double? = null,
/** Stable (pre-deadband) Surface tilt magnitude; drives the audio tick rate. Null for Edge. */
val stableTiltDegrees: Double? = null,
/** Surface-only presentation state; null for Edge mode. */
val surfacePresentation: SurfacePresentation? = null,
/** False when the device is not physically in the selected mode's geometry. */
val placementOk: Boolean,
/** Surface-only shared motion gate; true while the phone is still settling. */
val isSettling: Boolean = false,
val isLocked: Boolean,
val fireFeedback: Boolean,
)
/**
* Stateful per-collection pipeline: vector-space calibration → angle derivation →
* EMA smoothing → lock detection → display deadband. Calibration is applied to the
* gravity VECTOR before any display angle is derived, so it stays a reference
* orientation rather than a scalar offset. Created fresh when mode or calibration
* changes; the LockDetector is shared across recreations so the haptic debounce
* survives mode switches.
*
* Invalid Edge placement (e.g. Edge selected but the phone lying flat) suppresses
* lock detection — asin(gy) reads near zero there too, and locking on it would be
* a lie. Surface lock is self-gating: only a near-zero surface tilt can acquire it,
* while its magnitude remains visible at every orientation.
*/
class LevelPipeline(
private val mode: LevelMode,
private val surfaceCalibration: SurfaceCalibration,
private val edgeCalibration: EdgeCalibration,
private val lockDetector: LockDetector,
) {
private val emaPrimary = Ema(SMOOTHING_TAU_SECONDS)
private val emaA = Ema(SMOOTHING_TAU_SECONDS)
private val emaB = Ema(SMOOTHING_TAU_SECONDS)
private val primaryDeadband = DisplayDeadband()
private val aDeadband = DisplayDeadband()
private val bDeadband = DisplayDeadband()
private val surfacePresentationDetector = SurfacePresentationDetector()
private val settlingDetector = SettlingDetector()
private var lastTimestampNanos: Long? = null
fun process(g: GravitySample): LevelReading {
val dtSeconds = lastTimestampNanos?.let { (g.timestampNanos - it) / 1e9 } ?: 0.0
lastTimestampNanos = g.timestampNanos
// Sensor timestamps are monotonic; using them (not wall clock) keeps the
// lock state machine deterministic under recorded traces.
val nowMillis = g.timestampNanos / 1_000_000
val placementOk = OrientationMath.isPlacementValid(g, mode)
return when (mode) {
LevelMode.SURFACE -> {
val corrected = surfaceCalibration.apply(g)
val pitch = emaA.update(OrientationMath.surfacePitchDegrees(corrected), dtSeconds)
val roll = emaB.update(OrientationMath.surfaceRollDegrees(corrected), dtSeconds)
val magnitude = emaPrimary.update(
OrientationMath.surfaceTiltMagnitudeDegrees(corrected),
dtSeconds,
)
val displayMagnitude = primaryDeadband.update(magnitude)
val presentation = surfacePresentationDetector.update(displayMagnitude)
val settling = settlingDetector.update(pitch, roll, g.timestampNanos)
// Velocity-aware lock: ease into center → quick confirm; fly across → nothing.
val lock = lockDetector.update(magnitude, settling.movementRateDegreesPerSecond, nowMillis)
LevelReading(
mode = mode,
displayPrimaryDegrees = displayMagnitude,
secondaryADegrees = if (presentation == SurfacePresentation.FACE_UP) {
aDeadband.update(pitch)
} else {
null
},
secondaryBDegrees = if (presentation == SurfacePresentation.FACE_UP) {
bDeadband.update(roll)
} else {
null
},
stableTiltDegrees = magnitude,
stableSurfacePitchDegrees = pitch,
stableSurfaceRollDegrees = roll,
surfacePresentation = presentation,
placementOk = placementOk,
isSettling = settling.isSettling,
isLocked = lock.isLocked,
fireFeedback = lock.fireFeedback,
)
}
LevelMode.EDGE -> {
val corrected = edgeCalibration.apply(g)
val level = emaPrimary.update(
OrientationMath.edgeLevelDegrees(corrected),
dtSeconds,
)
val lean = emaB.update(
OrientationMath.edgePlumbLeanDegrees(corrected),
dtSeconds,
)
// Edge has no movement-rate signal yet: null → classic fixed-dwell lock.
val lock = lockDetector.update(
if (placementOk) level else Double.MAX_VALUE,
null,
nowMillis,
)
val displayLevel = primaryDeadband.update(level)
LevelReading(
mode = mode,
displayPrimaryDegrees = displayLevel,
secondaryADegrees = displayLevel,
secondaryBDegrees = bDeadband.update(lean),
placementOk = placementOk,
isLocked = lock.isLocked,
fireFeedback = lock.fireFeedback,
)
}
}
}
private companion object {
const val SMOOTHING_TAU_SECONDS = 0.15 // TODO(tune) against recorded traces
}
}
@@ -0,0 +1,409 @@
package com.onthelevel.feature.level
import android.os.Build
import android.view.HapticFeedbackConstants
import android.view.View
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.onthelevel.AppContainer
import com.onthelevel.core.design.KeepScreenOn
import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.sensors.EdgeCalibration
import com.onthelevel.core.sensors.LevelMode
import com.onthelevel.core.sensors.LockDetector
import com.onthelevel.core.sensors.RiseRun
import com.onthelevel.core.sensors.SensorSource
import com.onthelevel.core.sensors.SurfaceCalibration
import com.onthelevel.core.sensors.SurfaceGuidance
import com.onthelevel.core.sensors.SurfacePresentation
import com.onthelevel.core.settings.AudioAssistMode
import com.onthelevel.core.settings.MeasurementUnits
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import java.util.Locale
/**
* Scaffold Level screen: live numbers, manual Surface|Edge selector, lock state.
* TODO(feature): the tactile cross-vial visual (spring-animated display value),
* the brief lime lock reaction, audio cue, and the calibration flow entry.
* TODO(feature): in Edge mode, counter-rotate the readout so it reads upright
* while the portrait-locked device stands on its long edge.
*/
@Composable
fun LevelScreen(container: AppContainer, onOpenSettings: () -> Unit) {
KeepScreenOn()
val sensorSource = container.sensorSource
if (!sensorSource.isAvailable) {
UnsupportedDeviceMessage()
return
}
var mode by rememberSaveable { mutableStateOf(LevelMode.SURFACE) }
val surfaceCal by container.settings.surfaceCalibration
.collectAsStateWithLifecycle(initialValue = SurfaceCalibration.NONE)
val edgeCal by container.settings.edgeCalibration
.collectAsStateWithLifecycle(initialValue = EdgeCalibration.NONE)
val hapticsEnabled by container.settings.hapticsEnabled
.collectAsStateWithLifecycle(initialValue = true)
val audioMode by container.settings.audioAssistMode
.collectAsStateWithLifecycle(initialValue = AudioAssistMode.OFF)
val reducedMotion by container.settings.reducedMotion
.collectAsStateWithLifecycle(initialValue = false)
val measurementUnits by container.settings.measurementUnits
.collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC)
val view = LocalView.current
val lockDetector = remember { LockDetector() }
LaunchedEffect(mode) { lockDetector.reset() }
val readingFlow = remember(mode, surfaceCal, edgeCal, hapticsEnabled, view) {
val pipeline = LevelPipeline(mode, surfaceCal, edgeCal, lockDetector)
sensorSource.gravity
.map { pipeline.process(it) }
.onEach { if (it.fireFeedback && hapticsEnabled) view.performConfirmHaptic() }
}
val reading by readingFlow.collectAsStateWithLifecycle(initialValue = null)
AudioLevelAssist(reading, audioMode)
val isLocked = reading?.isLocked == true
val isCalibrated = when (mode) {
LevelMode.SURFACE -> surfaceCal != SurfaceCalibration.NONE
LevelMode.EDGE -> edgeCal != EdgeCalibration.NONE
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp, vertical = 16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text("LEVEL", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
Text(
text = statusLine(isCalibrated, sensorSource.kind),
style = MaterialTheme.typography.labelSmall,
color = if (isCalibrated) LevelColors.LimeLock else LevelColors.TextFaint,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
// Tap cycles Off -> Tones -> Voice. Lime when active (a confirmed on-state).
TextButton(onClick = {
val next = when (audioMode) {
AudioAssistMode.OFF -> AudioAssistMode.TONES
AudioAssistMode.TONES -> AudioAssistMode.VOICE
AudioAssistMode.VOICE -> AudioAssistMode.OFF
}
container.applicationScope.launch { container.settings.setAudioAssistMode(next) }
}) {
Text(
text = when (audioMode) {
AudioAssistMode.OFF -> "AUDIO OFF"
AudioAssistMode.TONES -> "TONES"
AudioAssistMode.VOICE -> "VOICE"
},
style = MaterialTheme.typography.labelSmall,
color = if (audioMode == AudioAssistMode.OFF) LevelColors.TextDim else LevelColors.LimeLockText,
)
}
IconButton(onClick = onOpenSettings) {
Icon(
Icons.Outlined.Settings,
contentDescription = "Settings",
tint = LevelColors.TextDim,
)
}
}
}
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
LevelMode.entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = mode == entry,
onClick = { mode = entry },
shape = SegmentedButtonDefaults.itemShape(index = index, count = LevelMode.entries.size),
) {
Text(if (entry == LevelMode.SURFACE) "Surface" else "Edge")
}
}
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
val placementOk = reading?.placementOk ?: true
val surfacePresentation = reading?.surfacePresentation
Column(
modifier = Modifier.offset(y = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.FACE_UP) {
val pitch = reading?.stableSurfacePitchDegrees
val roll = reading?.stableSurfaceRollDegrees
if (pitch != null && roll != null) {
SurfaceBullseye(pitch, roll, isLocked, reducedMotion)
}
}
Text(
text = reading?.let { primaryReadout(formatDegrees(it.displayPrimaryDegrees)) }
?: AnnotatedString(""),
style = MaterialTheme.typography.displayLarge,
color = LevelColors.TextPrimary,
)
// Lock state is announced with a label, never color alone (BRIEF.md).
// The label states the tolerance (the lock's exit threshold), so the
// rounded readout and the "level" claim can never contradict.
Text(
text = when {
mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.SCREEN_DOWN ->
"Screen facing down · turn phone screen up"
mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.NEAR_VERTICAL ->
"Near vertical · pitch and roll unavailable"
!placementOk && mode == LevelMode.SURFACE -> "Lay the phone flat, screen up"
!placementOk -> "Stand the phone upright on a long edge"
isLocked && mode == LevelMode.SURFACE -> "Flat within ${formatTolerance()}"
isLocked -> "Level within ${formatTolerance()}"
else -> ""
},
style = MaterialTheme.typography.headlineMedium,
color = when {
!placementOk -> LevelColors.TextDim
isLocked -> LevelColors.LimeLockText
else -> LevelColors.TextPrimary
},
textAlign = TextAlign.Center,
)
if (mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.FACE_UP) {
Spacer(Modifier.height(8.dp))
// This slot stays the same height for Settling, guidance, and
// level states so a stable phone never makes the instrument
// jump as its correction copy changes.
Box(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
contentAlignment = Alignment.TopCenter,
) {
// Guidance and the status label are mutually exclusive
// (guidance renders only while the label line is empty),
// so it rises to visually take the label's place.
SurfaceAdjustmentInfo(
reading,
measurementUnits,
Modifier.offset(y = (-36).dp),
)
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
ValuePanel(
label = if (mode == LevelMode.SURFACE) "PITCH" else "LEVEL",
value = reading?.secondaryADegrees,
modifier = Modifier.weight(1f),
)
ValuePanel(
label = if (mode == LevelMode.SURFACE) "ROLL" else "LEAN",
value = reading?.secondaryBDegrees,
modifier = Modifier.weight(1f),
)
}
}
}
/**
* Surface-only adjustment guidance. Direction comes exclusively from
* [SurfaceGuidance] using the displayed (deadbanded) axes, so the wording has
* the same hysteresis as the pitch and roll panels instead of flapping at a
* direction boundary. The shared pipeline settling gate deliberately defers a
* strong instruction while the phone is still being placed.
*/
@Composable
private fun SurfaceAdjustmentInfo(
reading: LevelReading?,
units: MeasurementUnits,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
if (reading == null) return@Column
// A locked surface is flat within tolerance: issuing correction guidance
// under the lock line would contradict it and crowd the status stack.
if (reading.isLocked) return@Column
if (reading.isSettling) {
Text(
text = "Settling",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
)
return@Column
}
val pitch = reading.secondaryADegrees ?: return@Column
val roll = reading.secondaryBDegrees ?: return@Column
val highLabel = SurfaceGuidance.from(pitch, roll).highLabel ?: return@Column
// One rank below the lock/status line (28sp mono): guidance advises, it
// doesn't compete.
Text(
text = highLabel,
style = MaterialTheme.typography.titleLarge,
color = LevelColors.Amber,
textAlign = TextAlign.Center,
)
Text(
text = formatRiseRun(reading.displayPrimaryDegrees, units),
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
)
}
}
@Composable
private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) {
Surface(
modifier = modifier,
color = LevelColors.ReadoutPanel,
contentColor = LevelColors.TextPrimary,
shape = MaterialTheme.shapes.large,
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
label,
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
textAlign = TextAlign.Center,
)
Text(
text = value?.let { formatDegrees(it) } ?: "",
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun UnsupportedDeviceMessage() {
Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(
text = "This device has no usable tilt sensor, so On the Level can't take measurements here.",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
color = LevelColors.TextDim,
)
}
}
private fun statusLine(isCalibrated: Boolean, kind: SensorSource.Kind): String {
val source = when (kind) {
SensorSource.Kind.GAME_ROTATION_VECTOR -> "fused"
SensorSource.Kind.GRAVITY -> "gravity"
SensorSource.Kind.ACCELEROMETER -> "accelerometer"
SensorSource.Kind.NONE -> "no sensor"
}
return (if (isCalibrated) "calibrated" else "live") + " · " + source
}
internal fun formatDegrees(value: Double): String = String.format(Locale.US, "%.1f°", value)
/**
* Primary readout typography: the degree symbol rendered small and faint keeps the
* number elegant without losing the instrument voice — and its invisible leading
* twin balances the trailing one, so the digits sit optically dead-center no
* matter the glyph widths.
*/
private fun primaryReadout(text: String): AnnotatedString = buildAnnotatedString {
// The shrunken symbol would otherwise sit on the digits' baseline at
// mid-height; the baseline shift floats it back up to the cap line where a
// degree mark belongs.
val degreeStyle = SpanStyle(
fontSize = DEGREE_SYMBOL_EM.em,
baselineShift = BaselineShift(DEGREE_SYMBOL_SHIFT),
)
withStyle(degreeStyle.copy(color = Color.Transparent)) { append("°") }
append(text.removeSuffix("°"))
withStyle(degreeStyle.copy(color = LevelColors.TextDim)) { append("°") }
}
private const val DEGREE_SYMBOL_EM = .55f
// No cap-height-alignment primitive exists in Compose; this value is derived from
// Roboto's metrics (digit cap height minus the degree glyph's top at the symbol
// size above — resize them together), verified against on-device captures.
private const val DEGREE_SYMBOL_SHIFT = .38f
internal fun formatRiseRun(valueDegrees: Double, units: MeasurementUnits): String = when (units) {
MeasurementUnits.METRIC ->
String.format(Locale.US, "%.1f mm/m", RiseRun.millimetersPerMeter(valueDegrees))
MeasurementUnits.IMPERIAL ->
String.format(Locale.US, "%.2f in/ft", RiseRun.inchesPerFoot(valueDegrees))
}
private fun formatTolerance(): String =
String.format(Locale.US, "%.2f°", LockDetector.DEFAULT_EXIT_DEGREES)
internal fun View.performConfirmHaptic() {
val constant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
HapticFeedbackConstants.CONFIRM
} else {
HapticFeedbackConstants.VIRTUAL_KEY
}
performHapticFeedback(constant)
}
@@ -0,0 +1,302 @@
package com.onthelevel.feature.level
import android.provider.Settings
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.design.ReadoutFontFamily
import com.onthelevel.core.sensors.SurfaceGuidance
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.sin
/**
* Glass bullseye whose target and rings are both driven by SurfaceGuidance.
*
* The bubble is a VOID in fluorescent fluid: a perfectly round, borderless
* translucent orb — one symmetric gradient whose edge fades out rather than
* outlining — grounded by a contact shadow and lit by a small fixed upper-left
* reflection. The calibration marks (and the red target dot) stay readable
* through it. Every layer serves depth or readability (SURFACE_LEVEL_PLAN.md:
* no decorative gloss); alphas are the tuning knobs if anything reads as chrome.
*/
@Composable
fun SurfaceBullseye(pitchDegrees: Double, rollDegrees: Double, isLocked: Boolean, reducedMotion: Boolean) {
val guidance = SurfaceGuidance.from(pitchDegrees, rollDegrees)
val targetX = guidance.normalizedX.toFloat()
val targetY = guidance.normalizedY.toFloat()
val animatedX = remember { Animatable(0f) }
val animatedY = remember { Animatable(0f) }
val context = LocalContext.current
val systemMotionDisabled = remember(context) {
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
}
val lockPulse = remember { Animatable(0f) }
val textMeasurer = rememberTextMeasurer()
// These must run independently. animateTo suspends until a spring settles;
// sequencing axes here would starve Y under the sensor's ~50 Hz retargeting
// and make diagonal movement trace an unnatural L.
LaunchedEffect(targetX, reducedMotion, systemMotionDisabled) {
if (reducedMotion || systemMotionDisabled) {
animatedX.snapTo(targetX)
} else {
animatedX.animateTo(targetX, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow))
}
}
LaunchedEffect(targetY, reducedMotion, systemMotionDisabled) {
if (reducedMotion || systemMotionDisabled) {
animatedY.snapTo(targetY)
} else {
animatedY.animateTo(targetY, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow))
}
}
LaunchedEffect(isLocked) {
if (isLocked) {
lockPulse.snapTo(1f)
lockPulse.animateTo(0f, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow))
} else lockPulse.snapTo(0f)
}
Canvas(Modifier.size(250.dp)) {
val radius = size.minDimension / 2f
val center = Offset(size.width / 2f, size.height / 2f)
val vialRadius = radius * .74f
val bubbleCenter = center + Offset(animatedX.value * vialRadius, animatedY.value * vialRadius)
// Nested inside the 1° target ring (0.148 × radius): the level-read is the
// classic "bubble inside the circle", never the bubble swallowing the mark.
val bubbleRadius = radius * BUBBLE_RADIUS_RATIO
// The target ring highlights on what the EYE sees: the drawn bubble sitting
// inside (or tangent to) the circle. Lock truth — label, pulse, haptic —
// stays with LockDetector; this accent must never outlive the visual.
val targetRingRadius = vialRadius * SurfaceGuidance.ringRadiusRatio(1.0).toFloat()
val bubbleInsideTarget =
hypot(bubbleCenter.x - center.x, bubbleCenter.y - center.y) + bubbleRadius <=
targetRingRadius + 1.dp.toPx()
drawVialGlass(center, radius, vialRadius)
drawCalibrationMarks(center, radius, vialRadius, bubbleInsideTarget, textMeasurer)
if (lockPulse.value > 0f) {
drawCircle(LevelColors.LimeLock.copy(alpha = .55f * lockPulse.value), vialRadius, center, style = Stroke(3.dp.toPx()))
}
drawBubbleBody(bubbleCenter, bubbleRadius)
drawBubbleLight(bubbleCenter, bubbleRadius)
}
}
private fun DrawScope.drawVialGlass(center: Offset, radius: Float, vialRadius: Float) {
drawCircle(Brush.radialGradient(listOf(Color(0xFF22272E), Color(0xFF090A0C)), center, radius), radius, center)
// Faint sheen from the fixed upper-left light — a depth cue, kept far below gloss.
drawCircle(
Brush.radialGradient(
listOf(Color.White.copy(alpha = .05f), Color.Transparent),
center + Offset(-radius * .38f, -radius * .42f),
radius * .9f,
),
radius,
center,
)
// Fluid vignette: the dye reads darker where it meets the vial wall.
drawCircle(
Brush.radialGradient(
colorStops = arrayOf(
0f to Color.Transparent,
.78f to Color.Transparent,
1f to Color.Black.copy(alpha = .32f),
),
center = center,
radius = vialRadius,
),
vialRadius,
center,
)
// Machined bezel: a shallow metallic annulus, lit from above, instead of a
// flat outline — housing depth without adding information noise.
val bezelWidth = 5.dp.toPx()
drawCircle(
Brush.linearGradient(
listOf(Color.White.copy(alpha = .20f), Color.White.copy(alpha = .05f)),
start = Offset(center.x, center.y - radius),
end = Offset(center.x, center.y + radius),
),
radius - bezelWidth / 2f,
center,
style = Stroke(bezelWidth),
)
drawCircle(Color.White.copy(alpha = .14f), radius - bezelWidth, center, style = Stroke(1.dp.toPx()))
// Etched bezel ticks: cardinals strong, 45° minors faint — orientation
// vocabulary borrowed from real instrument bezels.
for (degrees in 0 until 360 step 45) {
val isCardinal = degrees % 90 == 0
val angle = Math.toRadians(degrees.toDouble())
val direction = Offset(cos(angle).toFloat(), sin(angle).toFloat())
val outer = radius - 1.dp.toPx()
val inner = outer - if (isCardinal) 8.dp.toPx() else 5.dp.toPx()
drawLine(
Color.White.copy(alpha = if (isCardinal) .45f else .22f),
center + direction * inner,
center + direction * outer,
if (isCardinal) 1.5.dp.toPx() else 1.dp.toPx(),
)
}
}
private fun DrawScope.drawCalibrationMarks(
center: Offset,
radius: Float,
vialRadius: Float,
bubbleInsideTarget: Boolean,
textMeasurer: TextMeasurer,
) {
val labelStyle = TextStyle(
fontSize = 9.sp,
fontFamily = ReadoutFontFamily,
color = Color.White.copy(alpha = .38f),
)
SurfaceGuidance.RING_DEGREES.forEach { mark ->
val isTargetRing = mark == 1.0
val ringRadius = vialRadius * SurfaceGuidance.ringRadiusRatio(mark).toFloat()
val color = if (isTargetRing && bubbleInsideTarget) {
// Restrained lime accent, driven by visual containment of the drawn
// bubble. The label still carries the lock state in words.
LevelColors.LimeLock.copy(alpha = .9f)
} else {
val alpha = when (mark) {
1.0 -> .46f // The target zone needs to be the clearest calibration mark.
2.0 -> .36f
else -> .27f
}
Color.White.copy(alpha = alpha)
}
drawCircle(
color,
ringRadius,
center,
style = Stroke(if (isTargetRing && bubbleInsideTarget) 2.dp.toPx() else 1.5.dp.toPx()),
)
// Each ring says what it means: its degree value, etched small on the
// lower-right diagonal just outside the ring.
val layout = textMeasurer.measure(AnnotatedString("${mark.toInt()}°"), labelStyle)
val diagonal = (ringRadius + 5.dp.toPx()) * DIAGONAL_COMPONENT
val position = center + Offset(diagonal, diagonal)
drawText(
layout,
topLeft = position - Offset(layout.size.width / 2f, layout.size.height / 2f),
)
}
val crosshairStroke = 1.dp.toPx()
drawLine(Color.White.copy(alpha = .32f), Offset(center.x - vialRadius, center.y), Offset(center.x + vialRadius, center.y), crosshairStroke)
drawLine(Color.White.copy(alpha = .32f), Offset(center.x, center.y - vialRadius), Offset(center.x, center.y + vialRadius), crosshairStroke)
// The exact-center target mark stays beneath the translucent bubble, seen
// through the trapped air just as it would be in a real vial.
drawCircle(Color.White.copy(alpha = .85f), radius * .030f, center)
drawCircle(LevelColors.VialTarget, radius * .020f, center)
}
/** A borderless, perfectly round translucent orb: one symmetric gradient, edge fading to nothing. */
private fun DrawScope.drawBubbleBody(c: Offset, b: Float) {
// Fluorescent halo: the dye glows brightest where the lens bends light past the rim.
drawCircle(
Brush.radialGradient(
listOf(LevelColors.VialLime.copy(alpha = .16f), Color.Transparent),
c,
b * 1.9f,
),
b * 1.9f,
c,
)
// Contact shadow: a soft dark ring just outside the rim seats the bubble IN the
// fluid instead of floating over the graphics.
drawCircle(
Brush.radialGradient(
colorStops = arrayOf(
0f to Color.Transparent,
.68f to Color.Transparent,
.80f to Color.Black.copy(alpha = .30f),
1f to Color.Transparent,
),
center = c,
radius = b * 1.3f,
),
b * 1.3f,
c,
)
// The orb itself. Centered gradient = perfectly round; the soft bright band
// sits evenly inside the rim, and the thin dark contact edge fades out rather
// than drawing a border.
drawCircle(
Brush.radialGradient(
colorStops = arrayOf(
0f to LevelColors.VialHighlight.copy(alpha = .24f),
.52f to LevelColors.VialLime.copy(alpha = .18f),
.80f to LevelColors.VialLime.copy(alpha = .36f),
.91f to LevelColors.VialHighlight.copy(alpha = .66f),
.965f to LevelColors.VialDeep.copy(alpha = .32f),
1f to Color.Transparent,
),
center = c,
radius = b * 1.06f,
),
b * 1.06f,
c,
)
}
/**
* Reflection layer, world-oriented: a soft window-style reflection patch at the
* upper-left (how glass and soap bubbles actually mirror a light source), a sharp
* glint at its heart, and a faint pass-through shimmer on the far rim.
*/
private fun DrawScope.drawBubbleLight(c: Offset, b: Float) {
withTransform({
translate(c.x - b * .30f, c.y - b * .32f)
rotate(-38f, Offset.Zero)
scale(1f, .55f, Offset.Zero)
}) {
drawCircle(
Brush.radialGradient(
listOf(Color.White.copy(alpha = .50f), Color.Transparent),
Offset.Zero,
b * .46f,
),
b * .46f,
Offset.Zero,
)
}
drawCircle(Color.White.copy(alpha = .85f), b * .07f, c + Offset(-b * .30f, -b * .34f))
val shimmerCenter = c + Offset(b * .42f, b * .44f)
drawCircle(
Brush.radialGradient(
listOf(Color.White.copy(alpha = .12f), Color.Transparent),
shimmerCenter,
b * .30f,
),
b * .30f,
shimmerCenter,
)
}
private const val BUBBLE_RADIUS_RATIO = .12f
private const val DIAGONAL_COMPONENT = .7071f
@@ -0,0 +1,386 @@
package com.onthelevel.feature.level
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.onthelevel.AppContainer
import com.onthelevel.core.design.KeepScreenOn
import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.sensors.CapturedSurfaceSample
import com.onthelevel.core.sensors.Ema
import com.onthelevel.core.sensors.OrientationMath
import com.onthelevel.core.sensors.SettlingDetector
import com.onthelevel.core.sensors.StableSurfaceCapture
import com.onthelevel.core.sensors.SurfaceCalibration
import com.onthelevel.core.sensors.SurfaceCalibrationSample
import com.onthelevel.core.sensors.SurfaceCalibrationValidation
import com.onthelevel.core.sensors.TwoSampleCalibration
import com.onthelevel.core.settings.CalibrationMode
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.util.Locale
private enum class Stage { OVERVIEW, CAPTURING }
private enum class CapturePhase { READY, RUNNING, ERROR }
/**
* Surface calibration. OVERVIEW shows the current correction and lets the user start,
* reset, or leave. CAPTURING runs the guided flow and shows ONLY calibration controls;
* Cancel returns to OVERVIEW without touching the saved calibration, so exploring or
* backing out never changes the current setting.
*
* Captures read SensorSource.gravity directly and never apply the stored calibration.
*/
@Composable
fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
KeepScreenOn()
val sensorSource = container.sensorSource
val mode by container.settings.calibrationMode
.collectAsStateWithLifecycle(initialValue = CalibrationMode.SIMPLE)
val totalSteps = mode.captureCount
val existingCalibration by container.settings.surfaceCalibration
.collectAsStateWithLifecycle(initialValue = SurfaceCalibration.NONE)
val isCalibrated = existingCalibration != SurfaceCalibration.NONE
var stage by remember { mutableStateOf(Stage.OVERVIEW) }
var phase by remember { mutableStateOf(CapturePhase.READY) }
var captures by remember { mutableStateOf<List<CapturedSurfaceSample>>(emptyList()) }
var capture by remember { mutableStateOf<StableSurfaceCapture?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var justSaved by remember { mutableStateOf(false) }
val rawReadingFlow = remember(sensorSource) {
val pitchEma = Ema(0.15)
val rollEma = Ema(0.15)
val tiltEma = Ema(0.15)
val settling = SettlingDetector()
var lastNanos: Long? = null
sensorSource.gravity.map { raw ->
val dtSeconds = lastNanos?.let { (raw.timestampNanos - it) / 1e9 } ?: 0.0
lastNanos = raw.timestampNanos
val pitch = pitchEma.update(OrientationMath.surfacePitchDegrees(raw), dtSeconds)
val roll = rollEma.update(OrientationMath.surfaceRollDegrees(raw), dtSeconds)
val tilt = tiltEma.update(OrientationMath.surfaceTiltMagnitudeDegrees(raw), dtSeconds)
val settlingResult = settling.update(pitch, roll, raw.timestampNanos)
SurfaceCalibrationSample(pitch, roll, tilt, raw.timestampNanos, settlingResult.isSettling)
}
}
val reading by rawReadingFlow.collectAsStateWithLifecycle(initialValue = null)
// Fully synchronous body: persistence goes to `scope`, never awaited here. Writing a
// key (capture/phase/stage) and then suspending inside would let Compose cancel the
// effect mid-write and strand the transition.
LaunchedEffect(reading, stage, phase, capture) {
if (stage != Stage.CAPTURING || phase != CapturePhase.RUNNING) return@LaunchedEffect
val activeCapture = capture ?: return@LaunchedEffect
val current = reading ?: return@LaunchedEffect
val captured = activeCapture.add(current) ?: return@LaunchedEffect
val newCaptures = captures + captured
capture = null
if (newCaptures.size < totalSteps) {
captures = newCaptures
phase = CapturePhase.READY
return@LaunchedEffect
}
val calibration = TwoSampleCalibration.deriveSurfaceFromSamples(newCaptures)
val consistent = SurfaceCalibrationValidation.isRotationConsistent(newCaptures)
if (!SurfaceCalibrationValidation.isAcceptable(calibration) || !consistent) {
captures = emptyList()
error = "Those positions didn't agree. Keep the phone on the same spot, turn it in place, and don't flip it over."
phase = CapturePhase.ERROR
} else {
captures = emptyList()
phase = CapturePhase.READY
stage = Stage.OVERVIEW
justSaved = true
// App-owned scope: the write must survive an immediate Done/back.
container.applicationScope.launch { container.settings.setSurfaceCalibration(calibration) }
}
}
fun startCalibrating() {
captures = emptyList()
error = null
justSaved = false
phase = CapturePhase.READY
stage = Stage.CAPTURING
}
fun cancelCalibrating() {
// Saved calibration is never touched mid-flow, so this simply restores it.
captures = emptyList()
capture = null
error = null
phase = CapturePhase.READY
stage = Stage.OVERVIEW
}
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("SURFACE CALIBRATION", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
if (stage == Stage.CAPTURING) {
Text(
text = if (mode == CalibrationMode.THOROUGH) "DETAILED" else "STANDARD",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.Amber,
)
}
}
if (stage == Stage.OVERVIEW) {
OverviewContent(
modifier = Modifier.weight(1f).fillMaxWidth(),
isCalibrated = isCalibrated,
calibration = existingCalibration,
justSaved = justSaved,
mode = mode,
onModeChange = { container.applicationScope.launch { container.settings.setCalibrationMode(it) } },
)
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = ::startCalibrating, modifier = Modifier.fillMaxWidth()) {
Text(if (isCalibrated) "Recalibrate" else "Calibrate now")
}
if (isCalibrated) {
CalibrationOutlinedButton(
text = "Reset to phone defaults",
onClick = {
justSaved = false
container.applicationScope.launch { container.settings.clearSurfaceCalibration() }
},
modifier = Modifier.fillMaxWidth(),
)
}
CalibrationOutlinedButton(text = "Done", onClick = onBack, modifier = Modifier.fillMaxWidth())
}
} else {
CapturingContent(
modifier = Modifier.weight(1f).fillMaxWidth(),
phase = phase,
stepIndex = captures.size,
totalSteps = totalSteps,
mode = mode,
error = error,
tiltText = reading?.let { formatDegrees(it.tiltDegrees) } ?: "",
status = captureStatus(reading?.isSettling, reading?.tiltDegrees, reading != null),
)
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
when (phase) {
CapturePhase.READY -> {
val canStart = reading?.let {
!it.isSettling && it.tiltDegrees <= StableSurfaceCapture.MAXIMUM_CALIBRATION_TILT_DEGREES
} ?: false
Button(
onClick = {
capture = StableSurfaceCapture()
phase = CapturePhase.RUNNING
},
enabled = canStart,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (captures.isEmpty()) "Capture first position" else "Capture position ${captures.size + 1}")
}
}
CapturePhase.RUNNING -> Text(
text = "Hold still — capturing 1.5 seconds…",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = LevelColors.TextDim,
)
CapturePhase.ERROR -> Button(
onClick = {
captures = emptyList()
error = null
phase = CapturePhase.READY
},
modifier = Modifier.fillMaxWidth(),
) { Text("Start over") }
}
CalibrationOutlinedButton(text = "Cancel", onClick = ::cancelCalibrating, modifier = Modifier.fillMaxWidth())
}
}
}
}
@Composable
private fun OverviewContent(
modifier: Modifier,
isCalibrated: Boolean,
calibration: SurfaceCalibration,
justSaved: Boolean,
mode: CalibrationMode,
onModeChange: (CalibrationMode) -> Unit,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = if (justSaved) "CALIBRATION SAVED" else "CURRENT CORRECTION",
style = MaterialTheme.typography.labelSmall,
// Lime marks the confirmed/active state: just-saved, or an active calibration.
color = when {
justSaved -> LevelColors.LimeLockText
isCalibrated -> LevelColors.LimeLock
else -> LevelColors.TextDim
},
)
Spacer(Modifier.height(10.dp))
if (isCalibrated) {
Text(
"Pitch ${signedDegrees(calibration.pitchBiasDegrees)}",
style = MaterialTheme.typography.headlineMedium,
color = LevelColors.TextPrimary,
)
Text(
"Roll ${signedDegrees(calibration.rollBiasDegrees)}",
style = MaterialTheme.typography.headlineMedium,
color = LevelColors.TextPrimary,
)
} else {
Text(
"Using phone defaults",
style = MaterialTheme.typography.headlineMedium,
color = LevelColors.TextPrimary,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(10.dp))
Text(
text = "Calibration corrects your phone-and-case setup. It does not make a surface level.",
style = MaterialTheme.typography.bodyMedium,
color = LevelColors.TextDim,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(28.dp))
Text("METHOD", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
Spacer(Modifier.height(8.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
CalibrationMode.entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = mode == entry,
onClick = { onModeChange(entry) },
shape = SegmentedButtonDefaults.itemShape(index, CalibrationMode.entries.size),
) {
Text(if (entry == CalibrationMode.SIMPLE) "Standard" else "Detailed")
}
}
}
Text(
text = if (mode == CalibrationMode.THOROUGH) {
"Four positions, a quarter-turn apart. Averages more sensor noise and cross-checks the turn."
} else {
"Two positions, a half-turn apart. Quick and dependable."
},
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp),
)
}
}
@Composable
private fun CapturingContent(
modifier: Modifier,
phase: CapturePhase,
stepIndex: Int,
totalSteps: Int,
mode: CalibrationMode,
error: String?,
tiltText: String,
status: String,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
if (phase != CapturePhase.ERROR) {
Text(
"POSITION ${stepIndex + 1} OF $totalSteps",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
)
Spacer(Modifier.height(14.dp))
}
Text(
text = when {
phase == CapturePhase.ERROR -> error ?: "Let's try that again"
stepIndex == 0 -> "Lay the phone screen-up on a firm, roughly level surface. Keep it still."
mode == CalibrationMode.SIMPLE ->
"Spin the phone a half-turn (180°) in the same spot. Don't lift or flip it over."
else ->
"Spin the phone a quarter-turn (90°) the same direction, same spot. Don't lift or flip it over."
},
style = MaterialTheme.typography.headlineMedium,
color = if (phase == CapturePhase.ERROR) LevelColors.Amber else LevelColors.TextPrimary,
textAlign = TextAlign.Center,
)
if (phase != CapturePhase.ERROR) {
Spacer(Modifier.height(32.dp))
Text(tiltText, style = MaterialTheme.typography.displaySmall, color = LevelColors.TextPrimary)
Text(status, style = MaterialTheme.typography.bodyLarge, color = LevelColors.Amber, textAlign = TextAlign.Center)
}
}
}
private fun captureStatus(isSettling: Boolean?, tiltDegrees: Double?, hasReading: Boolean): String = when {
!hasReading -> "Waiting for a sensor reading"
isSettling == true -> "Settling — keep the phone still"
tiltDegrees != null && tiltDegrees > StableSurfaceCapture.MAXIMUM_CALIBRATION_TILT_DEGREES ->
"Find a flatter surface (under 5°)"
else -> "Ready to capture"
}
/** Outlined buttons default to a near-invisible outline on graphite; give them a real border. */
@Composable
private fun CalibrationOutlinedButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
OutlinedButton(
onClick = onClick,
modifier = modifier,
border = BorderStroke(1.5.dp, LevelColors.Amber.copy(alpha = .65f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = LevelColors.Amber),
) { Text(text) }
}
private fun signedDegrees(value: Double): String {
val rounded = Math.round(value * 10.0) / 10.0
return when {
rounded == 0.0 -> "0.0°" // never render a signed negative zero
rounded > 0.0 -> String.format(Locale.US, "+%.1f°", rounded)
else -> String.format(Locale.US, "%.1f°", rounded)
}
}
@@ -0,0 +1,55 @@
package com.onthelevel.feature.ruler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.onthelevel.AppContainer
import com.onthelevel.core.design.LevelColors
/**
* Scaffold Screen Ruler: Pro preview only (BRIEF.md §Tools — calm, intentional
* paywall; never an interruption in the free level/angle flow).
* TODO(pro): guided calibration (credit-card 85.60 mm + conventional ruler),
* scale stored per display identity, recalibration prompt on material display change.
*/
@Composable
fun RulerScreen(container: AppContainer) {
val entitlement by container.entitlement.entitlement.collectAsStateWithLifecycle()
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("SCREEN RULER", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
Text(
text = if (entitlement.isPro) {
"Ruler coming in the feature build."
} else {
"The calibrated screen ruler is part of On the Level Pro."
},
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp),
)
Text(
text = "For short, rough measurements only — not a substitute for a tape measure.",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp),
)
}
}
@@ -0,0 +1,152 @@
package com.onthelevel.feature.tools
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.onthelevel.AppContainer
import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.settings.MeasurementUnits
import kotlinx.coroutines.launch
/**
* App settings, reached from the Level header gear. Calibration is a plain doorway
* here (its own screen owns status and controls); the measurement preferences live
* directly on this list. Done is pinned to the bottom so it stays put as the list grows.
*/
@Composable
fun SettingsScreen(
container: AppContainer,
onOpenSurfaceCalibration: () -> Unit,
onBack: () -> Unit,
) {
// A preferences list should time out normally — no KeepScreenOn here (calibration
// and the live tool screens keep the screen awake themselves).
val units by container.settings.measurementUnits
.collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC)
val reducedMotion by container.settings.reducedMotion
.collectAsStateWithLifecycle(initialValue = false)
val hapticsEnabled by container.settings.hapticsEnabled
.collectAsStateWithLifecycle(initialValue = true)
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
Column(
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text("SETTINGS", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
SettingsSection("Calibration") {
Button(
onClick = onOpenSurfaceCalibration,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = LevelColors.ReadoutPanel,
contentColor = LevelColors.Amber,
),
) { Text("Calibration Utility") }
}
SettingsSection("Units") {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
MeasurementUnits.entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = units == entry,
onClick = { container.applicationScope.launch { container.settings.setMeasurementUnits(entry) } },
shape = SegmentedButtonDefaults.itemShape(index, MeasurementUnits.entries.size),
) {
Text(if (entry == MeasurementUnits.METRIC) "Metric" else "Imperial")
}
}
}
}
SettingsSection("Feedback & motion") {
ToggleRow(
label = "Haptic feedback",
description = "A gentle buzz when the level locks.",
checked = hapticsEnabled,
onCheckedChange = { container.applicationScope.launch { container.settings.setHapticsEnabled(it) } },
)
Spacer(Modifier.height(12.dp))
ToggleRow(
label = "Reduce motion",
description = "Snap the bubble instead of springing it.",
checked = reducedMotion,
onCheckedChange = { container.applicationScope.launch { container.settings.setReducedMotion(it) } },
)
}
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onBack,
modifier = Modifier.fillMaxWidth(),
border = BorderStroke(1.5.dp, LevelColors.Amber.copy(alpha = .65f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = LevelColors.Amber),
) { Text("Done") }
}
}
@Composable
private fun SettingsSection(title: String, content: @Composable () -> Unit) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
color = LevelColors.TextPrimary,
modifier = Modifier.padding(bottom = 10.dp),
)
content()
}
}
@Composable
private fun ToggleRow(
label: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(label, style = MaterialTheme.typography.bodyLarge, color = LevelColors.TextPrimary)
Text(description, style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = LevelColors.Graphite,
checkedTrackColor = LevelColors.Amber,
),
)
}
}
@@ -0,0 +1,77 @@
package com.onthelevel.feature.tools
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.onthelevel.core.design.LevelColors
@Composable
fun ToolsScreen(onOpenRuler: () -> Unit, onOpenSettings: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text("TOOLS", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
ToolCard(
title = "Screen Ruler",
subtitle = "Rough on-screen measuring",
badge = "PRO",
onClick = onOpenRuler,
)
ToolCard(
title = "Settings",
subtitle = "Calibration, units, feedback, and motion",
onClick = onOpenSettings,
)
}
}
@Composable
private fun ToolCard(
title: String,
subtitle: String,
badge: String? = null,
onClick: () -> Unit,
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
color = LevelColors.Panel,
contentColor = LevelColors.TextPrimary,
shape = MaterialTheme.shapes.large,
) {
Column(modifier = Modifier.padding(18.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(title, style = MaterialTheme.typography.headlineMedium)
if (badge != null) {
Text(
text = " $badge",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.Amber,
)
}
}
Text(
text = subtitle,
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Placeholder launcher art: a cross-vial hairline with the amber bubble at center. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M30,54 L78,54"
android:strokeColor="#33FFFFFF"
android:strokeWidth="1.5" />
<path
android:pathData="M54,30 L54,78"
android:strokeColor="#33FFFFFF"
android:strokeWidth="1.5" />
<path
android:pathData="M54,54 m-16,0 a16,16 0 1,1 32,0 a16,16 0 1,1 -32,0"
android:strokeColor="#8CCBEF5C"
android:strokeWidth="1.5" />
<path
android:pathData="M54,54 m-9,0 a9,9 0 1,1 18,0 a9,9 0 1,1 -18,0"
android:fillColor="#FFC44E" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/graphite" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="graphite">#FF0A0A0B</color>
<color name="amber">#FFFFC44E</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">On the Level</string>
</resources>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Dark-only instrument look; Compose owns all real theming (core/design). -->
<style name="Theme.OnTheLevel" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/graphite</item>
</style>
</resources>
@@ -0,0 +1,110 @@
package com.onthelevel.core.audio
import com.onthelevel.core.sensors.SurfacePresentation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SurfaceTickPolicyTest {
private fun input(
error: Double = 2.0,
now: Long = 0,
enabled: Boolean = true,
foreground: Boolean = true,
placementOk: Boolean = true,
presentation: SurfacePresentation? = SurfacePresentation.FACE_UP,
) = SurfaceTickPolicy.Input(
isEnabled = enabled,
isAppForeground = foreground,
placementOk = placementOk,
surfacePresentation = presentation,
errorDegrees = error,
nowMillis = now,
)
// --- Proximity ticking (outside the center zone) ---
@Test
fun `rate hits both anchors and clamps beyond them`() {
val p = SurfaceTickPolicy()
assertEquals(8.0, p.tickRatePerSecond(0.4), 1e-9)
assertEquals(1.5, p.tickRatePerSecond(5.0), 1e-9)
assertEquals(8.0, p.tickRatePerSecond(0.1), 1e-9)
assertEquals(1.5, p.tickRatePerSecond(12.0), 1e-9)
}
@Test
fun `rate is monotonic - closer is always faster`() {
val p = SurfaceTickPolicy()
val rates = listOf(0.4, 1.0, 2.0, 3.0, 4.0, 5.0).map { p.tickRatePerSecond(it) }
for (i in 1 until rates.size) assertTrue(rates[i] < rates[i - 1])
}
@Test
fun `silent and reset while disabled, backgrounded, or off-surface`() {
val p = SurfaceTickPolicy()
assertNull(p.update(input(enabled = false)))
assertNull(p.update(input(foreground = false)))
assertNull(p.update(input(placementOk = false)))
assertNull(p.update(input(presentation = SurfacePresentation.NEAR_VERTICAL)))
}
@Test
fun `ticks fire on the current interval and react immediately to changing tilt`() {
val p = SurfaceTickPolicy()
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 5.0, now = 0))) // first tick, anchor 0
// Drop toward center (still outside the 0.2 zone): next tick uses the FAST interval.
assertNull(p.update(input(error = 0.4, now = 124)))
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 0.4, now = 125)))
}
@Test
fun `a long gap yields one tick, not a catch-up burst`() {
val p = SurfaceTickPolicy()
p.update(input(error = 2.0, now = 0))
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 2.0, now = 10_000)))
assertNull(p.update(input(error = 2.0, now = 10_001)))
}
// --- Bullseye alignment (inside the center zone) ---
@Test
fun `entering the zone announces immediately - even on a fast pass, no dwell`() {
val p = SurfaceTickPolicy()
p.update(input(error = 3.0, now = 0)) // ticking, outside
// One frame later it's already inside (a fast sweep): aligned right away.
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.1, now = 20)))
}
@Test
fun `stays aligned every frame while inside, ticks suppressed`() {
val p = SurfaceTickPolicy()
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.15, now = 0)))
for (t in 20..2_000 step 20) {
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.15, now = t.toLong())))
}
}
@Test
fun `spatial hysteresis - holds aligned to 0_35, resumes ticks past it`() {
val p = SurfaceTickPolicy()
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.1, now = 0)))
// 0.3 is past the 0.2 enter but below the 0.35 exit: still aligned.
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.3, now = 20)))
// Beyond 0.35: exit, and ticks resume immediately (anchor cleared on exit).
assertEquals(SurfaceTickPolicy.Cue.TICK, p.update(input(error = 0.4, now = 40)))
}
@Test
fun `re-crossing rearms only after leaving the exit band, then announces again`() {
val p = SurfaceTickPolicy()
p.update(input(error = 0.1, now = 0)) // aligned
p.update(input(error = 0.25, now = 20)) // 0.2 < 0.25 < 0.35: still aligned (not rearmed)
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.25, now = 40)))
// Swing out past 0.35, then back to center: announces again.
p.update(input(error = 0.5, now = 60)) // exits
assertEquals(SurfaceTickPolicy.Cue.ALIGNED, p.update(input(error = 0.1, now = 80)))
}
}
@@ -0,0 +1,96 @@
package com.onthelevel.core.audio
import com.onthelevel.core.audio.VoiceGuidancePolicy.Direction
import com.onthelevel.core.audio.VoiceGuidancePolicy.Phrase
import com.onthelevel.core.sensors.SurfacePresentation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class VoiceGuidancePolicyTest {
private fun input(
pitch: Double = 0.0,
roll: Double = 0.0,
locked: Boolean = false,
settling: Boolean = false,
enabled: Boolean = true,
foreground: Boolean = true,
placementOk: Boolean = true,
presentation: SurfacePresentation? = SurfacePresentation.FACE_UP,
) = VoiceGuidancePolicy.Input(
isEnabled = enabled,
isAppForeground = foreground,
placementOk = placementOk,
presentation = presentation,
isSettling = settling,
pitchDegrees = pitch,
rollDegrees = roll,
isLocked = locked,
nowMillis = 0,
)
/** Move (silent), then come to rest — the frame the board settles on. */
private fun VoiceGuidancePolicy.moveThenSettle(pitch: Double, roll: Double): Phrase? {
update(input(pitch = pitch, roll = roll, settling = true))
return update(input(pitch = pitch, roll = roll, settling = false))
}
@Test
fun `first settled reading gives one instruction, low side of the dominant axis`() {
val p = VoiceGuidancePolicy()
assertEquals(Phrase.Raise(Direction.RAISE_LEFT, fine = false), p.update(input(roll = 3.0)))
}
@Test
fun `same direction after another settle stays silent`() {
val p = VoiceGuidancePolicy()
assertEquals(Phrase.Raise(Direction.RAISE_LEFT, fine = false), p.update(input(roll = 3.0)))
// Adjusted, still needs the same coarse "raise the left": say nothing.
assertNull(p.moveThenSettle(pitch = 0.0, roll = 2.8))
}
@Test
fun `direction change speaks once`() {
val p = VoiceGuidancePolicy()
p.update(input(roll = 3.0)) // raise left
// Overcorrected past level (left now high): speak the new direction, once.
assertEquals(Phrase.Raise(Direction.RAISE_RIGHT, fine = false), p.moveThenSettle(pitch = 0.0, roll = -3.0))
// Settling again with the same direction: silent.
assertNull(p.moveThenSettle(pitch = 0.0, roll = -2.9))
}
@Test
fun `crossing coarse to fine speaks once`() {
val p = VoiceGuidancePolicy()
p.update(input(roll = 3.0)) // coarse "raise the left"
// Now close, same direction: announce the finer correction, once.
assertEquals(Phrase.Raise(Direction.RAISE_LEFT, fine = true), p.moveThenSettle(pitch = 0.0, roll = 0.6))
// Still fine, same direction: silent.
assertNull(p.moveThenSettle(pitch = 0.0, roll = 0.5))
}
@Test
fun `lock speaks once`() {
val p = VoiceGuidancePolicy()
p.update(input(roll = 3.0))
assertEquals(Phrase.Level, p.update(input(locked = true)))
assertNull(p.update(input(locked = true)))
}
@Test
fun `no periodic repeat while sitting settled and off-level`() {
val p = VoiceGuidancePolicy()
assertEquals(Phrase.Raise(Direction.RAISE_LEFT, fine = false), p.update(input(roll = 3.0)))
// Many more settled frames, unchanged: silence, no timer-driven repeat.
repeat(50) { assertNull(p.update(input(roll = 3.0))) }
}
@Test
fun `silent when disabled, backgrounded, or off-surface`() {
assertNull(VoiceGuidancePolicy().update(input(roll = 3.0, enabled = false)))
assertNull(VoiceGuidancePolicy().update(input(roll = 3.0, foreground = false)))
assertNull(VoiceGuidancePolicy().update(input(roll = 3.0, placementOk = false)))
assertNull(VoiceGuidancePolicy().update(input(roll = 3.0, presentation = SurfacePresentation.NEAR_VERTICAL)))
}
}
@@ -0,0 +1,30 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertEquals
import org.junit.Test
class DisplayDeadbandTest {
@Test
fun `quantizes to tenths of a degree`() {
val band = DisplayDeadband()
assertEquals(0.1, band.update(0.14), 1e-9)
}
@Test
fun `noise on a boundary does not flicker the readout`() {
val band = DisplayDeadband()
val first = band.update(0.05) // boundary between 0.0 and 0.1
// Jitter of ±0.02° around the boundary must hold the displayed value.
assertEquals(first, band.update(0.06), 1e-9)
assertEquals(first, band.update(0.04), 1e-9)
assertEquals(first, band.update(0.05), 1e-9)
}
@Test
fun `a real change moves the readout`() {
val band = DisplayDeadband()
band.update(0.0)
assertEquals(0.2, band.update(0.2), 1e-9)
}
}
@@ -0,0 +1,42 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class EmaTest {
@Test
fun `first sample passes through unfiltered`() {
assertEquals(5.0, Ema(0.15).update(5.0, 0.0), 1e-9)
}
@Test
fun `converges toward a constant input`() {
val ema = Ema(0.15)
ema.update(0.0, 0.0)
var value = 0.0
repeat(100) { value = ema.update(10.0, 0.02) }
assertEquals(10.0, value, 1e-3)
}
@Test
fun `one time constant covers ~63 percent of a step`() {
val ema = Ema(1.0)
ema.update(0.0, 0.0)
val afterOneTau = ema.update(1.0, 1.0)
assertEquals(0.632, afterOneTau, 0.01)
}
@Test
fun `same elapsed time yields same smoothing regardless of sample rate`() {
val fast = Ema(0.5)
val slow = Ema(0.5)
fast.update(0.0, 0.0)
slow.update(0.0, 0.0)
var fastValue = 0.0
repeat(10) { fastValue = fast.update(1.0, 0.01) } // 100 ms in 10 steps
val slowValue = slow.update(1.0, 0.1) // 100 ms in 1 step
assertTrue(kotlin.math.abs(fastValue - slowValue) < 0.02)
}
}
@@ -0,0 +1,113 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class LockDetectorTest {
private fun detector() = LockDetector(
enterThresholdDegrees = 0.2,
exitThresholdDegrees = 0.35,
dwellMillis = 175,
settledDwellMillis = 400,
slowRateThresholdDegPerSec = 0.3,
feedbackDebounceMillis = 3_000,
)
private val slow = 0.1 // °/s, below the slow-rate threshold
private val fast = 2.0 // °/s, a real center crossing
@Test
fun `easing slowly into center locks after the short dwell`() {
val d = detector()
assertFalse(d.update(0.1, slow, 0).isLocked)
assertFalse(d.update(0.1, slow, 174).isLocked)
val r = d.update(0.1, slow, 175)
assertTrue(r.isLocked)
assertTrue(r.fireFeedback)
}
@Test
fun `flying across center never locks, however long it stays in the zone`() {
val d = detector()
// In the zone the whole time, but moving fast: the dwell never accumulates.
for (t in 0..2_000 step 20) assertFalse(d.update(0.05, fast, t.toLong()).isLocked)
}
@Test
fun `intermediate speed does not accumulate lock time`() {
val d = detector()
val intermediate = 0.5 // between slow (0.3) and a crossing
for (t in 0..2_000 step 20) assertFalse(d.update(0.1, intermediate, t.toLong()).isLocked)
}
@Test
fun `slowing to a stop inside the zone starts a fresh confirmation dwell`() {
val d = detector()
// In the zone but moving fast until t=300: no accumulation yet.
d.update(0.1, fast, 0)
d.update(0.1, fast, 300)
// Now slow down: dwell starts fresh here, so it must NOT be locked at +100ms...
assertFalse(d.update(0.1, slow, 320).isLocked)
assertFalse(d.update(0.1, slow, 420).isLocked)
// ...but locks 175ms after slowing.
assertTrue(d.update(0.1, slow, 495).isLocked)
}
@Test
fun `leaving the zone before dwell completes resets the timer`() {
val d = detector()
d.update(0.1, slow, 0)
d.update(0.5, slow, 100) // bounced out of the zone
d.update(0.1, slow, 150) // back in — dwell restarts
assertFalse(d.update(0.1, slow, 300).isLocked) // only 150ms since re-entry
assertTrue(d.update(0.1, slow, 325).isLocked)
}
@Test
fun `hysteresis holds the lock between enter and exit thresholds`() {
val d = detector()
d.update(0.1, slow, 0)
assertTrue(d.update(0.1, slow, 200).isLocked)
// 0.3° is above enter (0.2) but below exit (0.35): still locked (velocity irrelevant once locked).
assertTrue(d.update(0.3, fast, 300).isLocked)
// 0.4° exceeds the exit threshold: unlocked.
assertFalse(d.update(0.4, slow, 400).isLocked)
}
@Test
fun `feedback fires once per lock and respects the debounce window`() {
val d = detector()
d.update(0.1, slow, 0)
assertTrue(d.update(0.1, slow, 200).fireFeedback)
assertFalse(d.update(0.1, slow, 300).fireFeedback) // still locked, no re-fire
// Rock out and back quickly: re-lock at ~1500ms is inside the 3s debounce.
d.update(0.5, fast, 900)
d.update(0.1, slow, 1000)
val relock = d.update(0.1, slow, 1500)
assertTrue(relock.isLocked)
assertFalse(relock.fireFeedback)
// A re-lock after the debounce window fires again.
d.update(0.5, fast, 4000)
d.update(0.1, slow, 4100)
assertTrue(d.update(0.1, slow, 4400).fireFeedback)
}
@Test
fun `without a velocity signal it uses the fixed settled dwell (Edge)`() {
val d = detector()
assertFalse(d.update(0.1, null, 0).isLocked)
assertFalse(d.update(0.1, null, 399).isLocked) // 175 has passed, but the no-rate path needs 400
assertTrue(d.update(0.1, null, 400).isLocked)
}
@Test
fun `negative readings lock on magnitude`() {
val d = detector()
d.update(-0.1, slow, 0)
assertTrue(d.update(-0.1, slow, 200).isLocked)
}
}
@@ -0,0 +1,119 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.cos
import kotlin.math.sin
class OrientationMathTest {
private val g = 9.80665
private fun flat() = GravitySample(0.0, 0.0, g, 0)
/** Device tilted so the top edge is raised by [degrees] (rotation about the X axis). */
private fun pitched(degrees: Double): GravitySample {
val r = Math.toRadians(degrees)
return GravitySample(0.0, g * sin(r), g * cos(r), 0)
}
/** Device tilted so the right edge is raised by [degrees] (rotation about the Y axis). */
private fun rolled(degrees: Double): GravitySample {
val r = Math.toRadians(degrees)
return GravitySample(g * sin(r), 0.0, g * cos(r), 0)
}
/** Device standing on its long edge, tipped in the wall plane by [degrees]. */
private fun onEdge(tipDegrees: Double): GravitySample {
val r = Math.toRadians(tipDegrees)
return GravitySample(g * cos(r), g * sin(r), 0.0, 0)
}
@Test
fun `flat device reads zero everywhere in surface mode`() {
assertEquals(0.0, OrientationMath.surfaceTiltMagnitudeDegrees(flat()), 1e-9)
assertEquals(0.0, OrientationMath.surfacePitchDegrees(flat()), 1e-9)
assertEquals(0.0, OrientationMath.surfaceRollDegrees(flat()), 1e-9)
}
@Test
fun `pitch recovers the applied rotation with correct sign`() {
assertEquals(1.0, OrientationMath.surfacePitchDegrees(pitched(1.0)), 1e-9)
assertEquals(-2.5, OrientationMath.surfacePitchDegrees(pitched(-2.5)), 1e-9)
assertEquals(0.0, OrientationMath.surfaceRollDegrees(pitched(1.0)), 1e-9)
}
@Test
fun `roll recovers the applied rotation with correct sign`() {
assertEquals(3.0, OrientationMath.surfaceRollDegrees(rolled(3.0)), 1e-9)
assertEquals(0.0, OrientationMath.surfacePitchDegrees(rolled(3.0)), 1e-9)
}
@Test
fun `tilt magnitude matches single-axis rotations`() {
assertEquals(1.0, OrientationMath.surfaceTiltMagnitudeDegrees(pitched(1.0)), 1e-9)
assertEquals(4.0, OrientationMath.surfaceTiltMagnitudeDegrees(rolled(4.0)), 1e-9)
}
@Test
fun `surface magnitude remains authoritative through screen down`() {
assertEquals(120.0, OrientationMath.surfaceTiltMagnitudeDegrees(pitched(120.0)), 1e-9)
}
@Test
fun `edge mode reads zero when the long edge is horizontal`() {
assertEquals(0.0, OrientationMath.edgeLevelDegrees(onEdge(0.0)), 1e-9)
assertEquals(0.0, OrientationMath.edgePlumbLeanDegrees(onEdge(0.0)), 1e-9)
}
@Test
fun `edge mode recovers in-plane tip with correct sign`() {
assertEquals(1.5, OrientationMath.edgeLevelDegrees(onEdge(1.5)), 1e-9)
assertEquals(-2.0, OrientationMath.edgeLevelDegrees(onEdge(-2.0)), 1e-9)
}
@Test
fun `percent grade is tan-based and capped to infinity near vertical`() {
assertEquals(0.0, OrientationMath.percentGrade(0.0), 1e-9)
assertEquals(100.0, OrientationMath.percentGrade(45.0), 1e-6)
assertTrue(OrientationMath.percentGrade(89.6).isInfinite())
assertTrue(OrientationMath.percentGrade(-89.6) == Double.NEGATIVE_INFINITY)
}
@Test
fun `angle between identical directions is zero`() {
assertEquals(0.0, OrientationMath.angleBetweenDegrees(pitched(10.0), pitched(10.0)), 1e-9)
}
@Test
fun `angle between same-axis tilts is their difference`() {
assertEquals(15.0, OrientationMath.angleBetweenDegrees(pitched(10.0), pitched(25.0)), 1e-9)
}
@Test
fun `angle between cross-axis tilts is directional, not a magnitude difference`() {
// Zeroed at 10° pitch, moved to 10° roll: magnitudes are equal (difference 0),
// but the device genuinely rotated acos(cos²10°) ≈ 14.1°.
val expected = Math.toDegrees(
kotlin.math.acos(cos(Math.toRadians(10.0)) * cos(Math.toRadians(10.0))),
)
assertEquals(expected, OrientationMath.angleBetweenDegrees(pitched(10.0), rolled(10.0)), 1e-9)
assertTrue(expected > 14.0)
}
@Test
fun `placement validity matches the selected mode's geometry`() {
assertTrue(OrientationMath.isPlacementValid(flat(), LevelMode.SURFACE))
assertTrue(!OrientationMath.isPlacementValid(flat(), LevelMode.EDGE))
assertTrue(OrientationMath.isPlacementValid(onEdge(0.0), LevelMode.EDGE))
assertTrue(!OrientationMath.isPlacementValid(onEdge(0.0), LevelMode.SURFACE))
}
@Test
fun `zero vector does not produce NaN`() {
val zero = GravitySample(0.0, 0.0, 0.0, 0)
assertEquals(0.0, OrientationMath.surfaceTiltMagnitudeDegrees(zero), 1e-9)
assertEquals(0.0, OrientationMath.edgeLevelDegrees(zero), 1e-9)
}
}
@@ -0,0 +1,19 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertEquals
import org.junit.Test
class RiseRunTest {
@Test
fun `zero tilt has no rise over run`() {
assertEquals(0.0, RiseRun.millimetersPerMeter(0.0), 1e-9)
assertEquals(0.0, RiseRun.inchesPerFoot(0.0), 1e-9)
}
@Test
fun `forty five degrees converts to the conventional construction slopes`() {
assertEquals(1_000.0, RiseRun.millimetersPerMeter(45.0), 1e-9)
assertEquals(12.0, RiseRun.inchesPerFoot(45.0), 1e-9)
}
}
@@ -0,0 +1,64 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertEquals
import org.junit.Test
import kotlin.math.cos
import kotlin.math.sin
/**
* Pins the GravitySample contract: every sensor path yields device-frame WORLD-UP,
* with flat-screen-up = (0, 0, +g).
*
* Per the Android sensor docs (TYPE_ACCELEROMETER), a device stationary flat on a
* table reads +9.81 on Z: "the acceleration of the device (0 m/s²) minus the force
* of gravity (9.81 m/s²)". TYPE_GRAVITY shares that convention (it is the isolated
* gravity component of the same signal), so both pass-through paths already match.
* These tests prove the rotation-vector conversion produces the identical vector,
* so NO sign adjustment is applied to any path.
*/
class SensorContractTest {
private val g = RotationVectorMath.STANDARD_GRAVITY
/** Device→world rotation matrix for a device pitched up by [degrees] about X. */
private fun deviceToWorldPitch(degrees: Double): FloatArray {
val r = Math.toRadians(degrees)
return floatArrayOf(
1f, 0f, 0f,
0f, cos(r).toFloat(), (-sin(r)).toFloat(),
0f, sin(r).toFloat(), cos(r).toFloat(),
)
}
@Test
fun `flat device - rotation path matches the documented gravity sensor output`() {
val identity = floatArrayOf(1f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f)
val (x, y, z) = RotationVectorMath.worldUpDeviceFrame(identity)
// Documented TYPE_GRAVITY / TYPE_ACCELEROMETER flat output: (0, 0, +9.81).
assertEquals(0.0, x * g, 1e-6)
assertEquals(0.0, y * g, 1e-6)
assertEquals(g, z * g, 1e-6)
}
@Test
fun `pitched device - rotation path matches the analytic gravity vector`() {
val theta = 10.0
val (x, y, z) = RotationVectorMath.worldUpDeviceFrame(deviceToWorldPitch(theta))
val fromRotation = GravitySample(x * g, y * g, z * g, 0)
// The gravity-sensor path for the same physical orientation:
val r = Math.toRadians(theta)
val fromGravitySensor = GravitySample(0.0, g * sin(r), g * cos(r), 0)
assertEquals(fromGravitySensor.x, fromRotation.x, 1e-6)
assertEquals(fromGravitySensor.y, fromRotation.y, 1e-6)
assertEquals(fromGravitySensor.z, fromRotation.z, 1e-6)
// And both derive the same pitch through the measurement math:
assertEquals(
OrientationMath.surfacePitchDegrees(fromGravitySensor),
OrientationMath.surfacePitchDegrees(fromRotation),
1e-6,
)
}
}
@@ -0,0 +1,43 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SettlingDetectorTest {
@Test
fun `settles only after low movement has held for its dwell time`() {
val detector = SettlingDetector()
assertTrue(detector.update(0.0, 0.0, 0).isSettling)
assertTrue(detector.update(0.2, 0.0, 100_000_000).isSettling) // 2°/s: moving
assertTrue(detector.update(0.2, 0.0, 200_000_000).isSettling)
assertTrue(detector.update(0.2, 0.0, 600_000_000).isSettling)
assertFalse(detector.update(0.2, 0.0, 700_000_000).isSettling)
}
@Test
fun `movement re-enters settling and resets the dwell`() {
val detector = SettlingDetector()
detector.update(0.0, 0.0, 0)
detector.update(0.0, 0.0, 100_000_000)
detector.update(0.0, 0.0, 600_000_000)
assertFalse(detector.update(0.0, 0.0, 700_000_000).isSettling)
assertTrue(detector.update(0.2, 0.0, 800_000_000).isSettling)
assertTrue(detector.update(0.2, 0.0, 1_200_000_000).isSettling)
}
@Test
fun `mid band movement resets the quiet dwell without leaving settling`() {
val detector = SettlingDetector()
detector.update(0.0, 0.0, 0)
detector.update(0.0, 0.0, 100_000_000) // quiet dwell starts
detector.update(0.08, 0.0, 300_000_000) // 0.4°/s: between exit and enter
assertTrue(detector.update(0.08, 0.0, 600_000_000).isSettling)
assertTrue(detector.update(0.08, 0.0, 1_000_000_000).isSettling)
assertFalse(detector.update(0.08, 0.0, 1_100_000_000).isSettling)
}
}
@@ -0,0 +1,42 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class StableSurfaceCaptureTest {
private fun sample(
pitch: Double,
roll: Double,
timestampMillis: Long,
tilt: Double = 1.0,
settling: Boolean = false,
) = SurfaceCalibrationSample(pitch, roll, tilt, timestampMillis * 1_000_000, settling)
@Test
fun `captures the mean of a continuous settled one point five second window`() {
val capture = StableSurfaceCapture()
assertNull(capture.add(sample(0.2, -0.1, 0)))
assertNull(capture.add(sample(0.4, -0.3, 750)))
val result = capture.add(sample(0.6, -0.5, 1_500))
requireNotNull(result)
assertEquals(0.4, result.pitchDegrees, 1e-9)
assertEquals(-0.3, result.rollDegrees, 1e-9)
}
@Test
fun `motion or excessive tilt resets the capture window`() {
val capture = StableSurfaceCapture()
assertNull(capture.add(sample(0.2, 0.0, 0)))
assertNull(capture.add(sample(0.2, 0.0, 750, settling = true)))
assertNull(capture.add(sample(0.2, 0.0, 1_000)))
assertNull(capture.add(sample(0.2, 0.0, 1_500, tilt = 5.1)))
assertNull(capture.add(sample(0.2, 0.0, 1_750)))
assertNull(capture.add(sample(0.2, 0.0, 2_500)))
requireNotNull(capture.add(sample(0.2, 0.0, 3_250)))
}
}
@@ -0,0 +1,55 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.cos
import kotlin.math.sin
class SurfaceCalibrationValidationTest {
@Test
fun `accepts a combined bias at or below three degrees`() {
assertTrue(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(3.0, 0.0)))
assertTrue(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(1.8, 2.4)))
}
@Test
fun `rejects a combined bias above three degrees`() {
assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(3.01, 0.0)))
assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(2.5, 2.5)))
}
// A 5° surface is the worst permitted case (bias magnitude ≤ 5° is allowed to reach
// the mean). biasPitch/Roll are the fixed device bias; the true tilt rotates with
// the phone about the surface normal.
private val biasPitch = 0.3
private val biasRoll = -0.2
private val tilt = 5.0
private fun sampleAt(rotationDegrees: Double) = CapturedSurfaceSample(
pitchDegrees = biasPitch + tilt * cos(Math.toRadians(rotationDegrees)),
rollDegrees = biasRoll + tilt * sin(Math.toRadians(rotationDegrees)),
)
@Test
fun `clean four-point turn is consistent`() {
val clean = listOf(sampleAt(0.0), sampleAt(90.0), sampleAt(180.0), sampleAt(270.0))
assertTrue(SurfaceCalibrationValidation.isRotationConsistent(clean))
}
@Test
fun `sloppy four-point turn is rejected`() {
// Second position over-rotated 10° (100° instead of 90°). The (90,270) pair no
// longer cancels, so the two independent bias estimates diverge past tolerance —
// a malformed rotation the 3° magnitude guard alone cannot catch.
val sloppy = listOf(sampleAt(0.0), sampleAt(100.0), sampleAt(180.0), sampleAt(270.0))
assertFalse(SurfaceCalibrationValidation.isRotationConsistent(sloppy))
}
@Test
fun `two-point set has no cross-check and is always consistent`() {
val twoPoint = listOf(CapturedSurfaceSample(0.8, -0.1), CapturedSurfaceSample(-0.2, 0.5))
assertTrue(SurfaceCalibrationValidation.isRotationConsistent(twoPoint))
}
}
@@ -0,0 +1,29 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SurfaceGuidanceTest {
@Test fun `bubble mapping follows pitch roll and clamps before animation`() {
val guidance = SurfaceGuidance.from(2.0, -3.0)
assertEquals(-0.6, guidance.normalizedX, 1e-9)
assertEquals(-0.4, guidance.normalizedY, 1e-9)
val clamped = SurfaceGuidance.from(-8.0, 9.0)
assertEquals(1.0, clamped.normalizedX, 1e-9)
assertEquals(1.0, clamped.normalizedY, 1e-9)
}
@Test fun `direction uses edges for dominant axes and corners otherwise`() {
assertEquals("High: top edge", SurfaceGuidance.from(2.0, 0.2).highLabel)
assertEquals("High: bottom-left", SurfaceGuidance.from(-2.0, -2.0).highLabel)
assertEquals("High: top-right", SurfaceGuidance.from(2.0, 2.0).highLabel)
assertNull(SurfaceGuidance.from(0.05, -0.05).highLabel)
}
@Test fun `rings share the visual degree scale`() {
assertEquals(0.2, SurfaceGuidance.ringRadiusRatio(1.0), 1e-9)
assertEquals(0.4, SurfaceGuidance.ringRadiusRatio(2.0), 1e-9)
assertEquals(1.0, SurfaceGuidance.ringRadiusRatio(5.0), 1e-9)
}
}
@@ -0,0 +1,28 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertEquals
import org.junit.Test
class SurfacePresentationDetectorTest {
@Test
fun `axis suppression boundary is hysteretic`() {
val detector = SurfacePresentationDetector()
assertEquals(SurfacePresentation.FACE_UP, detector.update(79.9))
assertEquals(SurfacePresentation.NEAR_VERTICAL, detector.update(80.0))
assertEquals(SurfacePresentation.NEAR_VERTICAL, detector.update(79.2))
assertEquals(SurfacePresentation.FACE_UP, detector.update(78.9))
}
@Test
fun `screen down boundary is hysteretic`() {
val detector = SurfacePresentationDetector()
detector.update(80.0)
assertEquals(SurfacePresentation.NEAR_VERTICAL, detector.update(90.0))
assertEquals(SurfacePresentation.SCREEN_DOWN, detector.update(90.1))
assertEquals(SurfacePresentation.SCREEN_DOWN, detector.update(89.2))
assertEquals(SurfacePresentation.NEAR_VERTICAL, detector.update(89.0))
}
}
@@ -0,0 +1,177 @@
package com.onthelevel.core.sensors
import com.onthelevel.core.sensors.Vec3Math.Vec3
import org.junit.Assert.assertEquals
import org.junit.Test
import kotlin.math.acos
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.tan
class TwoSampleCalibrationTest {
private val g = 9.80665
private fun sample(v: Vec3) = GravitySample(v.x, v.y, v.z, 0)
private fun pitched(degrees: Double): GravitySample {
val r = Math.toRadians(degrees)
return GravitySample(0.0, g * sin(r), g * cos(r), 0)
}
@Test
fun `flip cancels true tilt and isolates device bias`() {
// Surface truly tilted 0.5°, device bias +0.3°:
val reading1 = 0.5 + 0.3 // as placed
val reading2 = -0.5 + 0.3 // after 180° rotation about the surface normal
val bias = TwoSampleCalibration.deriveBiasDegrees(reading1, reading2)
assertEquals(0.3, bias, 1e-9)
}
@Test
fun `surface calibration derives both axes independently`() {
val cal = TwoSampleCalibration.deriveSurface(
pitch1 = 0.8, roll1 = -0.1,
pitch2 = -0.2, roll2 = 0.5,
)
assertEquals(0.3, cal.pitchBiasDegrees, 1e-9)
assertEquals(0.2, cal.rollBiasDegrees, 1e-9)
}
@Test
fun `surface correction zeroes a biased level placement - vector space`() {
val cal = SurfaceCalibration(pitchBiasDegrees = 0.4, rollBiasDegrees = -0.3)
// The reference direction: what a biased device measures on a TRULY level surface.
val measuredAtLevel = sample(
Vec3Math.normalize(
Vec3(
tan(Math.toRadians(cal.rollBiasDegrees)),
tan(Math.toRadians(cal.pitchBiasDegrees)),
1.0,
),
).let { Vec3(it.x * g, it.y * g, it.z * g) },
)
val corrected = cal.apply(measuredAtLevel)
assertEquals(0.0, OrientationMath.surfacePitchDegrees(corrected), 1e-9)
assertEquals(0.0, OrientationMath.surfaceRollDegrees(corrected), 1e-9)
assertEquals(0.0, OrientationMath.surfaceTiltMagnitudeDegrees(corrected), 1e-6)
}
@Test
fun `surface correction is exact away from zero - same axis`() {
// Misalignment purely about X by 0.5°: a true pitch of 30° measures as 30.5°.
val cal = SurfaceCalibration(pitchBiasDegrees = 0.5, rollBiasDegrees = 0.0)
val measured = pitched(30.5)
val corrected = cal.apply(measured)
assertEquals(30.0, OrientationMath.surfacePitchDegrees(corrected), 1e-9)
assertEquals(30.0, OrientationMath.surfaceTiltMagnitudeDegrees(corrected), 1e-9)
}
@Test
fun `surface correction is exact away from zero - cross axis`() {
// General misalignment: pitch bias 0.4°, roll bias 0.3°. Build the measured
// sample by applying the INVERSE of the correction rotation to the true
// 30°-pitched gravity, then verify the correction recovers it exactly.
val cal = SurfaceCalibration(pitchBiasDegrees = 0.4, rollBiasDegrees = 0.3)
val reference = Vec3Math.normalize(
Vec3(
tan(Math.toRadians(cal.rollBiasDegrees)),
tan(Math.toRadians(cal.pitchBiasDegrees)),
1.0,
),
)
val axis = Vec3Math.normalize(Vec3Math.cross(reference, Vec3Math.WORLD_UP_FLAT))
val angle = acos(Vec3Math.dot(reference, Vec3Math.WORLD_UP_FLAT).coerceIn(-1.0, 1.0))
val true30 = pitched(30.0)
val measured = sample(Vec3Math.rotate(Vec3(true30.x, true30.y, true30.z), axis, -angle))
val corrected = cal.apply(measured)
assertEquals(30.0, OrientationMath.surfacePitchDegrees(corrected), 1e-9)
assertEquals(0.0, OrientationMath.surfaceRollDegrees(corrected), 1e-9)
assertEquals(30.0, OrientationMath.surfaceTiltMagnitudeDegrees(corrected), 1e-9)
}
@Test
fun `edge correction is exact away from zero and leaves lean untouched`() {
// Misalignment about Z by 0.3° on the positive-X edge; true in-plane tip 10°.
val bias = 0.3
val cal = EdgeCalibration(levelBiasDegrees = bias, calibratedOnPositiveXEdge = true)
fun onEdgeMeasured(tipDegrees: Double): GravitySample {
val r = Math.toRadians(tipDegrees + bias) // Rz misalignment adds directly in-plane
return GravitySample(g * cos(r), g * sin(r), 0.0, 0)
}
assertEquals(
0.0,
OrientationMath.edgeLevelDegrees(cal.apply(onEdgeMeasured(0.0))),
1e-9,
)
assertEquals(
10.0,
OrientationMath.edgeLevelDegrees(cal.apply(onEdgeMeasured(10.0))),
1e-9,
)
// Lean (device Z component) is untouched by the Z-rotation correction.
val leaned = GravitySample(g * 0.99, 0.05, g * 0.1, 0)
assertEquals(
OrientationMath.edgePlumbLeanDegrees(leaned),
OrientationMath.edgePlumbLeanDegrees(cal.apply(leaned)),
1e-9,
)
}
@Test
fun `edge polarity flips the correction direction`() {
val positive = EdgeCalibration(0.3, calibratedOnPositiveXEdge = true)
val negative = EdgeCalibration(0.3, calibratedOnPositiveXEdge = false)
val s = GravitySample(g, 0.1, 0.0, 0)
val correctedPositive = positive.apply(s)
val correctedNegative = negative.apply(s)
// Opposite rotation directions about Z:
assertEquals(
OrientationMath.edgeLevelDegrees(correctedPositive) - OrientationMath.edgeLevelDegrees(s),
-(OrientationMath.edgeLevelDegrees(correctedNegative) - OrientationMath.edgeLevelDegrees(s)),
1e-6,
)
}
@Test
fun `surface and edge calibrations are independent and applied per mode`() {
val surface = TwoSampleCalibration.deriveSurface(1.0, 1.0, 0.0, 0.0)
val edge = EdgeCalibration.NONE
// An edge sample passed through the untouched edge calibration is unchanged,
// regardless of surface calibration state (AUDIT.md finding 3).
val edgeSample = GravitySample(g, 0.12, 0.0, 0)
assertEquals(edgeSample, edge.apply(edgeSample))
assertEquals(0.5, surface.pitchBiasDegrees, 1e-9)
}
@Test
fun `two-sample averaging matches the pairwise derivation`() {
val fromSamples = TwoSampleCalibration.deriveSurfaceFromSamples(
listOf(CapturedSurfaceSample(0.8, -0.1), CapturedSurfaceSample(-0.2, 0.5)),
)
val pairwise = TwoSampleCalibration.deriveSurface(0.8, -0.1, -0.2, 0.5)
assertEquals(pairwise.pitchBiasDegrees, fromSamples.pitchBiasDegrees, 1e-9)
assertEquals(pairwise.rollBiasDegrees, fromSamples.rollBiasDegrees, 1e-9)
}
@Test
fun `four-point averaging recovers bias from a symmetric rotation set`() {
// Bias is fixed in the device frame; the true surface tilt appears as a
// symmetric set summing to zero over 0/90/180/270 (pitch: +t,0,-t,0 ;
// roll: 0,+t,0,-t). The mean must therefore be exactly the bias.
val biasPitch = 0.4
val biasRoll = -0.3
val t = 1.7
val samples = listOf(
CapturedSurfaceSample(biasPitch + t, biasRoll),
CapturedSurfaceSample(biasPitch, biasRoll + t),
CapturedSurfaceSample(biasPitch - t, biasRoll),
CapturedSurfaceSample(biasPitch, biasRoll - t),
)
val cal = TwoSampleCalibration.deriveSurfaceFromSamples(samples)
assertEquals(biasPitch, cal.pitchBiasDegrees, 1e-9)
assertEquals(biasRoll, cal.rollBiasDegrees, 1e-9)
}
}
@@ -0,0 +1,153 @@
package com.onthelevel.feature.level
import com.onthelevel.core.sensors.EdgeCalibration
import com.onthelevel.core.sensors.GravitySample
import com.onthelevel.core.sensors.LevelMode
import com.onthelevel.core.sensors.LockDetector
import com.onthelevel.core.sensors.SurfaceCalibration
import com.onthelevel.core.sensors.SurfacePresentation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.cos
import kotlin.math.sin
/**
* Acceptance tests for lock/readout coherence (Codex review): whenever the pipeline
* reports a lock, the deadbanded readout must stay within the tolerance the locked
* label states (the lock's exit threshold). Also pins placement gating.
*/
class LevelPipelineTest {
private val g = 9.80665
private fun pitched(degrees: Double, tMillis: Long): GravitySample {
val r = Math.toRadians(degrees)
return GravitySample(0.0, g * sin(r), g * cos(r), tMillis * 1_000_000)
}
private fun flat(tMillis: Long) = pitched(0.0, tMillis)
private fun surfacePipeline() = LevelPipeline(
mode = LevelMode.SURFACE,
surfaceCalibration = SurfaceCalibration.NONE,
edgeCalibration = EdgeCalibration.NONE,
lockDetector = LockDetector(),
)
/** Feed [degrees] steadily from t=[fromMillis] to t=[untilMillis] at 20 ms. */
private fun run(
pipeline: LevelPipeline,
degrees: Double,
fromMillis: Long,
untilMillis: Long,
): List<LevelReading> =
(fromMillis..untilMillis step 20).map { pipeline.process(pitched(degrees, it)) }
@Test
fun `steady near-level tilt locks and readout stays within stated tolerance`() {
val pipeline = surfacePipeline()
val readings = run(pipeline, degrees = 0.18, fromMillis = 0, untilMillis = 2_000)
assertTrue("expected a lock after dwell", readings.last().isLocked)
assertEquals(1, readings.count { it.fireFeedback })
readings.filter { it.isLocked }.forEach {
assertTrue(
"locked reading displayed ${it.displayPrimaryDegrees}° above tolerance",
it.displayPrimaryDegrees <= LockDetector.DEFAULT_EXIT_DEGREES,
)
}
}
@Test
fun `coherence invariant holds while drifting inside hysteresis, then unlocks`() {
val pipeline = surfacePipeline()
run(pipeline, degrees = 0.1, fromMillis = 0, untilMillis = 1_000) // acquire lock
// Drift to 0.30° — inside hysteresis (exit is 0.35°), so the lock holds and
// the displayed value (0.3°) must still be within the stated tolerance.
val drifted = run(pipeline, degrees = 0.30, fromMillis = 1_020, untilMillis = 3_000)
assertTrue(drifted.last().isLocked)
drifted.filter { it.isLocked }.forEach {
assertTrue(it.displayPrimaryDegrees <= LockDetector.DEFAULT_EXIT_DEGREES)
}
// Past the exit threshold the lock must release.
val tilted = run(pipeline, degrees = 0.6, fromMillis = 3_020, untilMillis = 5_000)
assertFalse(tilted.last().isLocked)
}
@Test
fun `surface magnitude remains available through near vertical and screen down`() {
fun readingAt(degrees: Double): LevelReading =
surfacePipeline().process(pitched(degrees, 0))
val atThirty = readingAt(30.0)
assertEquals(30.0, atThirty.displayPrimaryDegrees, 1e-9)
assertTrue(atThirty.secondaryADegrees != null)
assertTrue(atThirty.secondaryBDegrees != null)
assertEquals(SurfacePresentation.FACE_UP, atThirty.surfacePresentation)
assertFalse(atThirty.isLocked)
val atEighty = readingAt(80.0)
assertEquals(80.0, atEighty.displayPrimaryDegrees, 1e-9)
assertEquals(null, atEighty.secondaryADegrees)
assertEquals(null, atEighty.secondaryBDegrees)
assertEquals(SurfacePresentation.NEAR_VERTICAL, atEighty.surfacePresentation)
assertFalse(atEighty.isLocked)
val screenDown = readingAt(120.0)
assertEquals(120.0, screenDown.displayPrimaryDegrees, 1e-9)
assertEquals(null, screenDown.secondaryADegrees)
assertEquals(null, screenDown.secondaryBDegrees)
assertEquals(SurfacePresentation.SCREEN_DOWN, screenDown.surfacePresentation)
assertFalse(screenDown.isLocked)
}
@Test
fun `sustained steep Surface reading never locks or fires feedback`() {
val readings = run(surfacePipeline(), degrees = 30.0, fromMillis = 0, untilMillis = 2_000)
assertTrue(readings.all { !it.isLocked })
assertTrue(readings.none { it.fireFeedback })
}
@Test
fun `surface reading exposes the shared settling state before strong guidance`() {
val readings = run(surfacePipeline(), degrees = 2.0, fromMillis = 0, untilMillis = 1_000)
assertTrue(readings.first().isSettling)
assertFalse(readings.last().isSettling)
}
@Test
fun `edge mode never locks while the phone lies flat on a table`() {
val pipeline = LevelPipeline(
mode = LevelMode.EDGE,
surfaceCalibration = SurfaceCalibration.NONE,
edgeCalibration = EdgeCalibration.NONE,
lockDetector = LockDetector(),
)
// Flat on a table, Edge mode selected: edgeLevelDegrees(g) is ~0 — without
// placement gating this would lock on a meaningless reading.
val readings = (0L..2_000L step 20).map { pipeline.process(flat(it)) }
readings.forEach {
assertFalse(it.placementOk)
assertFalse(it.isLocked)
}
}
@Test
fun `edge mode locks on a genuinely level edge placement`() {
val pipeline = LevelPipeline(
mode = LevelMode.EDGE,
surfaceCalibration = SurfaceCalibration.NONE,
edgeCalibration = EdgeCalibration.NONE,
lockDetector = LockDetector(),
)
val readings = (0L..2_000L step 20).map {
pipeline.process(GravitySample(g, 0.0, 0.0, it * 1_000_000))
}
assertTrue(readings.last().placementOk)
assertTrue(readings.last().isLocked)
}
}
+4
View File
@@ -0,0 +1,4 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+12
View File
@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21
+42
View File
@@ -0,0 +1,42 @@
[versions]
agp = "9.2.1"
kotlin = "2.2.10"
coreKtx = "1.18.0"
activityCompose = "1.10.1"
lifecycle = "2.8.7"
composeBom = "2025.06.01"
navigation = "2.8.4"
datastore = "1.1.6"
billing = "9.1.0"
junit = "4.13.2"
coroutinesTest = "1.9.0"
androidxJunit = "1.3.0"
espresso = "3.7.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
billing-ktx = { group = "com.android.billingclient", name = "billing-ktx", version.ref = "billing" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+252
View File
@@ -0,0 +1,252 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=JetBrains+Mono:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
body{margin:0;background:radial-gradient(1200px 800px at 50% 20%, #1a1c20 0%, #101114 60%, #0b0c0e 100%)}
a{color:#ffb454;text-decoration:none} a:hover{color:#ffc97e}
@keyframes ping{0%{transform:scale(0.08);opacity:0.55}75%{transform:scale(1);opacity:0}100%{transform:scale(1);opacity:0}}
@keyframes chipring{0%{transform:scale(0.6);opacity:0.9}100%{transform:scale(2.4);opacity:0}}
@keyframes breathe{0%,100%{opacity:1}50%{opacity:0.25}}
@keyframes bob{0%,100%{transform:translate(0px,0px)}30%{transform:translate(1.4px,-1.1px)}65%{transform:translate(-1.2px,0.9px)}}
</style>
</helmet>
<div style="min-height:100vh;display:grid;place-items:center;padding:36px 24px">
<x-import component-from-global-scope="AndroidDevice" from="./android-frame.jsx" dark="{{ true }}" height="{{ 920 }}" hint-size="428px,936px">
<div data-screen-label="Level" style="position:relative;height:100%;display:flex;flex-direction:column;gap:14px;padding:14px 20px 18px;box-sizing:border-box;overflow:hidden;background:radial-gradient(140% 90% at 50% 0%, #121419 0%, #0a0b0d 55%, #060708 100%);font-family:'Archivo',system-ui,sans-serif;color:#e8eaed">
<div style="position:absolute;left:-120px;bottom:-120px;width:340px;height:340px;border-radius:50%;background:radial-gradient(circle, rgba(255,164,60,0.14) 0%, rgba(255,164,60,0) 70%);pointer-events:none;opacity:{{ glowOpacity }};transition:opacity 0.6s"></div>
<div style="display:flex;align-items:center;justify-content:space-between">
<div style="display:flex;flex-direction:column;gap:3px">
<div style="font-size:14px;font-weight:700;letter-spacing:4px;color:#e8eaed">LEVEL</div>
<div style="display:flex;align-items:center;gap:6px">
<div style="width:5px;height:5px;border-radius:50%;background:#7ed9a0;animation:breathe 2.6s ease-in-out infinite"></div>
<div style="font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:2px;color:#6f767e">LIVE · FUSED</div>
</div>
</div>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#6f767e" stroke-width="1.6" style="cursor:pointer">
<circle cx="12" cy="12" r="3.2"></circle>
<path d="M12 2.8v3M12 18.2v3M2.8 12h3M18.2 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1"></path>
</svg>
</div>
<div style="display:flex;align-items:center;gap:10px;padding:9px 14px;border:1px solid rgba(255,255,255,0.07);border-radius:12px;background:linear-gradient(180deg, rgba(255,255,255,0.025), rgba(255,255,255,0))">
<div style="position:relative;width:8px;height:8px;flex:none">
<div style="position:absolute;inset:0;border-radius:50%;background:{{ sonarDotColor }}"></div>
<sc-if value="{{ sonarOn }}" hint-placeholder-val="{{ true }}">
<div style="position:absolute;inset:-3px;border-radius:50%;border:1px solid #5fd4c4;animation:chipring 2.2s ease-out infinite"></div>
</sc-if>
</div>
<div style="font-size:10px;font-weight:600;letter-spacing:2.5px;color:{{ sonarLabelColor }}">SONAR</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:12px;color:{{ sonarValueColor }}">{{ sonarText }}</div>
<div style="flex:1"></div>
<sc-if value="{{ sonarOn }}" hint-placeholder-val="{{ true }}">
<div style="display:flex;align-items:center;gap:3px">
<div style="width:2px;height:6px;background:#5fd4c4;opacity:0.35;border-radius:1px"></div>
<div style="width:2px;height:10px;background:#5fd4c4;opacity:0.6;border-radius:1px"></div>
<div style="width:2px;height:14px;background:#5fd4c4;opacity:0.9;border-radius:1px"></div>
<div style="width:2px;height:9px;background:#5fd4c4;opacity:0.5;border-radius:1px"></div>
<div style="width:2px;height:5px;background:#5fd4c4;opacity:0.3;border-radius:1px"></div>
</div>
</sc-if>
</div>
<div style="position:relative;display:flex;background:#15171b;border:1px solid rgba(255,255,255,0.06);border-radius:999px;padding:3px">
<div style="position:absolute;top:3px;bottom:3px;width:calc(50% - 3px);left:{{ thumbLeft }};border-radius:999px;background:linear-gradient(180deg,#2a2d33,#1f2126);border:1px solid rgba(255,255,255,0.1);box-shadow:0 2px 8px rgba(0,0,0,0.5);transition:left 0.25s cubic-bezier(0.3,0.9,0.3,1)"></div>
<button onClick="{{ setSurface }}" style="position:relative;flex:1;padding:9px 0;background:none;border:none;border-radius:999px;font-family:'Archivo',sans-serif;font-size:12px;font-weight:600;letter-spacing:1.5px;color:{{ surfTabColor }};cursor:pointer;transition:color 0.25s">SURFACE</button>
<button onClick="{{ setEdge }}" style="position:relative;flex:1;padding:9px 0;background:none;border:none;border-radius:999px;font-family:'Archivo',sans-serif;font-size:12px;font-weight:600;letter-spacing:1.5px;color:{{ edgeTabColor }};cursor:pointer;transition:color 0.25s">EDGE</button>
</div>
<sc-if value="{{ isSurface }}" hint-placeholder-val="{{ true }}">
<div data-screen-label="Surface mode" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:space-evenly;min-height:0">
<svg width="308" height="308" viewBox="0 0 360 360" style="flex:none">
<defs>
<radialGradient id="dialFace" cx="42%" cy="34%" r="80%">
<stop offset="0%" stop-color="#1b1e23"></stop>
<stop offset="60%" stop-color="#111318"></stop>
<stop offset="100%" stop-color="#0a0b0e"></stop>
</radialGradient>
<radialGradient id="bubbleG" cx="38%" cy="30%" r="75%">
<stop offset="0%" stop-color="#eaffb0"></stop>
<stop offset="35%" stop-color="#c3e85e"></stop>
<stop offset="75%" stop-color="#8ab825"></stop>
<stop offset="100%" stop-color="#5f8510"></stop>
</radialGradient>
<radialGradient id="bubbleHalo" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#b7e050" stop-opacity="0.35"></stop>
<stop offset="100%" stop-color="#b7e050" stop-opacity="0"></stop>
</radialGradient>
</defs>
<circle cx="180" cy="180" r="172" fill="url(#dialFace)" stroke="rgba(255,255,255,0.1)" stroke-width="1"></circle>
<circle cx="180" cy="180" r="164" fill="none" stroke="rgba(255,255,255,0.28)" stroke-width="7" stroke-dasharray="1.4 41.55"></circle>
<circle cx="180" cy="180" r="164" fill="none" stroke="rgba(255,255,255,0.12)" stroke-width="5" stroke-dasharray="1 13.35"></circle>
<sc-if value="{{ sonarOn }}" hint-placeholder-val="{{ true }}">
<g style="transform-origin:180px 180px;animation:ping 4.5s linear infinite">
<circle cx="180" cy="180" r="150" fill="none" stroke="#5fd4c4" stroke-width="1" opacity="0.5"></circle>
</g>
<g style="transform-origin:180px 180px;animation:ping 4.5s linear 2.25s infinite">
<circle cx="180" cy="180" r="150" fill="none" stroke="#5fd4c4" stroke-width="1" opacity="0.5"></circle>
</g>
</sc-if>
<circle cx="180" cy="180" r="150" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="1"></circle>
<circle cx="180" cy="180" r="100" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="1"></circle>
<circle cx="180" cy="180" r="50" fill="none" stroke="rgba(255,255,255,0.14)" stroke-width="1"></circle>
<text x="186" y="44" font-family="JetBrains Mono" font-size="10" fill="#5a616a">1.5°</text>
<text x="186" y="94" font-family="JetBrains Mono" font-size="10" fill="#5a616a"></text>
<text x="186" y="144" font-family="JetBrains Mono" font-size="10" fill="#5a616a">0.5°</text>
<path d="M180 30 V150 M180 210 V330 M30 180 H150 M210 180 H330" stroke="rgba(255,255,255,0.16)" stroke-width="1"></path>
<path d="M78 249 A122 122 0 0 1 60.5 202" fill="none" stroke="#ffb454" stroke-width="2.5" stroke-linecap="round" opacity="{{ dirOpacity }}" style="transition:opacity 0.5s"></path>
<g opacity="{{ dirOpacity }}" style="transition:opacity 0.5s">
<path d="M96 262 l-9 5 3-10" fill="none" stroke="#ffb454" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</g>
<circle cx="180" cy="180" r="2.5" fill="#ffb454"></circle>
<g transform="translate({{ bubbleX }} {{ bubbleY }})" style="transition:transform 0.7s cubic-bezier(0.3,0.9,0.35,1)">
<g style="animation:bob 5.5s ease-in-out infinite">
<circle cx="180" cy="180" r="46" fill="url(#bubbleHalo)"></circle>
<circle cx="163" cy="196" r="3" fill="#b7e050" opacity="0.12"></circle>
<circle cx="171" cy="189" r="4.5" fill="#b7e050" opacity="0.22"></circle>
<circle cx="180" cy="180" r="21" fill="url(#bubbleG)"></circle>
<circle cx="180" cy="180" r="21" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="0.8"></circle>
<ellipse cx="173" cy="172" rx="7" ry="4.5" fill="#ffffff" opacity="0.55" transform="rotate(-32 173 172)"></ellipse>
</g>
</g>
</svg>
<div style="display:flex;flex-direction:column;align-items:center;gap:4px">
<div style="font-family:'JetBrains Mono',monospace;font-weight:300;font-size:84px;line-height:1;letter-spacing:-3px;color:#f2f4f6">{{ mainVal }}<span style="font-size:40px;font-weight:400;color:#8a919b;vertical-align:26px;letter-spacing:0">{{ mainUnit }}</span></div>
<svg width="240" height="22" viewBox="0 0 240 22">
<line x1="10" y1="14" x2="230" y2="14" stroke="rgba(255,255,255,0.1)" stroke-width="4" stroke-dasharray="1 10"></line>
<line x1="120" y1="6" x2="120" y2="20" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"></line>
<rect x="{{ devX }}" y="4" width="3" height="16" rx="1.5" fill="{{ stateColor }}" style="transition:x 0.7s, fill 0.5s"></rect>
</svg>
<div style="display:flex;align-items:center;gap:8px">
<div style="width:5px;height:5px;border-radius:50%;background:{{ stateColor }};transition:background 0.5s"></div>
<div style="font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:2px;color:#6f767e">{{ subReadout }}</div>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ isEdge }}" hint-placeholder-val="{{ false }}">
<div data-screen-label="Edge mode" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:space-evenly;min-height:0">
<svg width="340" height="150" viewBox="0 0 340 150" style="flex:none">
<defs>
<linearGradient id="tubeG" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#07080a"></stop>
<stop offset="45%" stop-color="#15181d"></stop>
<stop offset="100%" stop-color="#0b0c0f"></stop>
</linearGradient>
</defs>
<rect x="8" y="34" width="324" height="62" rx="31" fill="url(#tubeG)" stroke="rgba(255,255,255,0.1)" stroke-width="1"></rect>
<rect x="10" y="36" width="320" height="14" rx="7" fill="rgba(255,255,255,0.03)"></rect>
<line x1="146" y1="30" x2="146" y2="100" stroke="rgba(255,255,255,0.35)" stroke-width="1.5"></line>
<line x1="194" y1="30" x2="194" y2="100" stroke="rgba(255,255,255,0.35)" stroke-width="1.5"></line>
<g transform="translate({{ edgeBx }} 0)" style="transition:transform 0.7s cubic-bezier(0.3,0.9,0.35,1)">
<ellipse cx="170" cy="65" rx="24" ry="19" fill="url(#bubbleG2)"></ellipse>
<ellipse cx="170" cy="65" rx="24" ry="19" fill="none" stroke="rgba(255,255,255,0.25)" stroke-width="0.8"></ellipse>
<ellipse cx="162" cy="57" rx="8" ry="4" fill="#ffffff" opacity="0.5" transform="rotate(-18 162 57)"></ellipse>
</g>
<defs>
<radialGradient id="bubbleG2" cx="38%" cy="30%" r="75%">
<stop offset="0%" stop-color="#eaffb0"></stop>
<stop offset="35%" stop-color="#c3e85e"></stop>
<stop offset="75%" stop-color="#8ab825"></stop>
<stop offset="100%" stop-color="#5f8510"></stop>
</radialGradient>
</defs>
<line x1="20" y1="118" x2="320" y2="118" stroke="rgba(255,255,255,0.12)" stroke-width="6" stroke-dasharray="1 14"></line>
<line x1="170" y1="110" x2="170" y2="126" stroke="rgba(255,255,255,0.3)" stroke-width="1.5"></line>
<text x="14" y="142" font-family="JetBrains Mono" font-size="10" fill="#5a616a">-2°</text>
<text x="166" y="142" font-family="JetBrains Mono" font-size="10" fill="#5a616a">0</text>
<text x="312" y="142" font-family="JetBrains Mono" font-size="10" fill="#5a616a">+2°</text>
</svg>
<div style="display:flex;flex-direction:column;align-items:center;gap:6px">
<div style="font-family:'JetBrains Mono',monospace;font-weight:300;font-size:84px;line-height:1;letter-spacing:-3px;color:#f2f4f6">{{ edgeVal }}<span style="font-size:40px;font-weight:400;color:#8a919b;vertical-align:26px;letter-spacing:0">°</span></div>
<div style="display:flex;align-items:center;gap:8px">
<div style="width:5px;height:5px;border-radius:50%;background:{{ stateColor }};transition:background 0.5s"></div>
<div style="font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:2px;color:#6f767e">{{ edgeSubReadout }}</div>
</div>
</div>
</div>
</sc-if>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div style="background:linear-gradient(180deg,#14161b,#101216);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:13px 16px 11px;display:flex;flex-direction:column;gap:6px">
<div style="font-size:9.5px;font-weight:600;letter-spacing:2.5px;color:#6f767e">PITCH</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:26px;font-weight:400;color:#e8eaed">{{ pitchText }}</div>
<svg width="100%" height="20" viewBox="0 0 130 20" preserveAspectRatio="none">
<line x1="0" y1="10" x2="130" y2="10" stroke="rgba(255,255,255,0.08)" stroke-width="1"></line>
<g style="transform-origin:65px 10px;transform:rotate({{ pitchRot }}deg);transition:transform 0.7s">
<line x1="20" y1="10" x2="110" y2="10" stroke="{{ stateColor }}" stroke-width="1.5"></line>
</g>
<circle cx="65" cy="10" r="2" fill="{{ stateColor }}"></circle>
</svg>
</div>
<div style="background:linear-gradient(180deg,#14161b,#101216);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:13px 16px 11px;display:flex;flex-direction:column;gap:6px">
<div style="font-size:9.5px;font-weight:600;letter-spacing:2.5px;color:#6f767e">ROLL</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:26px;font-weight:400;color:#e8eaed">{{ rollText }}</div>
<svg width="100%" height="20" viewBox="0 0 130 20" preserveAspectRatio="none">
<line x1="0" y1="10" x2="130" y2="10" stroke="rgba(255,255,255,0.08)" stroke-width="1"></line>
<g style="transform-origin:65px 10px;transform:rotate({{ rollRot }}deg);transition:transform 0.7s">
<line x1="20" y1="10" x2="110" y2="10" stroke="{{ stateColor }}" stroke-width="1.5"></line>
</g>
<circle cx="65" cy="10" r="2" fill="{{ stateColor }}"></circle>
</svg>
</div>
</div>
</div>
</x-import>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:520,&quot;height&quot;:1000},&quot;leveled&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;State&quot;},&quot;sonarOn&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;Sonar&quot;},&quot;units&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;degrees&quot;,&quot;mm/m&quot;,&quot;percent&quot;],&quot;default&quot;:&quot;degrees&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Readout&quot;}}">
class Component extends DCLogic {
state = { mode: 'surface' };
renderVals() {
const leveled = this.props.leveled ?? false;
const sonarOn = this.props.sonarOn ?? true;
const units = this.props.units ?? 'degrees';
const mode = this.state.mode;
const pitch = leveled ? 0 : -0.6, roll = leveled ? 0 : -0.3;
const stateColor = leveled ? '#7ed9a0' : '#ffb454';
const fmt = (v) => (v > 0 ? '+' : v < 0 ? '-' : '') + Math.abs(v).toFixed(1) + '\u00b0';
let mainVal, mainUnit;
if (units === 'mm/m') { mainVal = leveled ? '0.0' : '10.5'; mainUnit = ' mm/m'; }
else if (units === 'percent') { mainVal = leveled ? '0.0' : '1.2'; mainUnit = '%'; }
else { mainVal = leveled ? '0.0' : '0.7'; mainUnit = '\u00b0'; }
return {
isSurface: mode === 'surface', isEdge: mode === 'edge',
setSurface: () => this.setState({ mode: 'surface' }),
setEdge: () => this.setState({ mode: 'edge' }),
thumbLeft: mode === 'surface' ? '3px' : 'calc(50%)',
surfTabColor: mode === 'surface' ? '#e8eaed' : '#6f767e',
edgeTabColor: mode === 'edge' ? '#e8eaed' : '#6f767e',
sonarOn,
sonarDotColor: sonarOn ? '#5fd4c4' : '#3a3f45',
sonarLabelColor: sonarOn ? '#9ba3ac' : '#4d535a',
sonarValueColor: sonarOn ? '#5fd4c4' : '#4d535a',
sonarText: sonarOn ? '0.42 m' : 'OFF',
glowOpacity: leveled ? 0 : 1,
dirOpacity: leveled ? 0 : 0.9,
bubbleX: roll * 100, bubbleY: -pitch * 100,
devX: 118.5 + pitch * 60,
stateColor,
mainVal, mainUnit,
subReadout: leveled ? 'LEVEL \u00b7 HOLD' : '10.5 MM/M \u00b7 HIGH \u2199',
edgeVal: leveled ? '0.0' : '0.3',
edgeSubReadout: leveled ? 'LEVEL \u00b7 HOLD' : '5.2 MM/M \u00b7 HIGH LEFT',
edgeBx: roll * 120,
pitchText: fmt(pitch), rollText: fmt(roll),
pitchRot: pitch * 12, rollRot: roll * 12,
};
}
}
</script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "OnTheLevel"
include(":app")
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.