Skip to content

Compact android widget icons#2

Closed
kavemang wants to merge 131 commits into
silentestshadows:mainfrom
kavemang:compact-android-widget-icons
Closed

Compact android widget icons#2
kavemang wants to merge 131 commits into
silentestshadows:mainfrom
kavemang:compact-android-widget-icons

Conversation

@kavemang

@kavemang kavemang commented Jul 8, 2026

Copy link
Copy Markdown

What this PR does

Adds a compact android widget, the current one has more padding than needed and takes up a lot of screen space

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

gradlew +
Tested locally on a samsung S9+

Checklist

  • Swift package tests pass for any package I touched (swift test in Packages/<name>)
  • Android unit tests pass if I touched android/ (./gradlew testFullDebugUnitTest)
  • [ X] No new build warnings introduced
  • UI changes use only StrandDesign tokens — no hardcoded colors, fonts, or spacing
    Glance widget that follows androids local color pattern
  • [ X] No hardcoded hex frame bytes; protocol facts live in the schema / decoders
  • [ X] Follows the conventions in docs/CONTRIBUTING.md
  • [ X] I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

ryanbr and others added 30 commits June 29, 2026 14:08
…ismissed (and remembered)

The "Building your baseline. About N more nights until your scores are
personal." note shows every day through the multi-night calibration window
with no way to dismiss it. Make it dismissible-and-remembered, reusing the
exact pattern the neighbouring "Live now. Your scores are building." card
already uses: an × that dismisses the note INTO the Updates inbox
(TodayCardDismissal-persisted, restorable from there), so a returning user
who has read it once is not shown it again.

- New card id CARD_CALIBRATING wired through the existing dismiss/restore
  plumbing (dismissed flag, dismissTodayCard, restore-to-Today signal).
- Only the calibrating note is dismissible; "Needs the strap" still always
  shows (it is a today-blocking state, not a recurring nag).
- Persisted via the shared TodayCardDismissal, so the dismissal survives
  relaunches; restorable via the inbox like every other Today info-card.

iOS shows the calibrating state as a ring pill ("Calibrating, N of 4"),
not this full banner, so there is no iOS counterpart to dismiss.
The reassembler runs on every BLE notification, including the historical
offload — the heaviest WHOOP path (thousands of ~1.9KB records over a
multi-night sync). It used an ArrayList<Byte>, which:

  - boxed every incoming byte (buf.add(b)) → millions of Byte allocations
    + GC churn during a backfill, and
  - drained each completed frame with repeat(total){ buf.removeAt(0) } —
    removeAt(0) is an O(n) array shift, called once per byte, so draining
    one frame is O(n^2).

Replace the backing store with a plain ByteArray + a `start` read offset:
bytes append into the array, `start` advances past consumed bytes, and
the unconsumed tail is compacted to the front once per feed(). Frame
draining becomes O(1) offset bumps + one bounded tail-compact; no boxing.

Output is byte-identical — same frames, same order. Guarded by the
existing FramingTest reassembler vectors (split-across-fragments,
two-in-one, leading-garbage-drop, garbage-length-resync, reset) plus a
new one-byte-per-fragment stress test that exercises the offset/compact
path on every byte. WHOOP 4 + 5/MG (family-aware length unchanged).

Android only; the Swift twin uses bulk removeFirst(k) (O(n) once per
frame, not per byte), so it's far milder — left as a possible follow-up.
Every received frame is validated by recomputing its CRCs, and the
validators sliced the frame just to checksum it — a throwaway sub-array
per frame on the historical-offload path (thousands of frames per sync):

  Android (Framing.kt):  byteArrayOf(frame[1],frame[2]) + copyOfRange(4,length)  [W4]
                         copyOfRange(0,6) + copyOfRange(8,payloadEnd)            [W5]
  iOS/macOS (Framing.swift): [frame[1],frame[2]] + Array(frame[4..<length])      [W4]
                         Array(frame[0..<6]) + Array(frame[8..<payloadEnd])      [W5]

For WHOOP4 the inner slice is ~the whole frame copied again only to be
hashed and discarded.

Add range params to the CRC functions on both platforms and checksum the
frame in place:
  - Kotlin: crc8/crc32/crc16Modbus(data, from = 0, to = data.size)
  - Swift:  crc8/crc32/crc16Modbus(_ bytes, _ from = 0, _ to: Int? = nil)
The default range is the whole array, so every existing caller (build
path + tests) is unchanged and the math stays in one loop. The validators
pass the frame + the exact [from, to) the old slices used.

Output is byte-identical — same checksums, same accept/reject. Android is
guarded by CrcTest + the validate-through-decode suites (DecoderOracle,
Whoop4/5 historical, clock-correction), all green locally. Swift is
guarded by the FramingTests / DeviceFamilyFramingTests / Whoop5 vectors
and gated on CI (no Swift build in my dev env).

Decode-side pay/payload slices and the command-build path are left as-is
(lower frequency, higher risk on the hardware-verified decode). WHOOP 4 +
5/MG; no Oura/scoring impact.
Read and surface the connected strap's firmware version on the
Settings → Devices card. The connect handshake now issues the
family-appropriate, documented-safe version READ command and the
response is decoded into LiveState:

  - WHOOP 4.0  → REPORT_VERSION_INFO (7)  → fw_harvard a.b.c.d
  - WHOOP 5/MG → GET_HELLO (145)          → fw_version a.b.c.d

These are read commands (distinct from the firmware-load opcodes);
each family ignores the other's command. The card renders
" · FW <version>" only for the active, connected device.

The Swift WhoopProtocol already decodes the same fields
(PostHooks fw_harvard / Interpreter fw_version); this wires
send → state → UI on Android. Adds a CRC-cross-checked decode
vector for the 4.0 REPORT_VERSION_INFO path.
…8.2.2 elsewhere)

Resolves PR silentestshadows#1 (ryanbr/noop) so main can adopt the v8.2.2 snapshot while retaining
the fork-only pieces. The 9 conflicting files were all cases where the fork's perf
(ranged CRC, O(1) Reassembler drain) and firmware-version-display work had ALREADY
been reimplemented independently in v8.2.2 — same features, different variable names
and comments. Taking v8.2.2 for those keeps a single upstream implementation instead
of a divergent fork copy; the features are retained (TodayScreen v8.2.2 is even a
superset: #827 calibrating-dismissal plus a new carried-sleep card).

Kept (non-conflicting, fork-unique): the unified fork testing-build CI workflow,
fork debug keystore, fork appId/identity (build.gradle.kts, project.yml), and the
fork update-source plumbing (UpdateCheck.kt / UpdateChecker.swift).
Import v8.2.2 snapshot (upstream catch-up + board wave; ports PRs #1035/#1036 + BLE/readiness fixes)
…tifacts

- Release APK: the android job now also runs `assembleFullRelease -PstagingRelease`
  and publishes `app-full-release-v<VER>.apk` alongside the debug APK. build.gradle.kts
  gives the release buildType a `.staging` applicationIdSuffix (+ `-staging` versionName)
  ONLY under -PstagingRelease, so it installs beside both the official app and the
  .debug staging build; a real release (no flag) keeps the true com.noop.whoop id.
  Verified locally: com.noop.whoop.staging / 8.2.2-staging, signed via the debug-key fallback.
- Version numbers in every artifact filename: app-full-release-v<VER>.apk,
  app-full-debug-v<VER>.apk, NOOP-macos-v<VER>.zip, NOOP-ios-unsigned-v<VER>.ipa.
  meta now outputs `ver`; release notes list the release APK + versioned names.
  The rolling tag is recreated per run, so old-version assets never accumulate.
… with keep rules

The release build was shipped UNMINIFIED because R8 full-mode crashed it on launch by
over-stripping reflective paths (Compose/Room/Tink). This turns real optimisation back on
without that failure mode:

- isMinifyEnabled + isShrinkResources = true (release).
- android.enableR8.fullMode = false — standard R8 shrinks/optimises but keeps default
  constructors and is forgiving of reflective libs; full-mode was the specific culprit.
- proguard-rules.pro pins the reflective survivors: Tink + protobuf (security-crypto's
  EncryptedSharedPreferences), WorkManager Worker (Context, WorkerParameters) constructors,
  enum values()/valueOf(), Parcelable CREATOR, native methods; + okhttp/conscrypt -dontwarn.

Result (local assembleFullRelease -PstagingRelease): R8 runs clean, APK 18 MB -> 4.6 MB,
4 dex -> 1, resources.arsc 588 KB -> 304 KB, app-id/label and Tink classes intact.

CAVEAT: a clean R8 build does not prove no runtime crash — reflective breakage shows at
launch, not build time. This ships to the fork testing release specifically so it can be
device-installed and verified before being trusted for a real ship.
Fork CI: staging-release APK, versioned artifact names, R8-optimised release
…ive ViewModels)

The R8-minified release launched (terms gate rendered) but exited on "Accept & Continue":
accepting flips the gate and composes the real content, which resolves AppViewModel /
CoachViewModel reflectively via Compose viewModel(). R8 had renamed those classes (only
com.noop.data + com.noop.protocol were kept), so the reflective instantiation threw and the
process died.

Keep ALL of NOOP's own code (-keep class com.noop.**) — its reflective surface is broader
than data/protocol (ViewModels, Glance widgets, manifest components) and it is NOT the size
bulk; the shrink win is in the androidx/Compose/kotlin libraries, which still shrink. Plus a
generic keep for ViewModel/AndroidViewModel constructors.

Local assembleFullRelease -PstagingRelease: R8 clean, ViewModels present + unrenamed,
APK 4.6 MB -> 6.7 MB (still ~63% under the 18 MB unminified build).
Confirmed on-device: the debug APK (unminified) works through the terms gate and beyond,
while the minified release exits on "Accept & Continue" and then hits the error screen on
reopen — so the crash is R8-specific, not a code bug. Even with full-mode OFF and broad keeps
(com.noop.** + Tink/Worker/ViewModel constructors), a minified build still died on a library
reflective path we can't pin without a device trace. Not worth a crashy release for users.

Restores the reliable UNMINIFIED release (isMinifyEnabled/isShrinkResources = false,
fullMode back to its dormant default, proguard-rules.pro back to the baseline). KEPT: the
fork staging-release APK (-PstagingRelease → .staging id, installs beside official) and the
version-stamped artifact filenames — those are orthogonal to minify and work fine.
Revert release to unminified (R8 minify crashes at runtime); keep staging-release + versioned names
The daily auto-backup already writes dated .noopbak snapshots and prunes the oldest beyond
BackupSyncPrefs.keepCount — but keepCount had no UI, so it sat at the default (10). Expose it
as a dropdown on the Backup & Sync screen (options 1/3/5/7/10/14), wired to the existing
setKeepCount; the next backup prunes the oldest beyond the chosen count (BackupSync.snapshotsToPrune,
newest-kept/oldest-dropped — unchanged). UI-only; no new plumbing, no scheduling change (still daily).

Default left at 10 (unchanged, and matches the Apple FolderBackup.keepCount parity); the picker
lets the user dial it down to 1 if they want a lighter footprint. Compiles clean.
- DEFAULT_KEEP 10 → 7: a week of daily rollback points is the sweet spot for the corruption-recovery
  use case (grab the newest good snapshot) without hoarding. Still user-adjustable via the dropdown
  (1/3/5/7/10/14). The Apple twin keeps 10 for now — iOS parity dropdown is a follow-up.
- Daily job now fires around 01:00 local: setInitialDelay(delayToNextBackupMs()) then repeats daily,
  mirroring DebugExportScheduler's time-of-day anchoring. WorkManager isn't exact (may slide into a
  maintenance window — fine for a backup); on-launch catchUpIfDue still covers any missed day.
- Screen copy notes the ~1am timing + the "keep N ≈ N days, grab newest on corruption" intent.
- Tests: DEFAULT_KEEP assertion updated to 7; added backupDelayLandsAtOneAm. BackupSyncTest 13 pass.
The daily auto-backup toggle + folder + keep-count live on the separate Backup & Sync screen,
which a user looking to "turn on automatic backups" wouldn't find under Settings. Add a signpost
section directly below the one-off "Backup & restore" section: a short blurb (daily ~1am, keeps
the last several, restore the newest on corruption) + a "Set up automatic backups" button that
opens the Backup & Sync screen (new onOpenBackupSync nav callback, wired like onOpenTestCentre).
Nothing moved; the setting is just discoverable from where people look. Compiles clean.
Backup & Sync: keep-count dropdown, default 7, ~1am daily, Settings discoverability
Brings the iOS FolderBackup to parity with the Android keep-count work:
- FolderBackup.keepCount: static let 10 → user-adjustable (UserDefaults-backed) with default 7
  and keepOptions [1,3,5,7,10,14], clamped 1...100 (mirrors Android DEFAULT_KEEP + KEEP_OPTIONS +
  setKeepCount). prune() already reads keepCount, so it now honours the user's choice.
- BackupSyncView: a "Keep last snapshots" menu Picker wired to keepCount, below the auto toggle;
  the auto-backup blurb now shows the live count.

NOT mirrored (platform difference, not a gap): the Android ~1am WorkManager anchor. iOS has no
reliable daily background scheduler (BackgroundTasks is throttled), so it stays the existing
on-launch catch-up — the screen already says "runs when you next open NOOP". The Settings
signpost also already exists on iOS (the "Backup & Sync to a folder…" link in Backup & restore).

Swift is CI-gated (can't compile on WSL); the fork iOS build job is the compile check.
iOS Backup & Sync: mirror Android adjustable keep-count (default 7)
- AiCoach.parseReply: firstChoice is smart-cast non-null after the content guard, so the
  finish_reason safe calls were redundant -> plain calls (optString returns non-null).
- CaffeineDecay: `mostRecentActiveHours!!` is redundant inside `x == null || … x` (smart-cast).
- WatchRecovery: `hrvHistory/rhrHistory.map { it as Double? }` -> pass List<Double> straight to
  foldHistory(List<Double?>) via List covariance; the casts (and identity map) were unnecessary.

Left as-is: the "unused parameter" warnings on inBedSeconds / nightsElapsed / habitualWakeHour /
day / resp are deliberate Kotlin<->Swift signature-parity mirrors (each exists in the Swift twin),
so removing them would break parity. compileFullDebugKotlin: BUILD SUCCESSFUL, those 4 gone.
…adows

All non-behavioural (compile-verified, ingest+data tests 298 pass):
- Deprecated Material icons → AutoMirrored variants (import + usage): VolumeUp/VolumeOff/
  ArrowForward (BreatheScreen), Send (CoachScreen), DirectionsWalk/DirectionsRun (DashboardCards),
  MenuBook (HealthScreen, LabBookScreen).
- Unnecessary safe calls on smart-cast-non-null receivers: SourceCoordinator (row.model),
  HealthScreen (day.recovery).
- HrvSnapshotScreen: dropped a redundant Elvis (r.rmssd is non-null here).
- DataBackup: removed the redundant `else` on an exhaustive `when`.
- CsvParser: dropped the redundant `= 0` initializer on `hours` (assigned on every branch).
- Name-shadow renames: Backfiller `unix`→`recUnix`, StandardHrSource inner `ch`→`sensorCh`.

Left as-is: the remaining "unused parameter" warnings — `data` (WearableExportImporter) is a
Swift-parity mirror (isWellnessFile(..., data:) exists in Swift); `batch`/`g` (WhoopBleClient) are
Android GATT-helper signature params; `color` (CoachMarkdown.parseInline) is passed by all callers.
All non-behavioural (compile-verified):
- Deprecated icons → AutoMirrored: Chat + OpenInNew (NotificationsSettingsScreen),
  MenuBook (SettingsScreen), DirectionsWalk (StepsCalibrationScreen), BatteryUnknown (TodayScreen).
- TodayScreen: removed 5 compiler-proven always-true null sub-conditions (recovery/strain/sleep
  are non-null there), renamed a shadowed inner `live`→`liveStrain`, deleted an unused
  `dismissScoringCard` lambda (never referenced).
- SleepScreen: renamed shadowed inner `imported`→`importedSessions`.
- TrendsReport: dropped a dead assignment (`y = drawMetrics(...)` → `drawMetrics(...)`; y unused after).

Left as-is: the "unused parameter" warnings (TodayScreen composable params, TestCentre `vm`,
TimeOfDayBackground `w`) — composable/Swift-parity signature params; removing risks parity + churn.
…cons

- UpdateStore.fromJson: `o.optString(name, null)` passes null where Android's optString(name, String)
  wants a non-null fallback (Nothing?-vs-String warning). Use the `if (o.has(name)) o.optString(name)
  else null` idiom — same result (value or null), no warning. deepLink/restorePayload already had the
  o.has guard so the inner `, null` was dead; kind gains the guard too.
- WorkoutsScreen: deprecated icons → AutoMirrored (ShowChart, MergeType, DirectionsRun x2,
  DirectionsWalk, DirectionsBike).

Non-behavioural; compileFullDebugKotlin BUILD SUCCESSFUL.
Compiler-warning cleanup: redundant operators, AutoMirrored icons, shadows, always-true conditions
noop.fans has shut down, so the "Mirror: noop.fans" row in Settings → About was a broken
link (tapping it went nowhere). Remove it on both platforms and drop the "kept as a mirror"
clause from the project-home comment; the GitHub project-home link stays as the sole source.

- Android SettingsScreen: removed the mirror Box + comment (compiles clean).
- iOS SettingsView: removed the mirror Link + comment (Swift CI-gated).

Left untouched (historical, not live links): the dated in-app changelog entries that mention
noop.fans (AppChangelog) and the CHANGELOG.md/docs history — they record what happened at the
time and are already superseded by the later "back on GitHub" entry.
ryanbr and others added 27 commits July 7, 2026 14:42
The Live Body Console derives every status from activeConnection
(= live.connected && live.bonded). `bonded` carries WHOOP encrypted-bond
semantics and is deliberately never set by the Oura path (#69), so a
connected, authenticated, streaming ring fell through every WHOOP branch
to "Radio connected, stream not yet trusted." — even while HR was live.

Add a display-only `ringStreaming` predicate (live.connected &&
live.streamingLiveHR) and route the status layer through it: mode detail,
connection pill, mode badge, mode color, and the Heart rate / Connection
signal-trust tiles now read a ring stream as an active, trusted stream.

activeConnection itself is untouched, so bond-only feature gates (buzz,
alarm, HRV snapshot, encrypted control) still correctly key off the WHOOP
bond. Wear/Worn stats stay gated on activeConnection to avoid a false
"On wrist" until the ring's wear signal is confirmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shipped macOS app was arm64-only and wouldn't launch on Intel Macs. The build
used `-destination 'platform=macOS'`, which on the Apple-Silicon runners resolves to
the host arch and thins the binary — project.yml's `ONLY_ACTIVE_ARCH: NO` only applies
under `configs.Release`, so the Debug build inherited the default YES. Confirmed
against the shipped v8.3.4 zip: `lipo`/`file` report arm64 only, no x86_64 slice.

Force a true universal build in all three workflows (release, testing, CI verify) with
the generic destination plus explicit `ARCHS="x86_64 arm64" ONLY_ACTIVE_ARCH=NO`. These
are command-line build settings that override the config, so the configuration stays
Debug and nothing else about the shipping build changes. Added a `lipo -archs` guard to
the release + testing jobs that fails the build if the binary isn't universal, so this
can't silently regress again.

Fixes #51.
…ts read)

The verify step used `defaults read $APP/Contents/Info CFBundleExecutable` to
locate the binary, which failed on the runner. Find the Mach-O executable directly
under Contents/MacOS instead (skipping dylibs) — no plist parsing, space-safe.
… console (#56 parity)

iOS PR #56 makes the Live view treat an actively-streaming non-WHOOP source (the Oura
ring) as a trusted stream instead of "connecting / not yet trusted". Android had the
identical WHOOP-only status logic, but couldn't be fixed the same way because
LiveState.streamingLiveHR was an unfilled stub (hardcoded false) that nothing drove —
the ring's HR reaches the UI via publishExternalLiveHr, which set connected=true but
never streamingLiveHR. So the parity here is two parts:

1. Plumb the signal. publishExternalLiveHr (the non-WHOOP live-HR seam, invoked ONLY
   while WHOOP's own BLE is paused) now sets streamingLiveHR=true, and clearedBiometrics
   + the two remaining connected=false transitions clear it, keeping the invariant
   streamingLiveHR ⟹ connected. bonded stays false, so the buzz/alarm/HRV feature gates
   keep keying off the WHOOP bond — the ring never claims those.

2. Status copy (LiveScreen), mirroring iOS: a `ringStreaming = connected && streamingLiveHR`
   predicate feeds the mode badge ("STREAMING"), the pill ("Streaming", green), the mode
   colour (accent), the detail ("Heart rate stream is active."), and the signal-trust
   tiles (Heart rate "Streaming now", Connection "Streaming / Live stream, no WHOOP bond").
   Every ringStreaming branch sits AFTER the WHOOP activeConnection checks, so a bonded
   WHOOP is unaffected.

On Android the signal is driven for any external live source (ring, FTMS machine, generic
HR strap) — a superset of iOS's Oura-only, and correct for all of them.

Compiles (compileFullReleaseKotlin) and the BLE unit tests pass.
Android: recognize the Oura ring's live stream as trusted in the Live console (#56 parity)
CI: build the macOS app as a universal binary (x86_64 + arm64) (#51)
The upstream NoopApp/noop repo has been deleted (gh can't resolve it; its
raw.githubusercontent.com stats JSON returns 404), so a pile of references now
point at a dead repo:

  - README stats badges (raw.githubusercontent.com/NoopApp/noop/.../docs/stats/*.json → 404)
  - the bug-report issue template + its config (FAQ / Troubleshooting / WHOOP-5.0 /
    Discussions links) — the fork's wiki DOES have those pages, so they resolve on ryanbr
  - SUPPORT.md issue/discussion links
  - Tools/ dev scripts that hit the GitHub API (refresh-stats-badges.py, release.sh,
    update-altstore-source.sh)

Case-sensitive sweep of `NoopApp/noop` and the URL-encoded `NoopApp%2Fnoop` → ryanbr,
which deliberately leaves the Homebrew tap references (lowercase `noopapp/noop`,
`NoopApp/homebrew-noop`) untouched — `ryanbr/homebrew-noop` doesn't exist yet, so
repointing the brew instruction would just swap one dead tap for another. Homebrew is a
separate follow-up (create the tap first).

Not touched: historical release notes / CHANGELOG, the in-app "What's New" changelog
cards, and code provenance comments ("recipe in NoopApp/noop PR #600") — those are a
dated record, not live links. The app's actual update-check + Settings links already
point at ryanbr/noop.

Curated from the good parts of #54 (excludes its unrelated Android capture files); also
covers the issue templates, which #54 missed.
docs: repoint dead NoopApp/noop links to ryanbr/noop (curated from #54)
Two live-dead links to the deleted upstream repo that the #60 sweep missed:

- The in-app "What's New" AltStore-source instruction pointed at
  raw.githubusercontent.com/NoopApp/noop/main/altstore-source.json (404). The
  live manifest is at ryanbr/noop; repoint both platforms' changelog entry.
- tools/linux-capture/README.md linked issue #194 at NoopApp/noop (404) → ryanbr.

Targeted at those exact URLs only — the historical 'NOOP is back on GitHub'
changelog card (which narrates a past NoopApp move) is deliberately left as-is.
Android compiles.
docs: fix two remaining dead NoopApp/noop links (#60 follow-up)
@kavemang

kavemang commented Jul 8, 2026

Copy link
Copy Markdown
Author

Opened against the wrong base repository; superseded by ryanbr#77.

@kavemang kavemang closed this Jul 8, 2026
kavemang pushed a commit to kavemang/noop that referenced this pull request Jul 8, 2026
…m time (#34)

When a strap's clock/alarm register is corrupted it silently rejects SET_ALARM_TIME
— the readback keeps reporting an old time — and the firmware alarm never fires.
Today that mismatch is detectable only in the debug export, and only as a one-shot.

Instrumentation (silentestshadows#2 — observability only, no BLE command change):
- FrameRouter: on each GET_ALARM_TIME readback, count CONSECUTIVE rejections
  (reported != last sent). A matching readback resets the streak, so a transient
  (first read stale, then correct) never trips it; only a persistent refusal climbs.
- recordAlarmArm: persist the strap-clock skew AT ARM. Skew ~0 while the strap still
  rejects ⇒ a corrupted alarm register, not a clock problem — pins whether a re-clock
  could ever help.
- Debug export Alarm block surfaces both (clock-at-arm on Last arm; "N in a row" on
  Strap reports).
- disableStrapAlarm clears the streak (nothing armed to refuse once disarmed).

User warning (#4):
- SmartAlarmView shows a card, ONLY when the alarm is on and the strap has refused
  >=2 arms in a row, telling the user the strap isn't accepting the alarm and to
  reset it via the official WHOOP app / keep a phone Clock alarm. Driven by
  @AppStorage so it appears/clears live.

Not a fix for a corrupted strap (that's firmware-side, #34) — it turns a silent,
export-only failure into a persistent signal and actionable on-device guidance.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants