diff --git a/.github/workflows/pr-agent.yml b/.github/workflows/pr-agent.yml
index a1e2ce7e..1da003ef 100644
--- a/.github/workflows/pr-agent.yml
+++ b/.github/workflows/pr-agent.yml
@@ -13,9 +13,37 @@ jobs:
issues: write
pull-requests: write
contents: write
+ # Job-level so the step `if` below can see it: the `secrets` context is not
+ # available in an `if` expression, but `env` is.
+ env:
+ PR_AGENT_API_KEY: ${{ secrets.PR_AGENT_API_KEY }}
steps:
+ # A PR from a fork gets no secrets, so the step below ran with an empty
+ # key, reviewed nothing, and still went green - a check that says
+ # "reviewed" when it did not is worse than no check.
+ #
+ # Skipping the step alone did not fix that: the only step is skipped, the
+ # JOB still reports success, and a green required check still reads as a
+ # pass. So SAY SO, in the one place a reader of the PR looks - the check's
+ # summary - and make the log line an annotation on the PR itself.
+ - name: Not applicable - no review key on this PR
+ if: env.PR_AGENT_API_KEY == ''
+ run: |
+ echo "::notice title=PR Agent did not run::No review key is available \
+ on this pull request (forks get no secrets), so NOTHING was reviewed. \
+ A green check here means the job finished, not that the diff passed."
+ {
+ echo "## PR Agent: not applicable"
+ echo
+ echo "No \`PR_AGENT_API_KEY\` on this run - a fork PR gets no"
+ echo "secrets. **No review was performed.** Treat this check as"
+ echo "absent, not as a pass."
+ } >> "$GITHUB_STEP_SUMMARY"
- name: PR Agent action step
- uses: the-pr-agent/pr-agent@main
+ if: env.PR_AGENT_API_KEY != ''
+ # Pinned, not @main: this action runs with `contents: write` and a token
+ # on every PR, and a floating ref means whatever landed upstream today.
+ uses: the-pr-agent/pr-agent@f6af7d77554ff8d26adffded077e6461329e92fa # v0.42.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Credentials only. The model chain lives in .pr_agent.toml so it is
diff --git a/.pr_agent.toml b/.pr_agent.toml
index 7368da38..287cc67a 100644
--- a/.pr_agent.toml
+++ b/.pr_agent.toml
@@ -21,9 +21,13 @@ fallback_models = [
"openai/gpt-oss-120b-medium",
]
# Required: an `openai/`-prefixed name is not in PR-Agent's MAX_TOKENS map, and
-# get_max_tokens (algo/utils.py:1008) raises rather than defaulting. Effective
-# input is still min(this, max_model_tokens=32000).
+# get_max_tokens (algo/utils.py:1008) raises rather than defaulting.
custom_model_max_tokens = 200000
+# The effective input is min(custom_model_max_tokens, max_model_tokens), and
+# max_model_tokens defaults to 32000 - so the 200k above bought nothing and a
+# large diff was silently clipped to a third of the review it looked like it
+# got. Raise the ceiling to match.
+max_model_tokens = 200000
# Inject AGENTS.md as repository context into /review, /improve, /describe, /ask.
# NOTE: read from the DEFAULT BRANCH by default, so AGENTS.md only takes effect
diff --git a/PRIVACY.md b/PRIVACY.md
index 6efb2c43..f364053f 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -64,9 +64,10 @@ because a sideloaded app has no store to tell you a security fix exists.
**Barcode lookup for food logging**
The food log can read a barcode with the camera and fill in the nutrition
-figures for you. Doing that means asking a database, so it is **off until you
-turn it on**, and the App asks you before the first lookup ever happens — not
-after.
+figures for you. Doing that means asking a database, so it is **on by
+default**: what leaves is a number the manufacturer printed on the packet, and
+nothing about you goes with it. Turn it off and the scan stops asking anybody
+anything.
- **What is sent is the barcode.** It goes to openfoodfacts.org, the free and
open food database. Nothing about you, your meals, your health or your device
@@ -157,7 +158,7 @@ moment you tap it:
further upload immediately, and the App tells you when the last one was.
- **Check for updates** — turning it off stops the App making any network
request of its own accord.
-- **Look barcodes up online** — off by default; turning it off stops any
+- **Look barcodes up online** — on by default; turning it off stops any
further lookup immediately, and the food log keeps working by hand.
You can also disable AI Coach or Health app integration at any time in Settings
diff --git a/README.md b/README.md
index 5681a140..93d2c88c 100644
--- a/README.md
+++ b/README.md
@@ -79,7 +79,8 @@ drawer-bracelet problem can use it, or go dig through the code themselves.
## Checklist
-- **WHOOP 4.0 only.** Haven't touched a WHOOP 5, don't know if it even shares a protocol.
+- **WHOOP 4.0 is the one that's properly tested.** WHOOP 5 and MG work too, but they're
+ experimental — see the note further down.
- Not affiliated with WHOOP, doesn't talk to their servers.
- Not a clone of their algorithms — different math, published methods, cited in the
analytics repo. Don't expect identical numbers to what their app shows.
@@ -143,9 +144,10 @@ shortcuts, a smart alarm that buzzes the band.
against a lab, don't treat any of it as a diagnosis.
- Not on the App Store or Play Store yet. iOS is a public TestFlight beta, which is a
normal install but still a beta; Android is an APK straight off Releases.
-- WHOOP 5.0 / MG support is in progress and **experimental** — the band is detected and
- spoken to, but it hasn't been validated against real 5.0 hardware. WHOOP 4.0 is the
- only one that's actually tested.
+- WHOOP 5.0 / MG support is **experimental**. Both pair, sync and decode, and the work is
+ checked against real records off real bands — but 4.0 is the one I wear every day, so
+ it's the one that gets found out when it breaks. Expect rough edges on 5 and MG, and
+ open an issue when you hit one.
## Run it
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 27fd5b98..d98b57da 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -283,6 +283,28 @@
android:name="android.appwidget.provider"
android:resource="@xml/widget_openstrap_info" />
+
+
+
+
+
+
+
+
+
+
+
+ 0, so an unknown need leaves it empty
- // instead of filling against a fabricated 8h denominator.
- val needMin = w.readInt(prefs, "sleep_need_min", -1)
- val hrv = w.readInt(prefs, "hrv", -1)
- val hrvBaseline = w.readInt(prefs, "hrv_baseline", -1)
-
- // Ring fractions — negative means "nothing measured", so ringBitmap
- // draws the track alone rather than an arc pinned at empty (which reads
- // as a real value of zero).
- val readinessT = if (readiness >= 0) readiness / 100.0 else -1.0
- val tier = w.readInt(prefs, "readiness_tier", -1)
- val readinessArc = w.readinessArc(tier)
- val readinessColor = w.readinessColor(tier, pal)
- // 0-21 is the headline scale strainScore maps TRIMP onto
- // (analytics/lib/src/onehz/clinical/load_trimp.dart:104-122).
- val strainT = if (strain >= 0) (strain / 21.0).coerceAtMost(1.0) else -1.0
- val sleepT = if (sleepMin >= 0 && needMin > 0) {
- (sleepMin.toDouble() / needMin).coerceAtMost(1.0)
- } else {
- -1.0
- }
- // HRV against YOUR OWN baseline: a full ring is at or above it. There
- // is no population scale for RMSSD, so with no baseline there is no
- // denominator and the arc is not drawn. This used to divide by a
- // hard-coded 100 (and by 1.5 x baseline), neither of which exists
- // anywhere in the pipeline.
- val hrvT = if (hrv >= 0 && hrvBaseline > 0) {
- (hrv.toDouble() / hrvBaseline).coerceAtMost(1.0)
- } else {
- -1.0
- }
- // HRV carries its domain accent and no colour judgement: a "0.8 x
- // baseline is amber" cut-off was invented here and appears in no
- // analytics output.
- val hrvColor = if (hrv >= 0) w.GREEN else w.N400
-
- // "" = no measurement. A bare dash is the one rendering the phone's
- // grammar forbids outright.
- val strainText = if (strain >= 0) String.format("%.1f", strain) else ""
- val readinessText = if (readiness >= 0) "$readiness" else ""
- val hrvText = if (hrv >= 0) "$hrv" else ""
-
val layout = if (small) R.layout.widget_openstrap_small else R.layout.widget_openstrap
- val ringDp = if (small) 40 else 56
- val strokeDp = if (small) 5f else 7f
-
+ val dialDp = if (small) 30 else 44
+ val strokeDp = if (small) 4.5f else 6f
val views = RemoteViews(context.packageName, layout)
views.setInt(R.id.widget_root, "setBackgroundResource", pal.bgRes)
views.setOnClickPendingIntent(R.id.widget_root, w.openAppIntent(context))
- // Readiness leads the row; its VALUE carries the readiness colour (the
- // iOS headline treatment, compressed into a cell).
- views.setImageViewBitmap(
- R.id.ring_readiness,
- w.ringBitmap(context, ringDp, strokeDp, pal.track, readinessArc, readinessT),
- )
- views.setTextViewText(R.id.val_readiness, readinessText)
- views.setTextColor(R.id.val_readiness, readinessColor)
- views.setTextColor(R.id.cap_readiness, pal.inkMuted)
+ // Recovery wears its band's colour (from the published tier — the
+ // cut-offs are never re-derived here), the other two their domain accent.
+ val tier = w.readInt(prefs, "readiness_tier", -1)
+ var gap: Pair? = null
- fun metric(ring: Int, value: Int, cap: Int, bmpColor: Int, t: Double, text: String) {
+ for (slot in slots) {
+ val r = w.ring(prefs, slot.key)
+ val accent = when (slot.key) {
+ "recovery" -> w.tierColor(tier, pal)
+ "strain" -> pal.move
+ else -> pal.sleep
+ }
+ val tint = r.color(accent, pal)
views.setImageViewBitmap(
- ring,
- w.ringBitmap(context, ringDp, strokeDp, pal.track, bmpColor, t),
+ slot.dial,
+ w.dialBitmap(context, dialDp, strokeDp, pal.track, tint, r.frac, slot.iconRes),
)
- views.setTextViewText(value, text)
- views.setTextColor(value, pal.ink)
- views.setTextColor(cap, pal.inkMuted)
+ views.setTextViewText(slot.cap, slot.label)
+ views.setTextColor(slot.cap, pal.inkMuted)
+ // The absence takes the SENTENCE colour rather than the numeral
+ // one, because it is a sentence: "No sleep" in full-weight ink
+ // would read as a score.
+ views.setTextViewText(slot.value, r.value)
+ views.setTextColor(slot.value, if (r.measured) pal.ink else pal.ink2)
+ if (!small) {
+ views.setTextViewText(slot.sub, r.sub)
+ views.setTextColor(slot.sub, pal.inkMuted)
+ }
+ if (gap == null && r.why.isNotEmpty()) gap = slot.label to r.why
+ }
+
+ // The first ring that is missing and said why. One line is what a
+ // widget can afford; the rest is one tap away in the app.
+ if (!small) {
+ val g = gap
+ if (g == null) {
+ views.setViewVisibility(R.id.gap_row, View.GONE)
+ } else {
+ views.setViewVisibility(R.id.gap_row, View.VISIBLE)
+ views.setTextViewText(R.id.gap_row, "${g.first} · ${g.second}")
+ views.setTextColor(R.id.gap_row, pal.inkMuted)
+ }
}
- metric(R.id.ring_strain, R.id.val_strain, R.id.cap_strain, w.PURPLE, strainT, strainText)
- metric(R.id.ring_sleep, R.id.val_sleep, R.id.cap_sleep, w.BLUE, sleepT, w.hm(sleepMin))
- metric(R.id.ring_hrv, R.id.val_hrv, R.id.cap_hrv, hrvColor, hrvT, hrvText)
return views
}
diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/OvernightWidgetProvider.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/OvernightWidgetProvider.kt
new file mode 100644
index 00000000..5388ed32
--- /dev/null
+++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/OvernightWidgetProvider.kt
@@ -0,0 +1,84 @@
+package wtf.openstrap.openstrap_edge
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.content.SharedPreferences
+import android.view.View
+import android.widget.RemoteViews
+import es.antonborri.home_widget.HomeWidgetProvider
+
+/**
+ * The two things the band actually MEASURED while you slept — the Android
+ * sibling of OpenStrapOvernightWidget.swift.
+ *
+ * It exists because the rebuilt home screen has three rings and HRV is not one
+ * of them, so OpenStrapWidgetProvider dropped the HRV ring it used to carry.
+ * This is where that number went, and it is a better home for it: HRV means
+ * nothing against a population and everything against your own baseline.
+ *
+ * HRV IS DRAWN AGAINST YOUR OWN BASELINE AND NOTHING ELSE. Full ring at or
+ * above it; with no baseline there is no denominator, so there is no arc. It
+ * carries the Health domain accent and no colour judgement — a "0.8 x baseline
+ * is amber" cut-off was invented on this surface once and appears in no
+ * analytics output.
+ */
+class OvernightWidgetProvider : HomeWidgetProvider() {
+
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray,
+ widgetData: SharedPreferences,
+ ) {
+ val w = StrapWidgets
+ val pal = w.pal(widgetData)
+
+ val views = if (!w.fresh(widgetData)) {
+ RemoteViews(context.packageName, R.layout.widget_openstrap_nodata).apply {
+ setTextColor(R.id.nodata_title, pal.ink)
+ setTextColor(R.id.nodata_body, pal.inkMuted)
+ }
+ } else {
+ val hrv = w.readInt(widgetData, "hrv", -1)
+ val base = w.readInt(widgetData, "hrv_baseline", -1)
+ val rhr = w.readInt(widgetData, "rhr", -1)
+ val frac = if (hrv >= 0 && base > 0) {
+ (hrv.toDouble() / base).coerceAtMost(1.0)
+ } else {
+ -1.0
+ }
+ RemoteViews(context.packageName, R.layout.widget_overnight).apply {
+ setImageViewBitmap(
+ R.id.dial_hrv,
+ w.dialBitmap(
+ context, 38, 5.5f, pal.track,
+ if (hrv >= 0) pal.good else pal.inkMuted, frac,
+ R.drawable.ic_widget_hrv,
+ ),
+ )
+ setTextColor(R.id.cap_hrv, pal.inkMuted)
+ setTextColor(R.id.cap_rhr, pal.inkMuted)
+ // The absence is a WORD, in the sentence colour. Never a dash,
+ // and never a zero — a zero RMSSD is a claim about a heart.
+ setTextViewText(R.id.val_hrv, if (hrv >= 0) "$hrv ms" else "Not measured")
+ setTextColor(R.id.val_hrv, if (hrv >= 0) pal.good else pal.ink2)
+ setTextViewText(R.id.sub_hrv, if (base > 0) "base $base ms" else "")
+ setTextColor(R.id.sub_hrv, pal.inkMuted)
+ setTextViewText(R.id.val_rhr, if (rhr >= 0) "$rhr bpm" else "Not measured")
+ setTextColor(R.id.val_rhr, if (rhr >= 0) pal.ink else pal.ink2)
+ // Why, when there is a why — the held-over night's reason
+ // first, then the night's own, and nothing when neither said.
+ // A reason is never written here.
+ val why = (widgetData.getString("overnight_why", "") ?: "")
+ .ifEmpty { w.ring(widgetData, "sleep").why }
+ val foot = if (hrv < 0 && rhr < 0) why else ""
+ setViewVisibility(R.id.foot, if (foot.isEmpty()) View.GONE else View.VISIBLE)
+ setTextViewText(R.id.foot, foot)
+ setTextColor(R.id.foot, pal.inkMuted)
+ }
+ }
+ views.setInt(R.id.widget_root, "setBackgroundResource", pal.bgRes)
+ views.setOnClickPendingIntent(R.id.widget_root, w.openAppIntent(context))
+ for (id in appWidgetIds) appWidgetManager.updateAppWidget(id, views)
+ }
+}
diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/SleepWidgetProvider.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/SleepWidgetProvider.kt
new file mode 100644
index 00000000..2f59a95b
--- /dev/null
+++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/SleepWidgetProvider.kt
@@ -0,0 +1,71 @@
+package wtf.openstrap.openstrap_edge
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.content.SharedPreferences
+import android.view.View
+import android.widget.RemoteViews
+import es.antonborri.home_widget.HomeWidgetProvider
+
+/**
+ * Last night, on its own — the Android sibling of OpenStrapSleepWidget.swift.
+ *
+ * The one number people look for before they open anything. It is the trio's
+ * sleep ring at full size plus the figure that does not fit in a third of a
+ * card: efficiency.
+ *
+ * Everything it renders is resolved by WidgetService.push, including whether
+ * there is a need to measure the night against at all — a night with no LEARNED
+ * need draws an open track and says so rather than filling against a hardcoded
+ * 8 h that is not this user's.
+ */
+class SleepWidgetProvider : HomeWidgetProvider() {
+
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray,
+ widgetData: SharedPreferences,
+ ) {
+ val w = StrapWidgets
+ val pal = w.pal(widgetData)
+ val fresh = w.fresh(widgetData)
+
+ val views = if (!fresh) {
+ RemoteViews(context.packageName, R.layout.widget_openstrap_nodata).apply {
+ setTextColor(R.id.nodata_title, pal.ink)
+ setTextColor(R.id.nodata_body, pal.inkMuted)
+ }
+ } else {
+ val r = w.ring(widgetData, "sleep")
+ val eff = w.readInt(widgetData, "sleep_efficiency", -1)
+ RemoteViews(context.packageName, R.layout.widget_sleep).apply {
+ setImageViewBitmap(
+ R.id.dial_sleep,
+ w.dialBitmap(
+ context, 52, 7f, pal.track, r.color(pal.sleep, pal), r.frac,
+ R.drawable.ic_widget_sleep,
+ ),
+ )
+ setTextColor(R.id.cap_sleep, pal.inkMuted)
+ setTextViewText(R.id.val_sleep, r.value)
+ setTextColor(R.id.val_sleep, if (r.measured) pal.ink else pal.ink2)
+ setTextViewText(R.id.sub_sleep, r.sub)
+ setTextColor(R.id.sub_sleep, pal.inkMuted)
+ // Efficiency when the night has one, the reason when it does
+ // not, and nothing at all when there is neither — never a dash.
+ val foot = when {
+ r.measured && eff >= 0 -> "$eff% efficient"
+ !r.measured -> r.why
+ else -> ""
+ }
+ setViewVisibility(R.id.foot, if (foot.isEmpty()) View.GONE else View.VISIBLE)
+ setTextViewText(R.id.foot, foot)
+ setTextColor(R.id.foot, pal.inkMuted)
+ }
+ }
+ views.setInt(R.id.widget_root, "setBackgroundResource", pal.bgRes)
+ views.setOnClickPendingIntent(R.id.widget_root, w.openAppIntent(context))
+ for (id in appWidgetIds) appWidgetManager.updateAppWidget(id, views)
+ }
+}
diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/StrapWidgets.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/StrapWidgets.kt
index 1ab0cd0c..a2626ba9 100644
--- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/StrapWidgets.kt
+++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/StrapWidgets.kt
@@ -8,46 +8,56 @@ import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
+import androidx.core.content.ContextCompat
/**
- * Shared bits for the home-screen widgets (see OpenStrapWidgetProvider /
- * OpenStrapBatteryWidgetProvider) — the palette, readers for the home_widget
- * snapshot, the freshness rule, and the arc-ring renderer.
+ * Shared bits for the home-screen widgets — the palette, readers for the
+ * home_widget snapshot, the freshness rule, and the dial renderer. The Kotlin
+ * half of ios/OpenStrapWidget/StrapWidgetKit.swift, so the two platforms read
+ * as the same product.
*
- * The palette and all value/colour rules mirror the Swift widgets under
- * ios/OpenStrapWidget exactly, so the two platforms read as the same product.
- * Both now spend lib/ui2/theme.dart's tokens rather than the retired
- * lib/theme/tokens.dart ones. Rings are pre-rendered as bitmaps because
- * RemoteViews can't draw arcs.
+ * WHAT THIS SIDE DECIDES: layout, and nothing else. Whether a ring is a
+ * reading, calibration progress or an absence — and what any of them SAY —
+ * arrives already resolved from `WidgetService.push`, which mirrors `RingTrio`
+ * on Home. The rule used to live in Dart AND Swift AND here, and the three
+ * copies disagreed about the same day.
+ *
+ * Dials are pre-rendered as bitmaps because RemoteViews cannot draw arcs.
*/
internal object StrapWidgets {
- // ── lib/ui2/theme.dart (mirrors Pal in OpenStrapWidget.swift) ───────────
- // A widget is a card, so surfaces are `P.card` over a `P.track` ring track
- // and `P.ink3` captions. `onGood`/`onWarn`/`onBad`/`onNone` are the tier
- // accents as TEXT — `P.on()`'s output, which nudges an accent toward the
- // page ink until it clears WCAG AA on the worst surface it can land on.
- // Arcs are non-text UI and spend the raw `C.*` pigment below.
+ // ── lib/ui2/theme.dart (mirrors SW.Pal in StrapWidgetKit.swift) ────────
+ // A widget is a card, so surfaces are `P.card` over a `P.track` ring track,
+ // `P.ink` numerals and `P.ink3` captions.
class Pal(
val bgRes: Int,
val ink: Int,
+ val ink2: Int,
val inkMuted: Int,
val track: Int,
- val onGood: Int,
- val onWarn: Int,
- val onBad: Int,
- val onNone: Int,
+ val good: Int,
+ val warn: Int,
+ val bad: Int,
+ val sleep: Int,
+ val move: Int,
)
+ // `P.on(accent)` per brightness — ui2 nudges an accent toward the page ink
+ // until it clears WCAG AA 4.5:1 on the worst surface it can land on, and a
+ // ring spends that solved value for BOTH its arc and its number (see
+ // `_RingState.arc` / `.ink` in home_screen.dart). Recomputing these means
+ // running P.on's binary search, not eyeballing a hex.
private val LIGHT = Pal(
- R.drawable.widget_bg_paper, 0xFF0F172A.toInt(),
- 0xFF627188.toInt(), 0xFFE2E8F0.toInt(),
- 0xFF1A7948.toInt(), 0xFFA5521D.toInt(), 0xFFB9393E.toInt(), 0xFF606B80.toInt(),
+ R.drawable.widget_bg_paper,
+ 0xFF0F172A.toInt(), 0xFF475569.toInt(), 0xFF627188.toInt(), 0xFFE2E8F0.toInt(),
+ 0xFF1A7A48.toInt(), 0xFFA5521D.toInt(), 0xFFB9393E.toInt(),
+ 0xFF2F66C0.toInt(), 0xFF734FCF.toInt(),
)
private val DARK = Pal(
- R.drawable.widget_bg_char, 0xFFF1F5F9.toInt(),
- 0xFF7F8DA0.toInt(), 0xFF232D3B.toInt(),
- 0xFF22C55E.toInt(), 0xFFF87F2A.toInt(), 0xFFEF7373.toInt(), 0xFF97A6BA.toInt(),
+ R.drawable.widget_bg_char,
+ 0xFFF1F5F9.toInt(), 0xFF94A3B8.toInt(), 0xFF7F8DA0.toInt(), 0xFF232D3B.toInt(),
+ 0xFF22C55E.toInt(), 0xFFF87E28.toInt(), 0xFFF07374.toInt(),
+ 0xFF689EF7.toInt(), 0xFFA988F7.toInt(),
)
// Raw pigment — `C` in lib/ui2/theme.dart. Arcs and fills only.
@@ -71,9 +81,17 @@ internal object StrapWidgets {
*/
private const val STALE_AFTER_SEC = 26L * 3600
+ /** The three home rings, in Home's order. */
+ val RING_KEYS = listOf("recovery", "strain", "sleep")
+
/** Is the published snapshot still today's answer? */
fun fresh(prefs: SharedPreferences): Boolean {
if (!prefs.getBoolean("has_data", false)) return false
+ // A snapshot written by an app version older than the rings has every
+ // ring value empty, which draws circles with nothing in them. It heals
+ // on the first push (the app publishes on every foreground); until then
+ // the no-data state is the honest picture.
+ if (RING_KEYS.all { prefs.getString("ring_${it}_value", "").isNullOrEmpty() }) return false
val at = readLong(prefs, "updated_at", 0)
// An unknown timestamp is not a claim of staleness (matching
// WidgetService.isStale); a snapshot never pushed has has_data false.
@@ -82,24 +100,17 @@ internal object StrapWidgets {
}
/**
- * Readiness tier -> arc pigment. The THRESHOLDS are not here: Dart publishes
- * `readiness_tier` (see `readinessBand` in lib/ui2/screens/home_screen.dart)
- * so the phone, the widget, the watch and Siri cannot disagree about what a
- * score of 65 means. Never re-derive a band from the raw number.
+ * Readiness tier -> its accent, arc and numeral alike. The THRESHOLDS are
+ * not here: Dart publishes `readiness_tier` (see `readinessBand` in
+ * lib/ui2/screens/home_screen.dart) so the phone, the widget, the watch and
+ * Siri cannot disagree about what a score of 65 means. Never re-derive a
+ * band from the raw number.
*/
- fun readinessArc(tier: Int): Int = when (tier) {
- 3, 2 -> GREEN
- 1 -> ORANGE
- 0 -> RED
- else -> N400
- }
-
- /** The same tier, solved for TEXT. */
- fun readinessColor(tier: Int, pal: Pal): Int = when (tier) {
- 3, 2 -> pal.onGood
- 1 -> pal.onWarn
- 0 -> pal.onBad
- else -> pal.onNone
+ fun tierColor(tier: Int, pal: Pal): Int = when (tier) {
+ 3, 2 -> pal.good
+ 1 -> pal.warn
+ 0 -> pal.bad
+ else -> pal.inkMuted
}
/// The app mirrors its in-app appearance into `theme_dark` (see
@@ -139,6 +150,35 @@ internal object StrapWidgets {
else -> def
}
+ // ── the resolved rings ───────────────────────────────────────────────────
+ /** One home ring exactly as Dart published it. */
+ class RingData(
+ /** 0 measured · 1 calibrating · 2 absent. */
+ val state: Int,
+ /** The number, or the absence IN WORDS — never a dash. */
+ val value: String,
+ /** What it is out of, the readiness band, or the nights banked. */
+ val sub: String,
+ /** The pipeline's own reason. Absent rings only. */
+ val why: String,
+ /** What to sweep, 0..1 — negative when there is nothing honest to sweep. */
+ val frac: Double,
+ ) {
+ val measured: Boolean get() = state == 0
+
+ /** Arc and numeral share one colour, and the colour IS the signal that
+ * this is not a reading. */
+ fun color(accent: Int, pal: Pal): Int = if (measured) accent else pal.inkMuted
+ }
+
+ fun ring(prefs: SharedPreferences, key: String): RingData = RingData(
+ readInt(prefs, "ring_${key}_state", 2),
+ prefs.getString("ring_${key}_value", "") ?: "",
+ prefs.getString("ring_${key}_sub", "") ?: "",
+ prefs.getString("ring_${key}_why", "") ?: "",
+ readDouble(prefs, "ring_${key}_frac", -1.0),
+ )
+
// ── formatting ───────────────────────────────────────────────────────────
/** "45m" / "7h 05m" — the phone's own `hm()` (lib/ui2/screens/home_screen.dart),
* so the same night reads identically on the phone, the widget and iOS.
@@ -181,6 +221,36 @@ internal object StrapWidgets {
return bmp
}
+ /**
+ * The dial: the arc with the ring's ICON at its centre, as on Home. The
+ * number lives UNDER the dial, not inside it — inside is where "7h 45m"
+ * overflows its own circle at the first accessibility step, and nothing
+ * about that string gets shorter.
+ *
+ * The icon is drawn into the same bitmap rather than stacked as a second
+ * RemoteViews child: one view per dial, and the tint cannot drift from the
+ * arc it sits in.
+ */
+ fun dialBitmap(
+ context: Context,
+ sizeDp: Int,
+ strokeDp: Float,
+ trackColor: Int,
+ color: Int,
+ t: Double,
+ iconRes: Int,
+ ): Bitmap {
+ val bmp = ringBitmap(context, sizeDp, strokeDp, trackColor, color, t)
+ val icon = ContextCompat.getDrawable(context, iconRes) ?: return bmp
+ val px = bmp.width
+ val side = (px * 0.34f).toInt().coerceAtLeast(1)
+ val left = (px - side) / 2
+ icon.setBounds(left, left, left + side, left + side)
+ icon.setTint(color)
+ icon.draw(Canvas(bmp))
+ return bmp
+ }
+
/** Tap anywhere on a widget → open the app. */
fun openAppIntent(context: Context): PendingIntent =
PendingIntent.getActivity(
diff --git a/android/app/src/main/res/drawable/ic_widget_hrv.xml b/android/app/src/main/res/drawable/ic_widget_hrv.xml
new file mode 100644
index 00000000..bfa895bd
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_widget_hrv.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_widget_recovery.xml b/android/app/src/main/res/drawable/ic_widget_recovery.xml
new file mode 100644
index 00000000..2279d33b
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_widget_recovery.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_widget_sleep.xml b/android/app/src/main/res/drawable/ic_widget_sleep.xml
new file mode 100644
index 00000000..5d8748e0
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_widget_sleep.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_widget_strain.xml b/android/app/src/main/res/drawable/ic_widget_strain.xml
new file mode 100644
index 00000000..7eef5203
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_widget_strain.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_openstrap.xml b/android/app/src/main/res/layout/widget_openstrap.xml
index 02242777..73407468 100644
--- a/android/app/src/main/res/layout/widget_openstrap.xml
+++ b/android/app/src/main/res/layout/widget_openstrap.xml
@@ -1,8 +1,11 @@
+ android:padding="10dp">
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_openstrap_small.xml b/android/app/src/main/res/layout/widget_openstrap_small.xml
index 7148164a..b6cbc9b2 100644
--- a/android/app/src/main/res/layout/widget_openstrap_small.xml
+++ b/android/app/src/main/res/layout/widget_openstrap_small.xml
@@ -1,7 +1,8 @@
-
-
+ android:padding="10dp">
-
-
-
-
+ android:layout_marginTop="0dp"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+
+
+
-
-
+
+
-
-
-
-
+ android:layout_marginTop="6dp"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
+ android:layout_marginTop="6dp"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+
+
+
-
-
+
+
-
diff --git a/android/app/src/main/res/layout/widget_overnight.xml b/android/app/src/main/res/layout/widget_overnight.xml
new file mode 100644
index 00000000..29f61d44
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_overnight.xml
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_sleep.xml b/android/app/src/main/res/layout/widget_sleep.xml
new file mode 100644
index 00000000..90a48fca
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_sleep.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/widget_strings.xml b/android/app/src/main/res/values/widget_strings.xml
index 73402773..6cb8612d 100644
--- a/android/app/src/main/res/values/widget_strings.xml
+++ b/android/app/src/main/res/values/widget_strings.xml
@@ -1,7 +1,14 @@
OpenStrap
- Readiness, strain, sleep and HRV at a glance.
+ Recovery, strain and sleep at a glance.Band batteryYour band\'s battery, from the last connection.
+ SLEEP
+ Last night
+ How long you slept, against the need the app has learned.
+ Overnight
+ Last night\'s HRV against your own baseline, and resting heart rate.
+ HRV
+ RESTING HR
diff --git a/android/app/src/main/res/xml/widget_overnight_info.xml b/android/app/src/main/res/xml/widget_overnight_info.xml
new file mode 100644
index 00000000..7a456ee4
--- /dev/null
+++ b/android/app/src/main/res/xml/widget_overnight_info.xml
@@ -0,0 +1,15 @@
+
+
diff --git a/android/app/src/main/res/xml/widget_sleep_info.xml b/android/app/src/main/res/xml/widget_sleep_info.xml
new file mode 100644
index 00000000..80d7955d
--- /dev/null
+++ b/android/app/src/main/res/xml/widget_sleep_info.xml
@@ -0,0 +1,16 @@
+
+
diff --git a/docs/privacy.html b/docs/privacy.html
index d4d14bb8..29c1345d 100644
--- a/docs/privacy.html
+++ b/docs/privacy.html
@@ -68,9 +68,10 @@
Anonymous diagnostics
Barcode lookup for food logging
The food log can read a barcode with the camera and fill in the nutrition
- figures for you. Doing that means asking a database, so it is off
- until you turn it on, and the App asks you before the first
- lookup ever happens — not after.
+ figures for you. Doing that means asking a database, so it is on
+ by default: what leaves is a number the manufacturer printed on
+ the packet, and nothing about you goes with it. Turn it off and the scan
+ stops asking anybody anything.
What is sent is the barcode. It goes to
openfoodfacts.org, the free and open food database. Nothing about you,
@@ -164,8 +165,8 @@
Your controls
integration at any time in Settings if you'd previously turned them on. If
you explicitly installed a GitHub release and enabled health data
contribution, you can disable that feature at any time from the app's
- settings. Barcode lookup for the food log is off by default and can be
- turned off again at any time in Settings › Privacy ›
+ settings. Barcode lookup for the food log is on by default and can be
+ turned off at any time in Settings › Privacy ›
“Look barcodes up online”.
Uninstalling the App deletes all of your locally stored data immediately.
diff --git a/ios/OpenStrapWidget/OpenStrapOvernightWidget.swift b/ios/OpenStrapWidget/OpenStrapOvernightWidget.swift
new file mode 100644
index 00000000..12a8f0f7
--- /dev/null
+++ b/ios/OpenStrapWidget/OpenStrapOvernightWidget.swift
@@ -0,0 +1,186 @@
+//
+// OpenStrapOvernightWidget.swift
+// OpenStrapWidget
+//
+// The two things the band actually MEASURED while you slept: nocturnal HRV
+// (RMSSD, from beat-to-beat intervals) and resting heart rate. Everything else
+// on a widget is a composite of them.
+//
+// It exists because the rebuilt home screen has three rings and HRV is not one
+// of them — Recovery, Strain and Sleep are — so the redesigned OpenStrapWidget
+// dropped the HRV ring it used to carry. This is where that number went, and
+// it is a better home for it: HRV means nothing against a population and
+// everything against your own baseline, which needs the room to say so.
+//
+// HRV IS DRAWN AGAINST YOUR OWN BASELINE AND NOTHING ELSE. Full ring at or
+// above it. With no baseline there is no denominator, so there is no arc —
+// this used to divide by a hardcoded 100, a number that exists nowhere in the
+// pipeline.
+//
+
+import WidgetKit
+import SwiftUI
+
+struct OvernightEntry: TimelineEntry {
+ var date: Date
+ let snap: SW.Snapshot
+
+ var fresh: Bool { SW.fresh(snap, at: date) }
+ static let placeholder = OvernightEntry(date: Date(), snap: .placeholder)
+
+ var hrvFrac: Double {
+ guard snap.hrv >= 0, snap.hrvBaseline > 0 else { return -1 }
+ return min(Double(snap.hrv) / Double(snap.hrvBaseline), 1)
+ }
+
+ /// Why there are no overnight numbers, as the app said it — the held-over
+ /// night's reason first, then the night's own. Empty when nothing said why,
+ /// in which case the widget says the value is missing and stops there rather
+ /// than inventing a cause.
+ var why: String {
+ if !snap.overnightWhy.isEmpty { return snap.overnightWhy }
+ return snap.sleep.why
+ }
+
+ var hasAny: Bool { snap.hrv >= 0 || snap.rhr >= 0 }
+}
+
+struct OvernightProvider: TimelineProvider {
+ func placeholder(in context: Context) -> OvernightEntry { .placeholder }
+
+ func getSnapshot(in context: Context, completion: @escaping (OvernightEntry) -> Void) {
+ completion(context.isPreview ? .placeholder : OvernightEntry(date: Date(), snap: SW.read()))
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
+ let snap = SW.read()
+ completion(SW.timeline(snap, Date()) { OvernightEntry(date: $0, snap: snap) })
+ }
+}
+
+/// One measured overnight figure: label, number, unit, and what it is being
+/// read against. Absent prints the word, in the sentence weight.
+private struct Figure: View {
+ let label: String
+ let value: Int
+ let unit: String
+ let against: String
+ let accent: Color
+
+ var body: some View {
+ let p = SW.pal
+ VStack(alignment: .leading, spacing: 1) {
+ Text(label.uppercased()).font(SW.over).tracking(0.5).foregroundStyle(p.ink3)
+ if value >= 0 {
+ HStack(alignment: .firstTextBaseline, spacing: 3) {
+ Text("\(value)").font(SW.num(24)).foregroundStyle(accent)
+ Text(unit).font(SW.cap).foregroundStyle(p.ink3)
+ }
+ if !against.isEmpty {
+ Text(against).font(SW.cap).foregroundStyle(p.ink3).lineLimit(1)
+ }
+ } else {
+ Text("Not measured").font(SW.body).foregroundStyle(p.ink2)
+ }
+ }
+ }
+}
+
+private struct OvernightSmallView: View {
+ let e: OvernightEntry
+
+ var body: some View {
+ let p = SW.pal
+ let s = e.snap
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(spacing: 10) {
+ // The HRV dial carries no colour judgement — green is the Health
+ // domain's accent, not a verdict. A "0.8 × baseline is amber" cut-off
+ // was invented on this surface once and appears in no analytics output.
+ SW.Dial(
+ r: SW.RingData(state: s.hrv >= 0 ? 0 : 2, value: "", sub: "", why: "",
+ frac: e.hrvFrac),
+ symbol: "waveform.path.ecg", accent: p.good, size: 40, line: 6)
+ Figure(label: "HRV", value: s.hrv, unit: "ms",
+ against: s.hrvBaseline > 0 ? "base \(s.hrvBaseline)" : "",
+ accent: p.good)
+ Spacer(minLength: 0)
+ }
+ Divider()
+ Figure(label: "Resting HR", value: s.rhr, unit: "bpm", against: "", accent: p.ink)
+ if !e.hasAny, !e.why.isEmpty {
+ Text(e.why).font(.system(size: 11)).foregroundStyle(p.ink3).lineLimit(3)
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(12)
+ }
+}
+
+struct OpenStrapOvernightWidgetEntryView: View {
+ @Environment(\.widgetFamily) var family
+ var entry: OvernightEntry
+
+ var body: some View {
+ content.strapBackground(family)
+ }
+
+ private var line: String {
+ let s = entry.snap
+ let parts = [s.hrv >= 0 ? "HRV \(s.hrv) ms" : nil,
+ s.rhr >= 0 ? "RHR \(s.rhr)" : nil].compactMap { $0 }
+ return parts.isEmpty ? "" : parts.joined(separator: " ")
+ }
+
+ @ViewBuilder private var content: some View {
+ if !entry.fresh {
+ SW.NoData()
+ } else {
+ switch family {
+ case .accessoryCircular:
+ if entry.snap.hrv >= 0, entry.hrvFrac >= 0 {
+ Gauge(value: entry.hrvFrac) {
+ Text("HRV")
+ } currentValueLabel: {
+ Text("\(entry.snap.hrv)")
+ }
+ .gaugeStyle(.accessoryCircular)
+ .widgetAccentable()
+ } else {
+ VStack(spacing: 0) {
+ Image(systemName: "waveform.path.ecg").font(.system(size: 14)).widgetAccentable()
+ Text(entry.snap.hrv >= 0 ? "\(entry.snap.hrv)" : "HRV")
+ .font(.system(size: 10, weight: .semibold))
+ }
+ }
+ case .accessoryRectangular:
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Overnight").font(.system(size: 11, weight: .semibold)).widgetAccentable()
+ Text(entry.hasAny ? line : "Not measured")
+ .font(.system(size: 15, weight: .bold))
+ Text(entry.hasAny
+ ? (entry.snap.hrvBaseline > 0 ? "Your baseline \(entry.snap.hrvBaseline) ms" : "")
+ : entry.why)
+ .font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(2)
+ }
+ case .accessoryInline:
+ Text(entry.snap.hrv >= 0 ? "HRV \(entry.snap.hrv) ms" : "OpenStrap · HRV not measured")
+ default: OvernightSmallView(e: entry)
+ }
+ }
+ }
+}
+
+struct OpenStrapOvernightWidget: Widget {
+ let kind: String = "OpenStrapOvernightWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: OvernightProvider()) { entry in
+ OpenStrapOvernightWidgetEntryView(entry: entry)
+ }
+ .configurationDisplayName("Overnight")
+ .description("Last night's HRV against your own baseline, and resting heart rate.")
+ .supportedFamilies([.systemSmall, .accessoryCircular,
+ .accessoryRectangular, .accessoryInline])
+ }
+}
diff --git a/ios/OpenStrapWidget/OpenStrapSleepWidget.swift b/ios/OpenStrapWidget/OpenStrapSleepWidget.swift
new file mode 100644
index 00000000..28834e5c
--- /dev/null
+++ b/ios/OpenStrapWidget/OpenStrapSleepWidget.swift
@@ -0,0 +1,138 @@
+//
+// OpenStrapSleepWidget.swift
+// OpenStrapWidget
+//
+// Last night, on its own. The one number people look for before they open
+// anything, and the moment they want it — first unlock of the morning — is the
+// moment a lock-screen widget is on screen anyway.
+//
+// It is the trio's sleep ring at full size plus the one figure that does not
+// fit in a third of a card: efficiency. Everything it renders is resolved by
+// `WidgetService.push`, including whether there is a need to measure the night
+// against at all — a night with no LEARNED need draws an open track and says
+// "No target yet" rather than filling against a hardcoded 8 h that is not this
+// user's.
+//
+
+import WidgetKit
+import SwiftUI
+
+struct SleepEntry: TimelineEntry {
+ var date: Date
+ let snap: SW.Snapshot
+
+ var fresh: Bool { SW.fresh(snap, at: date) }
+ static let placeholder = SleepEntry(date: Date(), snap: .placeholder)
+}
+
+struct SleepProvider: TimelineProvider {
+ func placeholder(in context: Context) -> SleepEntry { .placeholder }
+
+ func getSnapshot(in context: Context, completion: @escaping (SleepEntry) -> Void) {
+ completion(context.isPreview ? .placeholder : SleepEntry(date: Date(), snap: SW.read()))
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
+ let snap = SW.read()
+ completion(SW.timeline(snap, Date()) { SleepEntry(date: $0, snap: snap) })
+ }
+}
+
+private struct SleepSmallView: View {
+ let snap: SW.Snapshot
+
+ var body: some View {
+ let p = SW.pal
+ let r = snap.sleep
+ VStack(spacing: 6) {
+ SW.Dial(r: r, symbol: "moon.fill", accent: p.sleep, size: 60, line: 8)
+ SW.RingText(label: "Sleep", r: r, accent: p.sleep, valueSize: 22)
+ // Efficiency only when the night has one. It is the share of time in bed
+ // actually asleep, and there is no honest placeholder for it.
+ if r.measured, snap.efficiency >= 0 {
+ Text("\(snap.efficiency)% efficient")
+ .font(SW.cap).foregroundStyle(p.ink3).lineLimit(1)
+ } else if !r.why.isEmpty {
+ Text(r.why)
+ .font(.system(size: 11)).foregroundStyle(p.ink3)
+ .multilineTextAlignment(.center).lineLimit(3)
+ }
+ }
+ .padding(12)
+ }
+}
+
+private struct SleepRectangularView: View {
+ let snap: SW.Snapshot
+ var body: some View {
+ let r = snap.sleep
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Last night").font(.system(size: 11, weight: .semibold)).widgetAccentable()
+ Text(r.value).font(.system(size: 16, weight: .bold))
+ Text(r.measured
+ ? [r.sub, snap.efficiency >= 0 ? "\(snap.efficiency)% efficient" : nil]
+ .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ")
+ : r.why)
+ .font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(2)
+ }
+ }
+}
+
+struct OpenStrapSleepWidgetEntryView: View {
+ @Environment(\.widgetFamily) var family
+ var entry: SleepEntry
+
+ var body: some View {
+ content.strapBackground(family)
+ }
+
+ @ViewBuilder private var content: some View {
+ if !entry.fresh {
+ SW.NoData()
+ } else {
+ switch family {
+ case .accessoryCircular:
+ let r = entry.snap.sleep
+ // An arc only when there is a need to measure the night against.
+ if r.measured, r.frac >= 0 {
+ Gauge(value: min(r.frac, 1)) {
+ Image(systemName: "moon.fill")
+ } currentValueLabel: {
+ Text(r.value).minimumScaleFactor(0.5)
+ }
+ .gaugeStyle(.accessoryCircular)
+ .widgetAccentable()
+ } else {
+ // Measured but unscaled (no learned need) prints the duration with no
+ // ring; absent prints the glyph and the word alone. Neither draws an
+ // arc, because an arc at zero reads as a night with no sleep in it.
+ VStack(spacing: 0) {
+ Image(systemName: "moon.fill").font(.system(size: 13)).widgetAccentable()
+ Text(r.measured ? r.value : "SLEEP")
+ .font(.system(size: 10, weight: .semibold)).minimumScaleFactor(0.6)
+ }
+ }
+ case .accessoryRectangular: SleepRectangularView(snap: entry.snap)
+ case .accessoryInline:
+ Text(entry.snap.sleep.measured
+ ? "Slept \(entry.snap.sleep.value)"
+ : "OpenStrap · \(entry.snap.sleep.value.lowercased())")
+ default: SleepSmallView(snap: entry.snap)
+ }
+ }
+ }
+}
+
+struct OpenStrapSleepWidget: Widget {
+ let kind: String = "OpenStrapSleepWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: SleepProvider()) { entry in
+ OpenStrapSleepWidgetEntryView(entry: entry)
+ }
+ .configurationDisplayName("Last night")
+ .description("How long you slept, against the need the app has learned.")
+ .supportedFamilies([.systemSmall, .accessoryCircular,
+ .accessoryRectangular, .accessoryInline])
+ }
+}
diff --git a/ios/OpenStrapWidget/OpenStrapWidget.swift b/ios/OpenStrapWidget/OpenStrapWidget.swift
index 8dfe7319..61b915de 100644
--- a/ios/OpenStrapWidget/OpenStrapWidget.swift
+++ b/ios/OpenStrapWidget/OpenStrapWidget.swift
@@ -2,221 +2,42 @@
// OpenStrapWidget.swift
// OpenStrapWidget
//
-// Home/lock-screen widget — renders the snapshot the app writes into the shared
-// App Group. Nothing else: this is a local-first app with no backend and no
-// account, so there is nothing for the widget to fetch. (It used to carry a
-// "self-refreshes hourly by fetching /today" path guarded on a JWT that no code
-// ever wrote — dead on every install, and its parser hard-wrote has_data = true,
-// which would have clobbered the app's staleness gate the moment anyone wired it
-// up.) The app calls WidgetService.refresh() after every derive; that is the
-// only refresh there is.
+// The home/lock-screen face of the app's daily snapshot. Nothing else: this is
+// a local-first app with no backend and no account, so there is nothing for a
+// widget to fetch. `WidgetService.refresh()` publishes after every derive and
+// on every foreground; that is the only refresh there is.
//
-// Shows three rings: Strain · Sleep · HRV. (Recovery was retired — the app no
-// longer surfaces a recovery score; HRV is the real measured autonomic signal.)
+// IT IS THE SAME THREE RINGS AS HOME NOW — Recovery · Strain · Sleep, in that
+// order, with the icon inside the dial and the number under it. It used to be
+// a readiness headline over Strain · Sleep · HRV, which was the previous
+// design system's home screen; HRV stopped being one of Home's rings in the
+// rebuild and lives on its own widget (OpenStrapOvernightWidget) instead.
//
-// HONESTY: nothing here is allowed to look current when it isn't. `has_data`
-// is the Dart side saying "this snapshot is empty or describes a day more than
-// one behind" — but it is a bool frozen at push time, so on a phone that stops
-// syncing it stays true forever. Freshness is therefore computed HERE, at
-// render time, from `updated_at` (see `OpenStrapEntry.fresh`), and every family
-// gates on that. An absent metric is drawn as an empty slot, never as a dash
-// over a ring pinned at zero. The readiness BANDING is not computed here: Dart
-// publishes `readiness_tier` so the phone, the widget, the watch and Siri
-// cannot disagree about what 65 means.
-
-import WidgetKit
-import SwiftUI
-
-private let kAppGroup = AppGroup.identifier
-
-// MARK: - Theme (lib/ui2/theme.dart)
-// The app writes "theme_dark" into the App Group to mirror its in-app appearance
-// (which already resolves "System" to the actual OS brightness).
+// HONESTY: nothing here may look current when it isn't, and nothing here may
+// look measured when it isn't.
//
-// These are ui2's tokens, not the retired lib/theme/tokens.dart ones — the
-// widget sits on the same home screen as the app and had been painting the
-// previous design system's palette. Surfaces are `P.card` (a widget IS a card),
-// the track is `P.track`, muted ink is `P.ink3`.
+// · Freshness is computed at RENDER time from `updated_at`, not from the
+// `has_data` bool frozen at push time — see `SW.fresh`.
+// · The four ring states (measured / calibrating / unscaled / absent) arrive
+// resolved from Dart. Before that this file drew one dimmed empty circle
+// for every absence, so "four more nights and this fills in" and "the band
+// recorded nothing all night" were the same picture, forever.
+// · An absence is a WORD and a reason, never a dash and never an arc at zero
+// — an arc at zero reads as a score of zero, which is a lie about the user.
//
-// Accents come in two forms, and the distinction is the whole point of ui2's
-// palette: RAW pigment (`C.*`) is for arcs and fills — non-text UI — while an
-// accent used as TEXT is run through `P.on()`, which nudges it toward the page
-// ink until it clears WCAG AA 4.5:1 on the worst surface it can land on. Those
-// solved values are precomputed here (`onGood`/`onWarn`/`onBad`/`onNone`);
-// re-deriving them means running P.on's binary search, not eyeballing a hex.
-
-private extension Color {
- init(_ r: Int, _ g: Int, _ b: Int) {
- self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255)
- }
-}
-
-/// Raw pigment — arcs and fills only. Identical in both themes, like `C` in
-/// lib/ui2/theme.dart.
-enum C {
- static let green = Color(0x22, 0xC5, 0x5E)
- static let orange = Color(0xF9, 0x73, 0x16)
- static let red = Color(0xEF, 0x44, 0x44)
- static let blue = Color(0x3B, 0x82, 0xF6) // sleep
- static let purple = Color(0x8B, 0x5C, 0xF6) // strain / movement
- static let n400 = Color(0x94, 0xA3, 0xB8)
-}
-
-private struct Pal {
- let bg: Color, ink: Color, inkMuted: Color, track: Color
- /// Tier accents solved for TEXT (`P.on`), per brightness.
- let onGood: Color, onWarn: Color, onBad: Color, onNone: Color
- static let light = Pal(bg: Color(0xFF, 0xFF, 0xFF), ink: Color(0x0F, 0x17, 0x2A),
- inkMuted: Color(0x62, 0x71, 0x88), track: Color(0xE2, 0xE8, 0xF0),
- onGood: Color(0x1A, 0x79, 0x48), onWarn: Color(0xA5, 0x52, 0x1D),
- onBad: Color(0xB9, 0x39, 0x3E), onNone: Color(0x60, 0x6B, 0x80))
- static let dark = Pal(bg: Color(0x15, 0x1C, 0x26), ink: Color(0xF1, 0xF5, 0xF9),
- inkMuted: Color(0x7F, 0x8D, 0xA0), track: Color(0x23, 0x2D, 0x3B),
- onGood: Color(0x22, 0xC5, 0x5E), onWarn: Color(0xF8, 0x7F, 0x2A),
- onBad: Color(0xEF, 0x73, 0x73), onNone: Color(0x97, 0xA6, 0xBA))
- static var isDark: Bool {
- UserDefaults(suiteName: kAppGroup)?.object(forKey: "theme_dark") as? Bool ?? false
- }
- static var current: Pal { isDark ? .dark : .light }
-}
-private extension Color {
- static var paper: Color { Pal.current.bg }
- static var ink: Color { Pal.current.ink }
- static var inkMuted: Color { Pal.current.inkMuted }
- static var surfaceAlt: Color { Pal.current.track }
-}
+import WidgetKit
+import SwiftUI
// MARK: - Model
-/// How old the snapshot may be before the widget stops presenting it as today's
-/// answer. The app pushes on every completed derivation and on every finished
-/// sync, so under normal use this is refreshed each morning; 26 h is one whole
-/// missed wake cycle plus a couple of hours of grace for a wandering wake time.
-/// Past it, the readiness on the home screen is at best the morning before
-/// last's, and the honest render is the no-data state, not a stale number with
-/// nothing on it to say so.
-///
-/// Kept in step with the same constant on the Watch (WatchMetrics.swift), in
-/// Siri (OpenStrapIntents.swift) and on Android (StrapWidgets.kt) — three
-/// separate build targets, so it cannot be one declaration.
-let kStaleAfter: TimeInterval = 26 * 3600
-
struct OpenStrapEntry: TimelineEntry {
var date: Date
- let hasData: Bool
- let updatedAt: Int // epoch sec of the last push, 0 = unknown
- let readiness: Int // -1 = none (composite 0..100) — the headline
- let tier: Int // -1 = not scored · 0 rest · 1 easy · 2 steady · 3 good
- let band: String // the phone's own label for `tier` ("Steady", …)
- let strain: Double // -1 = none
- let sleepMin: Int // -1 = none
- let needMin: Int // -1 = none (sleep need, min) — never fabricate 8h
- let hrv: Int // -1 = none (RMSSD, ms)
- let hrvBaseline: Int // -1 = none (personal RMSSD baseline, ms)
- let rhr: Int // -1 = none
- let coachLine: String
-
- static let placeholder = OpenStrapEntry(
- date: Date(), hasData: true, updatedAt: Int(Date().timeIntervalSince1970),
- readiness: 72, tier: 2, band: "Steady",
- strain: 12.4, sleepMin: 437, needMin: 480, hrv: 62, hrvBaseline: 58, rhr: 54,
- coachLine: "Room to push today")
-
- /// Is this snapshot still today's answer, AS OF THIS ENTRY'S DATE?
- ///
- /// `hasData` alone is not enough and never was: it is frozen the moment Dart
- /// writes it, so a phone that has not synced for a week keeps a week-old
- /// readiness on the home screen looking exactly like this morning's. The age
- /// is measured against `date` rather than `Date()` so that WidgetKit can
- /// render the flip from a timeline entry it already holds — see getTimeline.
- ///
- /// An unknown timestamp (0) is not a claim of staleness, matching
- /// `WidgetService.isStale`; a snapshot that never got a push has `has_data`
- /// false anyway.
- var fresh: Bool {
- guard hasData else { return false }
- guard updatedAt > 0 else { return true }
- return date.timeIntervalSince1970 - Double(updatedAt) <= kStaleAfter
- }
+ let snap: SW.Snapshot
- /// The instant this entry stops being today's answer, or nil if it already is
- /// not (or never had a timestamp to age).
- var stalenessDeadline: Date? {
- guard hasData, updatedAt > 0 else { return nil }
- let at = Date(timeIntervalSince1970: Double(updatedAt) + kStaleAfter)
- return at > date ? at : nil
- }
+ var fresh: Bool { SW.fresh(snap, at: date) }
- func at(_ d: Date) -> OpenStrapEntry { var c = self; c.date = d; return c }
-
- // Ring fractions (0…1). A negative fraction means "no measurement" — Ring
- // draws the track only, and no view fills an arc against a value we don't have.
- var readinessT: Double { readiness >= 0 ? Double(readiness) / 100.0 : -1 }
- /// Tier → colour. The THRESHOLDS live in Dart (`readinessBand` in
- /// lib/ui2/screens/home_screen.dart) and arrive as `readiness_tier`; this maps
- /// the tier onto the widget's own surface palette and nothing more. Do not
- /// re-derive a band from `readiness` here — that is how the phone, the widget
- /// and the watch ended up disagreeing about the same score.
- /// Arc pigment (raw `C`) and text pigment (`P.on`-solved) for the tier.
- var readinessArc: Color {
- switch tier {
- case 3, 2: return C.green
- case 1: return C.orange
- case 0: return C.red
- default: return C.n400
- }
- }
- var readinessColor: Color {
- let p = Pal.current
- switch tier {
- case 3, 2: return p.onGood
- case 1: return p.onWarn
- case 0: return p.onBad
- default: return p.onNone
- }
- }
- /// 0–21 is the real headline scale (`strainScore` log-maps TRIMP onto it —
- /// analytics/lib/src/onehz/clinical/load_trimp.dart:104-122), not a widget
- /// invention. Siri says "out of twenty-one" for the same reason.
- var strainT: Double { strain >= 0 ? min(strain / 21.0, 1) : -1 }
- var sleepT: Double { (sleepMin >= 0 && needMin > 0) ? min(Double(sleepMin) / Double(needMin), 1) : -1 }
- /// HRV against YOUR OWN baseline: a full ring is at or above it. There is no
- /// population scale for RMSSD, so with no baseline there is no denominator
- /// and the arc is simply not drawn — this used to divide by a hard-coded 100
- /// (and by 1.5 × baseline), neither of which exists anywhere in the pipeline.
- var hrvT: Double {
- guard hrv >= 0, hrvBaseline > 0 else { return -1 }
- return min(Double(hrv) / Double(hrvBaseline), 1)
- }
- /// HRV carries its domain accent (`C.green`, as on the phone's Health trend)
- /// and no colour judgement: a "0.8 × baseline is amber" cut-off was invented
- /// here and appears in no analytics output.
- var hrvColor: Color { hrv >= 0 ? C.green : C.n400 }
-}
-
-// MARK: - Shared store (App Group, read-only)
-
-private enum Store {
- static var defaults: UserDefaults? { UserDefaults(suiteName: kAppGroup) }
-
- static func read() -> OpenStrapEntry {
- let d = defaults
- return OpenStrapEntry(
- date: Date(),
- hasData: d?.bool(forKey: "has_data") ?? false,
- updatedAt: d?.object(forKey: "updated_at") as? Int ?? 0,
- readiness: d?.object(forKey: "readiness") as? Int ?? -1,
- tier: d?.object(forKey: "readiness_tier") as? Int ?? -1,
- band: d?.string(forKey: "readiness_band") ?? "",
- strain: d?.object(forKey: "strain") as? Double ?? -1,
- sleepMin: d?.object(forKey: "sleep_min") as? Int ?? -1,
- needMin: (d?.object(forKey: "sleep_need_min") as? Int) ?? -1,
- hrv: d?.object(forKey: "hrv") as? Int ?? -1,
- hrvBaseline: d?.object(forKey: "hrv_baseline") as? Int ?? -1,
- rhr: d?.object(forKey: "rhr") as? Int ?? -1,
- coachLine: d?.string(forKey: "coach_line") ?? "")
- }
+ static let placeholder = OpenStrapEntry(date: Date(), snap: .placeholder)
}
// MARK: - Provider
@@ -225,160 +46,106 @@ struct Provider: TimelineProvider {
func placeholder(in context: Context) -> OpenStrapEntry { .placeholder }
func getSnapshot(in context: Context, completion: @escaping (OpenStrapEntry) -> Void) {
- completion(context.isPreview ? .placeholder : Store.read())
+ completion(context.isPreview
+ ? .placeholder
+ : OpenStrapEntry(date: Date(), snap: SW.read()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
- // Push-driven: the app reloads timelines after every derive. Two things
- // keep it honest when it doesn't.
- //
- // The SECOND ENTRY is the load-bearing one. `fresh` is a function of the
- // entry's own date, so an entry scheduled at the staleness deadline renders
- // the no-data state at exactly that moment — WidgetKit switches to it with
- // no process wake, no budget spend and nothing for the app to do. A widget
- // that only ever re-read a bool at push time is how a week-old readiness
- // sat on the home screen looking like this morning's.
- //
- // The hourly `.after` is the cheap belt-and-braces: it picks up a new
- // snapshot the app wrote while we were not reloaded, and re-arms the
- // deadline entry.
- let now = Date()
- let entry = Store.read().at(now)
- var entries = [entry]
- if let deadline = entry.stalenessDeadline { entries.append(entry.at(deadline)) }
- let next = Calendar.current.date(byAdding: .hour, value: 1, to: now)
- ?? now.addingTimeInterval(3600)
- completion(Timeline(entries: entries, policy: .after(next)))
+ let snap = SW.read()
+ completion(SW.timeline(snap, Date()) { OpenStrapEntry(date: $0, snap: snap) })
}
}
-// MARK: - Reusable views
+// MARK: - The trio
-private struct Ring: View {
- let t: Double
- let color: Color
- let lineWidth: CGFloat
- var body: some View {
- ZStack {
- Circle().stroke(Color.surfaceAlt, lineWidth: lineWidth)
- if t > 0 {
- Circle()
- .trim(from: 0, to: min(max(t, 0), 1))
- .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
- .rotationEffect(.degrees(-90))
- }
+/// Which ring. The three the app can stand behind on a home screen: what the
+/// night gave back, what the day has cost, and what the night was made of —
+/// the same three, in the same order, as `HomeRingKind` on Home.
+private enum Trio: CaseIterable {
+ case recovery, strain, sleep
+
+ var label: String {
+ switch self {
+ case .recovery: return "Recovery"
+ case .strain: return "Strain"
+ case .sleep: return "Sleep"
}
}
-}
-/// Minutes → "45m" / "7h 05m". Byte-for-byte the phone's `hm()`
-/// (lib/ui2/screens/home_screen.dart) so the same night reads the same on both.
-private func hm(_ min: Int) -> String {
- if min < 0 { return "" }
- if min < 60 { return "\(min)m" }
- return String(format: "%dh %02dm", min / 60, min % 60)
-}
+ /// The nearest SF Symbol to Home's Lucide glyph: battery-charging, zap, moon.
+ var symbol: String {
+ switch self {
+ case .recovery: return "battery.100percent.bolt"
+ case .strain: return "bolt.fill"
+ case .sleep: return "moon.fill"
+ }
+ }
-private func numFont(_ size: CGFloat) -> Font { .system(size: size, weight: .bold, design: .rounded) }
-
-/// One labelled metric ring (used for all three: Strain / Sleep / HRV).
-///
-/// An absent metric is an EMPTY slot: no number, no arc, the whole cell dimmed.
-/// The phone's contract (grammar.dart) is what/why/fix, which does not fit in a
-/// 44pt circle — but a bare "—" over a ring drawn at zero reads as "your HRV is
-/// zero", which is worse than saying nothing. The reason is one tap away.
-private struct MetricRing: View {
- let label: String
- let value: String
- let t: Double
- let color: Color
- var size: CGFloat = 58
- var line: CGFloat = 7
- var valueSize: CGFloat = 16
- private var absent: Bool { value.isEmpty }
- var body: some View {
- VStack(spacing: 5) {
- ZStack {
- Ring(t: t, color: color, lineWidth: line)
- Text(value).font(numFont(valueSize)).foregroundColor(.ink).minimumScaleFactor(0.6).lineLimit(1)
- }
- .frame(width: size, height: size)
- Text(label).font(.system(size: 9, weight: .semibold)).tracking(0.8).foregroundColor(.inkMuted)
+ func data(_ s: SW.Snapshot) -> SW.RingData {
+ switch self {
+ case .recovery: return s.recovery
+ case .strain: return s.strain
+ case .sleep: return s.sleep
+ }
+ }
+
+ /// Recovery wears its band's colour, the other two their domain accent.
+ func accent(_ s: SW.Snapshot, _ p: SW.Pal) -> Color {
+ switch self {
+ case .recovery: return SW.tierColor(s.tier, p)
+ case .strain: return p.move
+ case .sleep: return p.sleep
}
- .opacity(absent ? 0.4 : 1)
}
}
-/// The three rings in a row, each taking an equal share of the width so they're
-/// evenly distributed regardless of value width.
-private struct TripleRings: View {
- let e: OpenStrapEntry
- var size: CGFloat = 58
- var line: CGFloat = 7
- var valueSize: CGFloat = 16
+/// Home's own accessibility layout — dial left, type in the width it needs.
+/// A small widget has the same problem a 1.3× text size does: three columns of
+/// "7h 17m" do not fit across 140 points.
+private struct RingRow: View {
+ let kind: Trio
+ let snap: SW.Snapshot
+ var dial: CGFloat = 34
+
var body: some View {
- HStack(spacing: 0) {
- MetricRing(label: "STRAIN",
- value: e.strain >= 0 ? String(format: "%.1f", e.strain) : "",
- t: e.strainT, color: C.purple, size: size, line: line, valueSize: valueSize)
- .frame(maxWidth: .infinity)
- MetricRing(label: "SLEEP", value: hm(e.sleepMin),
- t: e.sleepT, color: C.blue, size: size, line: line, valueSize: valueSize - 1)
- .frame(maxWidth: .infinity)
- MetricRing(label: "HRV", value: e.hrv >= 0 ? "\(e.hrv)" : "",
- t: e.hrvT, color: e.hrvColor, size: size, line: line, valueSize: valueSize)
- .frame(maxWidth: .infinity)
+ let p = SW.pal
+ let r = kind.data(snap)
+ HStack(spacing: 10) {
+ SW.Dial(r: r, symbol: kind.symbol, accent: kind.accent(snap, p),
+ size: dial, line: 5)
+ SW.RingText(label: kind.label, r: r, accent: kind.accent(snap, p),
+ align: .leading, valueSize: 17, showSub: false)
+ Spacer(minLength: 0)
}
- .frame(maxWidth: .infinity)
}
}
-/// Readiness headline row — big ring + score + the phone's own band label.
-private struct ReadinessRow: View {
- let e: OpenStrapEntry
- var ring: CGFloat = 64
+/// The default: three across, the number under the dial.
+private struct RingColumn: View {
+ let kind: Trio
+ let snap: SW.Snapshot
+ var dial: CGFloat = 48
+
var body: some View {
- HStack(spacing: 12) {
- ZStack {
- Ring(t: e.readinessT, color: e.readinessArc, lineWidth: 9)
- if e.readiness >= 0 {
- Text("\(e.readiness)").font(numFont(22)).foregroundColor(e.readinessColor)
- }
- }
- .frame(width: ring, height: ring)
- VStack(alignment: .leading, spacing: 2) {
- Text("READINESS").font(.system(size: 10, weight: .semibold)).tracking(1.1).foregroundColor(.inkMuted)
- // "Readiness not scored" and nothing more, the same neutral line
- // `accessoryInline` uses. This said "Still building your baseline",
- // which is ONE of the reasons and not the common one: with the band
- // worn by day and off at night there is no measured night at all, and
- // no reason key crosses the App Group for this side to tell the two
- // apart. Naming the wrong one is a false claim about the user's state.
- Text(e.readiness >= 0
- ? (e.band.isEmpty ? "HRV recovery + sleep" : e.band)
- : "Readiness not scored")
- .font(.system(size: 12)).foregroundColor(.ink)
- }
- Spacer(minLength: 0)
+ let p = SW.pal
+ let r = kind.data(snap)
+ VStack(spacing: 5) {
+ SW.Dial(r: r, symbol: kind.symbol, accent: kind.accent(snap, p),
+ size: dial, line: 7)
+ SW.RingText(label: kind.label, r: r, accent: kind.accent(snap, p),
+ valueSize: 20)
}
- .opacity(e.readiness >= 0 ? 1 : 0.55)
+ .frame(maxWidth: .infinity)
}
}
private struct SmallView: View {
- let e: OpenStrapEntry
+ let snap: SW.Snapshot
var body: some View {
- // 2×2: Readiness · Strain / Sleep · HRV.
- VStack(spacing: 10) {
- HStack(spacing: 0) {
- MetricRing(label: "READY", value: e.readiness >= 0 ? "\(e.readiness)" : "",
- t: e.readinessT, color: e.readinessArc, size: 44, line: 6, valueSize: 13).frame(maxWidth: .infinity)
- MetricRing(label: "STRAIN", value: e.strain >= 0 ? String(format: "%.1f", e.strain) : "",
- t: e.strainT, color: C.purple, size: 44, line: 6, valueSize: 13).frame(maxWidth: .infinity)
- }
- HStack(spacing: 0) {
- MetricRing(label: "SLEEP", value: hm(e.sleepMin), t: e.sleepT, color: C.blue, size: 44, line: 6, valueSize: 12).frame(maxWidth: .infinity)
- MetricRing(label: "HRV", value: e.hrv >= 0 ? "\(e.hrv)" : "", t: e.hrvT, color: e.hrvColor, size: 44, line: 6, valueSize: 13).frame(maxWidth: .infinity)
+ VStack(spacing: 8) {
+ ForEach(Array(Trio.allCases.enumerated()), id: \.offset) { _, k in
+ RingRow(kind: k, snap: snap)
}
}
.padding(12)
@@ -386,120 +153,111 @@ private struct SmallView: View {
}
private struct MediumView: View {
- let e: OpenStrapEntry
- var body: some View {
- VStack(alignment: .leading, spacing: 12) {
- ReadinessRow(e: e)
- TripleRings(e: e, size: 56, line: 7, valueSize: 15)
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(16)
+ let snap: SW.Snapshot
+
+ /// The first ring that is missing and said why. One line is what a medium
+ /// widget can afford; the rest is one tap away in the app.
+ private var gap: Trio? {
+ Trio.allCases.first { !$0.data(snap).why.isEmpty }
}
-}
-/// `has_data == false` — the app is telling us the snapshot is empty or is
-/// describing a day more than one behind. Say that; do not render last week's
-/// readiness at full confidence.
-private struct NoDataView: View {
- @Environment(\.widgetFamily) var family
var body: some View {
- switch family {
- case .accessoryCircular:
- Image(systemName: "bolt.heart").font(.system(size: 18)).widgetAccentable()
- case .accessoryRectangular:
- VStack(alignment: .leading, spacing: 2) {
- Text("No recent data").font(.system(size: 13, weight: .bold)).widgetAccentable()
- Text("Open OpenStrap and sync your band.")
- .font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(2)
+ VStack(spacing: 8) {
+ HStack(alignment: .top, spacing: 4) {
+ ForEach(Array(Trio.allCases.enumerated()), id: \.offset) { _, k in
+ RingColumn(kind: k, snap: snap)
+ }
}
- case .accessoryInline:
- Text("OpenStrap · no recent data")
- default:
- VStack(spacing: 6) {
- Image(systemName: "bolt.heart").font(.system(size: 22)).foregroundColor(.inkMuted)
- Text("No recent data")
- .font(.system(size: 14, weight: .semibold, design: .rounded)).foregroundColor(.ink)
- Text("Open OpenStrap and sync your band.")
- .font(.system(size: 11)).multilineTextAlignment(.center).foregroundColor(.inkMuted)
+ if let g = gap {
+ SW.GapRow(label: g.label, symbol: g.symbol, why: g.data(snap).why)
+ .frame(maxWidth: .infinity, alignment: .leading)
}
- .padding(12)
}
+ .padding(14)
}
}
+// MARK: - Accessory families
+
private struct AccessoryCircularView: View {
- let e: OpenStrapEntry
+ let snap: SW.Snapshot
var body: some View {
- // No Gauge when there is no score: `Gauge(value: 0)` draws a ring pinned at
- // empty, which is indistinguishable from "your readiness is 0".
- if e.readiness >= 0 {
- Gauge(value: e.readinessT) {
- Text("RDY")
+ let r = snap.recovery
+ // No Gauge when there is no score: a gauge at zero is indistinguishable
+ // from a recovery OF zero.
+ if r.measured, r.frac >= 0 {
+ Gauge(value: min(r.frac, 1)) {
+ Text("RCV")
} currentValueLabel: {
- Text("\(e.readiness)")
+ Text(r.value)
}
.gaugeStyle(.accessoryCircular)
.widgetAccentable()
} else {
VStack(spacing: 0) {
Image(systemName: "bolt.heart").font(.system(size: 15)).widgetAccentable()
- Text("RDY").font(.system(size: 9, weight: .semibold))
+ Text("RCV").font(.system(size: 9, weight: .semibold))
}
}
}
}
private struct AccessoryRectangularView: View {
- let e: OpenStrapEntry
+ let snap: SW.Snapshot
var body: some View {
VStack(alignment: .leading, spacing: 2) {
- Text(e.readiness >= 0 ? "Readiness \(e.readiness)" : "Readiness not scored")
+ Text(snap.recovery.measured
+ ? "Recovery \(snap.recovery.value)"
+ : "Recovery · \(snap.recovery.value)")
.font(.system(size: 13, weight: .bold)).widgetAccentable()
- Text(pair("Strain", e.strain >= 0 ? String(format: "%.1f", e.strain) : nil,
- "HRV", e.hrv >= 0 ? "\(e.hrv)" : nil))
+ // Only the rings that are actually reporting. An absent metric is left
+ // out of the line rather than printed as a dash.
+ Text(pair("Strain", snap.strain, "Sleep", snap.sleep))
.font(.system(size: 13, weight: .semibold))
- Text(pair("Sleep", hm(e.sleepMin).isEmpty ? nil : hm(e.sleepMin),
- "RHR", e.rhr >= 0 ? "\(e.rhr)" : nil))
- .font(.system(size: 12)).foregroundStyle(.secondary)
+ Text(snap.recovery.measured && !snap.recovery.sub.isEmpty
+ ? snap.recovery.sub
+ : firstWhy)
+ .font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(1)
}
}
- /// Two "Label value" pairs, dropping whichever side has no measurement — an
- /// absent metric is left out of the line rather than printed as a dash.
- private func pair(_ aLabel: String, _ a: String?, _ bLabel: String, _ b: String?) -> String {
- [a.map { "\(aLabel) \($0)" }, b.map { "\(bLabel) \($0)" }]
- .compactMap { $0 }.joined(separator: " ")
+ private var firstWhy: String {
+ for r in [snap.recovery, snap.sleep, snap.strain] where !r.why.isEmpty { return r.why }
+ return ""
}
-}
-private extension View {
- @ViewBuilder func widgetBackground(_ color: Color) -> some View {
- containerBackground(color, for: .widget)
+ private func pair(_ aLabel: String, _ a: SW.RingData,
+ _ bLabel: String, _ b: SW.RingData) -> String {
+ [a.measured ? "\(aLabel) \(a.value)" : nil,
+ b.measured ? "\(bLabel) \(b.value)" : nil]
+ .compactMap { $0 }.joined(separator: " ")
}
}
+// MARK: - Widget
+
struct OpenStrapWidgetEntryView: View {
@Environment(\.widgetFamily) var family
var entry: OpenStrapEntry
var body: some View {
- content.widgetBackground(isSystem ? Color.paper : Color.clear)
+ content.strapBackground(family)
}
- private var isSystem: Bool { family == .systemSmall || family == .systemMedium }
-
@ViewBuilder private var content: some View {
if !entry.fresh {
- NoDataView()
+ SW.NoData()
} else {
switch family {
- case .systemSmall: SmallView(e: entry)
- case .systemMedium: MediumView(e: entry)
- case .accessoryCircular: AccessoryCircularView(e: entry)
- case .accessoryRectangular: AccessoryRectangularView(e: entry)
+ case .systemSmall: SmallView(snap: entry.snap)
+ case .systemMedium: MediumView(snap: entry.snap)
+ case .accessoryCircular: AccessoryCircularView(snap: entry.snap)
+ case .accessoryRectangular: AccessoryRectangularView(snap: entry.snap)
case .accessoryInline:
- Text(entry.readiness >= 0 ? "Ready \(entry.readiness)" : "Readiness not scored")
- default: SmallView(e: entry)
+ Text(entry.snap.recovery.measured
+ ? "Recovery \(entry.snap.recovery.value)"
+ : "OpenStrap · \(entry.snap.recovery.value.lowercased())")
+ default: SmallView(snap: entry.snap)
}
}
}
@@ -513,7 +271,7 @@ struct OpenStrapWidget: Widget {
OpenStrapWidgetEntryView(entry: entry)
}
.configurationDisplayName("OpenStrap")
- .description("Readiness, strain, sleep and HRV at a glance.")
+ .description("Recovery, strain and sleep at a glance.")
.supportedFamilies([.systemSmall, .systemMedium,
.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
diff --git a/ios/OpenStrapWidget/OpenStrapWidgetBundle.swift b/ios/OpenStrapWidget/OpenStrapWidgetBundle.swift
index 3f83a519..9c7c2e33 100644
--- a/ios/OpenStrapWidget/OpenStrapWidgetBundle.swift
+++ b/ios/OpenStrapWidget/OpenStrapWidgetBundle.swift
@@ -12,6 +12,8 @@ import SwiftUI
struct OpenStrapWidgetBundle: WidgetBundle {
var body: some Widget {
OpenStrapWidget()
+ OpenStrapSleepWidget()
+ OpenStrapOvernightWidget()
OpenStrapBatteryWidget()
OpenStrapWidgetLiveActivity()
OpenStrapBreathingLiveActivity()
diff --git a/ios/OpenStrapWidget/StrapWidgetKit.swift b/ios/OpenStrapWidget/StrapWidgetKit.swift
new file mode 100644
index 00000000..d5c45610
--- /dev/null
+++ b/ios/OpenStrapWidget/StrapWidgetKit.swift
@@ -0,0 +1,342 @@
+//
+// StrapWidgetKit.swift
+// OpenStrapWidget
+//
+// The palette, the snapshot reader and the ring, shared by every widget that
+// renders what the app publishes into the App Group (OpenStrapWidget, the
+// Sleep widget and the Overnight widget). One namespace rather than free
+// functions and `extension Color` statics, because the Live Activity files
+// already own a `Pal` and a `Color.ink` of their own and two of those in one
+// module is a fight nobody wins.
+//
+// WHAT THIS SIDE IS ALLOWED TO DECIDE: layout, and nothing else. The numbers,
+// their labels, what they are out of, whether a ring is a reading or
+// calibration progress, and why one is missing all arrive already resolved —
+// see `WidgetService.push` (lib/widget/widget_service.dart), which mirrors
+// `RingTrio` on Home. Rules that used to live here in Swift AND in Kotlin AND
+// in Dart disagreed about the same day; there is one copy now.
+//
+
+import WidgetKit
+import SwiftUI
+
+enum SW {
+ static let appGroup = AppGroup.identifier
+
+ // MARK: - Theme (lib/ui2/theme.dart)
+
+ /// ui2's tokens, resolved for both appearances.
+ ///
+ /// The accents are `P.on(accent)` — ui2 nudges an accent toward the page ink
+ /// until it clears WCAG AA 4.5:1 on the worst surface it can land on, and a
+ /// ring spends that solved value for BOTH its arc and its number (see
+ /// `_RingState.arc` / `.ink` in home_screen.dart). Recomputing these means
+ /// running `P.on`'s binary search, not eyeballing a hex.
+ struct Pal {
+ let card, ink, ink2, ink3, track: Color
+ /// Readiness tiers, then the two domain accents the other rings carry.
+ let good, warn, bad, sleep, move: Color
+
+ static let light = Pal(
+ card: c(0xFFFFFF), ink: c(0x0F172A), ink2: c(0x475569),
+ ink3: c(0x627188), track: c(0xE2E8F0),
+ good: c(0x1A7A48), warn: c(0xA5521D), bad: c(0xB9393E),
+ sleep: c(0x2F66C0), move: c(0x734FCF))
+ static let dark = Pal(
+ card: c(0x151C26), ink: c(0xF1F5F9), ink2: c(0x94A3B8),
+ ink3: c(0x7F8DA0), track: c(0x232D3B),
+ good: c(0x22C55E), warn: c(0xF87E28), bad: c(0xF07374),
+ sleep: c(0x689EF7), move: c(0xA988F7))
+ }
+
+ static func c(_ hex: Int) -> Color {
+ Color(red: Double((hex >> 16) & 0xFF) / 255,
+ green: Double((hex >> 8) & 0xFF) / 255,
+ blue: Double(hex & 0xFF) / 255)
+ }
+
+ /// The app mirrors its own resolved appearance into `theme_dark` (including
+ /// an in-app override of the OS), so the widget follows the app rather than
+ /// the system.
+ static var pal: Pal {
+ (UserDefaults(suiteName: appGroup)?.object(forKey: "theme_dark") as? Bool ?? false)
+ ? .dark : .light
+ }
+
+ /// Readiness tier → its accent. The CUT-OFFS are not here: Dart publishes
+ /// `readiness_tier` (`readinessBand`, home_screen.dart) so the phone, the
+ /// widget, the Watch and Siri cannot disagree about what a 65 means.
+ static func tierColor(_ tier: Int, _ p: Pal) -> Color {
+ switch tier {
+ case 3, 2: return p.good
+ case 1: return p.warn
+ case 0: return p.bad
+ default: return p.ink3
+ }
+ }
+
+ // MARK: - Type (F, lib/ui2/theme.dart)
+
+ /// The numeral ramp. Tabular so a value never jitters its own layout, and SF
+ /// Pro Text rather than the rounded face — the app's numbers are not round.
+ static func num(_ size: CGFloat) -> Font {
+ .system(size: size, weight: .bold).monospacedDigit()
+ }
+ /// `F.over` — the uppercase label over every ring.
+ static let over = Font.system(size: 11, weight: .semibold)
+ /// `F.cap` / `F.body`.
+ static let cap = Font.system(size: 13)
+ static let body = Font.system(size: 15)
+
+ // MARK: - Freshness
+
+ /// How old the snapshot may be before the widget stops presenting it as
+ /// today's answer. The app pushes after every derive and on every foreground,
+ /// so under normal use this is refreshed each morning; 26 h is one whole
+ /// missed wake cycle plus grace for a wandering wake time.
+ ///
+ /// Kept in step with the same constant on the Watch (WatchMetrics.swift), in
+ /// Siri (OpenStrapIntents.swift) and on Android (StrapWidgets.kt) — four
+ /// separate build targets, so it cannot be one declaration.
+ static let staleAfter: TimeInterval = 26 * 3600
+
+ // MARK: - Snapshot
+
+ /// One home ring as Dart resolved it. `state` is the same four-way split the
+ /// phone draws: a reading, calibration progress, or an absence with the
+ /// pipeline's own reason attached.
+ struct RingData {
+ let state: Int // 0 measured · 1 calibrating · 2 absent
+ let value: String // the number, or the absence IN WORDS — never a dash
+ let sub: String // what it is out of, the band, or the nights banked
+ let why: String // absent rings only
+ let frac: Double // negative = nothing honest to sweep
+
+ var measured: Bool { state == 0 }
+ var calibrating: Bool { state == 1 }
+
+ /// Arc and numeral share one colour on the phone, and the colour IS the
+ /// signal that this is not a reading.
+ func color(_ accent: Color, _ p: Pal) -> Color { measured ? accent : p.ink3 }
+ }
+
+ struct Snapshot {
+ let hasData: Bool
+ let updatedAt: Int // epoch sec of the last push, 0 = unknown
+ let tier: Int // -1 not scored · 0 rest · 1 easy · 2 steady · 3 good
+ let recovery, strain, sleep: RingData
+ let hrv, hrvBaseline, rhr, efficiency: Int // -1 = none
+ /// Why the overnight figures are missing, when they are held over from a
+ /// night that is not today's. "" when they are today's own.
+ let overnightWhy: String
+
+ /// Has any ring at all been published? False for a snapshot written by an
+ /// app version older than the rings — every value would be the empty
+ /// string, which draws three circles with nothing in them. It heals on the
+ /// first push (the app publishes on every foreground), and until then the
+ /// no-data state is the honest picture.
+ var usable: Bool {
+ !recovery.value.isEmpty || !strain.value.isEmpty || !sleep.value.isEmpty
+ }
+
+ static let placeholder = Snapshot(
+ hasData: true, updatedAt: Int(Date().timeIntervalSince1970), tier: 3,
+ recovery: RingData(state: 0, value: "72", sub: "Good to go", why: "", frac: 0.72),
+ strain: RingData(state: 0, value: "12.4", sub: "of 21", why: "", frac: 12.4 / 21),
+ sleep: RingData(state: 0, value: "7h 17m", sub: "of 7h 45m", why: "", frac: 437.0 / 465),
+ hrv: 62, hrvBaseline: 58, rhr: 54, efficiency: 91,
+ overnightWhy: "")
+ }
+
+ private static func ring(_ d: UserDefaults?, _ key: String) -> RingData {
+ RingData(
+ state: d?.object(forKey: "ring_\(key)_state") as? Int ?? 2,
+ value: d?.string(forKey: "ring_\(key)_value") ?? "",
+ sub: d?.string(forKey: "ring_\(key)_sub") ?? "",
+ why: d?.string(forKey: "ring_\(key)_why") ?? "",
+ frac: d?.object(forKey: "ring_\(key)_frac") as? Double ?? -1)
+ }
+
+ static func read() -> Snapshot {
+ let d = UserDefaults(suiteName: appGroup)
+ func i(_ k: String) -> Int { d?.object(forKey: k) as? Int ?? -1 }
+ return Snapshot(
+ hasData: d?.bool(forKey: "has_data") ?? false,
+ updatedAt: d?.object(forKey: "updated_at") as? Int ?? 0,
+ tier: i("readiness_tier"),
+ recovery: ring(d, "recovery"), strain: ring(d, "strain"), sleep: ring(d, "sleep"),
+ hrv: i("hrv"), hrvBaseline: i("hrv_baseline"), rhr: i("rhr"),
+ efficiency: i("sleep_efficiency"),
+ overnightWhy: d?.string(forKey: "overnight_why") ?? "")
+ }
+
+ /// Is [s] still today's answer AS OF [date]?
+ ///
+ /// `has_data` alone is not enough and never was: it is frozen the moment Dart
+ /// writes it, so a phone that has not synced for a week keeps a week-old
+ /// readiness on the home screen looking exactly like this morning's. Measured
+ /// against the ENTRY's date rather than `Date()` so WidgetKit can render the
+ /// flip from a timeline entry it already holds. An unknown timestamp is not a
+ /// claim of staleness (matching `WidgetService.isStale`).
+ static func fresh(_ s: Snapshot, at date: Date) -> Bool {
+ guard s.hasData, s.usable else { return false }
+ guard s.updatedAt > 0 else { return true }
+ return date.timeIntervalSince1970 - Double(s.updatedAt) <= staleAfter
+ }
+
+ /// The instant [s] stops being today's answer, or nil if it already is not.
+ static func stalenessDeadline(_ s: Snapshot, after date: Date) -> Date? {
+ guard s.hasData, s.updatedAt > 0 else { return nil }
+ let at = Date(timeIntervalSince1970: Double(s.updatedAt) + staleAfter)
+ return at > date ? at : nil
+ }
+
+ /// The one timeline policy all three snapshot widgets share: now, the moment
+ /// the snapshot goes stale (so the honest empty state appears with no process
+ /// wake and no budget spent), and an hourly re-read as belt and braces.
+ static func timeline(
+ _ s: Snapshot, _ now: Date, _ make: (Date) -> E
+ ) -> Timeline {
+ var entries = [make(now)]
+ if let deadline = stalenessDeadline(s, after: now) { entries.append(make(deadline)) }
+ let next = Calendar.current.date(byAdding: .hour, value: 1, to: now)
+ ?? now.addingTimeInterval(3600)
+ return Timeline(entries: entries, policy: .after(next))
+ }
+
+ // MARK: - Views
+
+ /// Track circle + progress arc from 12 o'clock, round caps.
+ struct Ring: View {
+ let frac: Double
+ let color: Color
+ var lineWidth: CGFloat = 8
+
+ var body: some View {
+ let p = SW.pal
+ ZStack {
+ Circle().stroke(p.track, lineWidth: lineWidth)
+ if frac > 0 {
+ Circle()
+ .trim(from: 0, to: min(frac, 1))
+ .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ }
+ }
+ }
+ }
+
+ /// The dial: the arc with the ring's ICON at its centre, exactly as on Home.
+ ///
+ /// The number lives UNDER the dial, not inside it — inside is where "7h 45m"
+ /// overflows its own circle at the first accessibility step, and nothing
+ /// about that string gets shorter.
+ struct Dial: View {
+ let r: RingData
+ let symbol: String
+ let accent: Color
+ var size: CGFloat = 56
+ var line: CGFloat = 7
+
+ var body: some View {
+ let p = SW.pal
+ let tint = r.color(accent, p)
+ ZStack {
+ Ring(frac: r.frac, color: tint, lineWidth: line)
+ Image(systemName: symbol)
+ .font(.system(size: size * 0.32, weight: .medium))
+ .foregroundStyle(tint)
+ }
+ .frame(width: size, height: size)
+ }
+ }
+
+ /// Label over, value under, what-it-is-out-of under that. An absence takes
+ /// the SENTENCE weight rather than the numeral one, because it is a sentence:
+ /// "No sleep" set in 24pt bold would read as a score.
+ struct RingText: View {
+ let label: String
+ let r: RingData
+ let accent: Color
+ var align: HorizontalAlignment = .center
+ var valueSize: CGFloat = 22
+ var showSub: Bool = true
+
+ var body: some View {
+ let p = SW.pal
+ VStack(alignment: align, spacing: 1) {
+ Text(label.uppercased()).font(SW.over).tracking(0.5).foregroundStyle(p.ink3)
+ Text(r.value)
+ .font(r.measured ? SW.num(valueSize) : SW.body)
+ .foregroundStyle(r.measured ? p.ink : p.ink2)
+ .lineLimit(1).minimumScaleFactor(0.65)
+ if showSub && !r.sub.isEmpty {
+ Text(r.sub).font(SW.cap).foregroundStyle(p.ink3)
+ .lineLimit(1).minimumScaleFactor(0.7)
+ }
+ }
+ .multilineTextAlignment(align == .leading ? .leading : .center)
+ }
+ }
+
+ /// WHY a ring is empty. The row Home puts under the trio, at the size a
+ /// widget can afford: what is missing, and the reason the pipeline gave.
+ /// Never a reason invented here.
+ struct GapRow: View {
+ let label: String
+ let symbol: String
+ let why: String
+
+ var body: some View {
+ let p = SW.pal
+ HStack(alignment: .top, spacing: 6) {
+ Image(systemName: symbol).font(.system(size: 11)).foregroundStyle(p.ink3)
+ // Interpolated rather than concatenated: `Text + Text` is deprecated,
+ // and a nested Text keeps the label's weight without a second view.
+ Text("\(Text(label).fontWeight(.semibold).foregroundColor(p.ink2)) · \(why)")
+ .font(.system(size: 11))
+ .foregroundStyle(p.ink3)
+ }
+ .lineLimit(2)
+ }
+ }
+
+ /// `has_data` is false, or the snapshot has aged past [staleAfter]. Say that;
+ /// do not render last week's readiness at full confidence.
+ struct NoData: View {
+ @Environment(\.widgetFamily) var family
+
+ var body: some View {
+ let p = SW.pal
+ switch family {
+ case .accessoryCircular:
+ Image(systemName: "bolt.heart").font(.system(size: 18)).widgetAccentable()
+ case .accessoryRectangular:
+ VStack(alignment: .leading, spacing: 2) {
+ Text("No recent data").font(.system(size: 13, weight: .bold)).widgetAccentable()
+ Text("Open OpenStrap and sync your band.")
+ .font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(2)
+ }
+ case .accessoryInline:
+ Text("OpenStrap · no recent data")
+ default:
+ VStack(spacing: 6) {
+ Image(systemName: "bolt.heart").font(.system(size: 22)).foregroundStyle(p.ink3)
+ Text("No recent data").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.ink)
+ Text("Open OpenStrap and sync your band.")
+ .font(.system(size: 11)).multilineTextAlignment(.center).foregroundStyle(p.ink3)
+ }
+ .padding(12)
+ }
+ }
+ }
+}
+
+extension View {
+ /// Systems families get the app's card surface; accessory families must stay
+ /// clear so the lock screen's own material shows through.
+ @ViewBuilder func strapBackground(_ family: WidgetFamily) -> some View {
+ let system = family == .systemSmall || family == .systemMedium || family == .systemLarge
+ containerBackground(system ? SW.pal.card : Color.clear, for: .widget)
+ }
+}
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index aeb7a31f..e1f53469 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -208,6 +208,9 @@ PODS:
- Flutter
- home_widget (0.0.1):
- Flutter
+ - mobile_scanner (7.0.0):
+ - Flutter
+ - FlutterMacOS
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
@@ -232,9 +235,6 @@ PODS:
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
- - video_player_avfoundation (0.0.1):
- - Flutter
- - FlutterMacOS
- workmanager_apple (0.0.1):
- Flutter
@@ -255,12 +255,12 @@ DEPENDENCIES:
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- health (from `.symlinks/plugins/health/ios`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
+ - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS:
@@ -323,6 +323,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/health/ios"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
+ mobile_scanner:
+ :path: ".symlinks/plugins/mobile_scanner/darwin"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
share_plus:
@@ -333,8 +335,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
- video_player_avfoundation:
- :path: ".symlinks/plugins/video_player_avfoundation/darwin"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
@@ -374,6 +374,7 @@ SPEC CHECKSUMS:
GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850
health: a4ddeac72091000e94776864d0028f6be31ec7a5
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
+ mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273
@@ -384,7 +385,6 @@ SPEC CHECKSUMS:
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
- video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: b50997058227f33b81189532a9f3fc5007ec070b
diff --git a/lib/ai/briefing_engine.dart b/lib/ai/briefing_engine.dart
index 823edb87..47cc37b1 100644
--- a/lib/ai/briefing_engine.dart
+++ b/lib/ai/briefing_engine.dart
@@ -16,6 +16,7 @@ import '../coach/coach_config.dart';
import '../coach/coach_engine.dart';
import '../data/day_label.dart';
import '../data/local_repository.dart';
+import '../ui2/screens/home_screen.dart' as ring show readinessBand;
import 'briefing.dart';
import 'nightly_sweep.dart';
@@ -224,18 +225,21 @@ String partOfDay(DateTime now) {
/// and can contradict the score itself (a 16/100 read as "strong overnight
/// recovery"). The band is declared authoritative in the system prompt.
///
-/// THE single source of truth for readiness-score banding — also used by
-/// the Today ring's status word (`TodayVitals._orbitHero` in
-/// today_screen.dart maps good/moderate/low → Push/Focus/Recover).
-/// These cuts (40/66) MUST match the ring's own thresholds: a briefing band
-/// computed from different cuts than the ring's word is exactly the
-/// tone-vs-score contradiction this function exists to prevent, just moved
-/// from "sub-metrics vs score" to "briefing vs ring".
-String readinessBand(num v) {
- if (v < 40) return 'low';
- if (v < 66) return 'moderate';
- return 'good';
-}
+/// DERIVED FROM THE RING, never re-declared. It used to carry its own 40/66
+/// cuts with a comment insisting they match the ring's — and then #250 moved
+/// the ring to the score's own quantiles (26/37/61) and left these behind. A
+/// 61 was "Good to go" on Home and "moderate" in the briefing on the same
+/// morning: the tone-vs-score contradiction this function exists to prevent,
+/// arrived from the one direction the comment could not police.
+///
+/// So there is one classifier ([readinessBand] in home_screen.dart) and this
+/// is a PRESENTATION of it: four tiers folded to the three words the prompt
+/// speaks, with both warning tiers reading "low".
+String readinessBand(num v) => switch (ring.readinessBand(v).tier) {
+ 3 => 'good',
+ 2 => 'moderate',
+ _ => 'low',
+ };
/// The nightly sweep's rules.
///
diff --git a/lib/app.dart b/lib/app.dart
index f4e8fd97..70d31f02 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -27,6 +27,7 @@ import 'ui2/screens/what_changed.dart';
import 'ui2/screens/health_screen.dart';
import 'ui2/screens/home_screen.dart';
import 'ui2/screens/journal_compose.dart';
+import 'ui2/screens/log_workout.dart';
import 'ui2/screens/nutrition_screen.dart';
import 'ui2/screens/wellness_screen.dart';
import 'ui2/screens/workout_screen.dart';
@@ -309,12 +310,15 @@ ShellDomain domainForTab(int tab) => switch (tab) {
/// Every `kRoute*` in `tap_router.dart` is here, and unknown routes fall back
/// to Home rather than crashing a cold launch on a payload from an older
/// build.
-ShellDomain domainForRoute(String route) => switch (route) {
+ShellDomain domainForRoute(String route) => switch (routePath(route)) {
kRouteAiMorning || kRouteAiEvening => ShellDomain.home,
kRouteJournalCompose || kRouteBreathing => ShellDomain.wellness,
// Water is a journal field that lives on Nutrition — that is the tab
// behind the log screen, and where a "back" from it should land.
kRouteWater => ShellDomain.nutrition,
+ // The medication reminder. Wellness owns the Medication tab and its
+ // checklist, which is where a dose is actually recorded.
+ kRouteMeds => ShellDomain.wellness,
kRouteWorkoutSuggestion => ShellDomain.workout,
// Emitted by the battery forecast (`app_state.dart`) and the weekly
// recap (`notification_center.dart`), and declared in `tap_router`
@@ -337,14 +341,15 @@ ShellDomain domainForRoute(String route) => switch (route) {
/// The focused screen a deep link pushes on top of its domain, when one
/// exists. Null means the domain itself is the destination.
///
-/// One route still resolves to null and should not: `/workouts/suggestion`
-/// ("Tap to log it" has nothing to tap through to — nothing reads
-/// `workout_suggestions`). It is recorded in the sweep; the fix is to stop
-/// making the promise, not to route it somewhere plausible.
+/// `/workouts/suggestion` used to be in that list, and it was the one route
+/// where the fallback was a broken promise: "Tap to log it" landed on the
+/// plain Workouts tab, because the screen that could log it was deleted with
+/// `lib/ui/workouts/` and nothing read `workout_suggestions`. There is a
+/// destination again, and confirming on it writes a real session.
///
-/// `/ai/*` used to be in that list. It now lands on the briefing itself, which
-/// also carries the exact snapshot that was sent to produce it.
-Widget? screenForRoute(String route) => switch (route) {
+/// `/ai/*` used to be in that list too. It now lands on the briefing itself,
+/// which also carries the exact snapshot that was sent to produce it.
+Widget? screenForRoute(String route) => switch (routePath(route)) {
kRouteAiMorning =>
const AiBriefingScreen(period: BriefingPeriod.morning),
kRouteAiEvening =>
@@ -357,6 +362,20 @@ Widget? screenForRoute(String route) => switch (route) {
// which is how the tile that everybody actually used stayed add-only for
// so long — the thing that could clear a value was behind a notification.
kRouteWater => const NutritionScreen(),
+ // The detected bout, with the three answers to it: log it, adjust the
+ // times first, or say it never happened.
+ // The medication reminder pushes NOTHING, and still lands on the
+ // checklist: it is a SUB-TAB of Wellness, so pushing anything would put
+ // a second copy of a shell tab over the shell. `_consume` asks Wellness
+ // for the tab instead (`WellnessScreen.tabRequest`) — the deep link is
+ // wired, the answer here stays null.
+ kRouteMeds => null,
+ // A CONSTRUCTOR ARGUMENT is right here and wrong for `/meds` above: this
+ // screen is PUSHED by `_consume`, so every tap builds a fresh one and the
+ // id reaches it. Wellness is a shell tab kept alive in the IndexedStack,
+ // never rebuilt on a tap, which is why that one needs a request notifier.
+ kRouteWorkoutSuggestion =>
+ WorkoutSuggestionScreen(focusId: routeId(route)),
// Battery, band and sources all live behind this one.
kRouteProfile => const ProfileHome(),
// The weekly recap used to land on the Health tab and push nothing,
@@ -439,6 +458,15 @@ class _ShellState extends State<_Shell> {
// the base the payload was built with, not a second destination.
if (s != null && s.isNotEmpty) {
_go(domainForRoute(s));
+ // A route whose destination is a SUB-tab, which no pushed screen can
+ // express. Asked for AFTER `_go` (which may re-key the shell and build a
+ // fresh Wellness) and cleared a frame later, so whichever state ends up
+ // on screen has seen it — see `WellnessScreen.tabRequest`.
+ if (routePath(s) == kRouteMeds) {
+ WellnessScreen.tabRequest.value = WellnessScreen.medsTab;
+ WidgetsBinding.instance.addPostFrameCallback(
+ (_) => WellnessScreen.tabRequest.value = -1);
+ }
final screen = screenForRoute(s);
if (screen != null) {
Navigator.of(context)
diff --git a/lib/coach/coach_actions.dart b/lib/coach/coach_actions.dart
index 7596c2fa..93667844 100644
--- a/lib/coach/coach_actions.dart
+++ b/lib/coach/coach_actions.dart
@@ -30,6 +30,7 @@ import '../data/journal_fields.dart';
import '../data/local_repository.dart';
import '../data/med_store.dart';
import '../data/nutrition_store.dart';
+import '../health/health_export.dart';
/// Raised when the model's arguments cannot be honoured. The message goes back
/// into the transcript so the model can correct itself rather than retrying the
@@ -264,6 +265,11 @@ class CoachActions {
endTs: startTs + mins * 60,
type: type,
);
+ // Every other write path exports; without this a workout logged through
+ // the coach reached the health store only if the next day-result pass
+ // happened to sweep it up (#130). The seam checks `healthSyncEnabled`
+ // itself, so this is a no-op with the switch off, and it never throws.
+ await HealthExporter.exportWorkoutId(r['workout_id'] as String?);
return jsonEncode({'saved': true, 'date': d, 'type': type, ...r});
} catch (e) {
// The repo rejects overlaps, futures and absurd durations. Hand the
diff --git a/lib/coach/coach_config.dart b/lib/coach/coach_config.dart
index 3593fd8c..c4211c92 100644
--- a/lib/coach/coach_config.dart
+++ b/lib/coach/coach_config.dart
@@ -66,12 +66,51 @@ class CoachConfig extends ChangeNotifier {
bool _keyUndetermined = false;
bool get keyUndetermined => _keyUndetermined;
- /// Bumped by every [save]. A [load] that started before a save must not apply
- /// its stale result afterwards: the startup load is unawaited and a slow
- /// keystore read can still be in flight when the user pastes a key, and its
- /// late `_key = null` would wipe the key they just saved out of the session.
+ /// Bumped TWICE by every [save] — once on the way in, once on the way out.
+ ///
+ /// A [load] that started before a save must not apply its stale result
+ /// afterwards: the startup load is unawaited and a slow keystore read can
+ /// still be in flight when the user pastes a key, and its late `_key = null`
+ /// would wipe the key they just saved out of the session.
+ ///
+ /// One bump only caught the load that started BEFORE the save. A load that
+ /// starts DURING one captured the already-incremented value, so its check
+ /// passed, and its read — taken while the write was still inside the plugin —
+ /// came back empty. Trusted, that empty read is treated as proof there is no
+ /// key: it cleared `_key` and wrote the `_kKeyPresent` marker to false over
+ /// the true the save had just set. A later background read then reports the
+ /// stored key as ABSENT rather than unreadable, which also puts
+ /// [refreshKeyOnResume] to sleep — the retry that would have recovered it.
+ /// Bumping again on the way out invalidates any read that straddled the
+ /// write, which is the only kind that can be wrong about it.
int _generation = 0;
+ /// ONE keychain MUTATION at a time.
+ ///
+ /// [load] does not only read: it writes the value it just read back, to
+ /// upgrade an item stored before this class asked for `first_unlock`. That
+ /// write is awaited, but `load` itself is not — the startup call is
+ /// fire-and-forget — so nothing stopped it overlapping the user's Save. Two
+ /// ways that ends badly: the upgrade lands last and puts the OLD key back
+ /// over the one they just pasted, or, on iOS, a write races a delete inside
+ /// the plugin and comes out as `PlatformException(-25299)`
+ /// (errSecDuplicateItem). [_generation] already orders the in-memory half of
+ /// that race; it cannot order two calls that are both inside the plugin.
+ ///
+ /// WRITES ONLY, deliberately. The read is left outside, because a keystore
+ /// read can hang outright (the documented Samsung Knox case this file's
+ /// `load` is already shaped around) and a lock that a hung read holds would
+ /// block Save forever — trading a rare clobber for a wedged settings screen.
+ Future _keychainLock = Future.value();
+
+ Future _serialized(Future Function() op) {
+ final done = _keychainLock.then((_) => op());
+ // A failed operation must not wedge the queue — the next caller runs either
+ // way, and the error still reaches whoever awaited `done`.
+ _keychainLock = done.catchError((_) {});
+ return done;
+ }
+
String get baseUrl => _baseUrl;
String get model => _model;
String? get apiKey => _key;
@@ -150,13 +189,19 @@ class CoachConfig extends ChangeNotifier {
// Keystore (the documented Samsung Knox hang) on the startup path for
// no reason.
if (marker != true) {
- await _secure.write(
- key: _kKey,
- value: read,
- iOptions: _apple,
- mOptions: _macos,
- );
- await prefs.setBool(_kKeyPresent, true);
+ await _serialized(() async {
+ // Re-checked INSIDE the lock, not just before the read. A save can
+ // land while this upgrade is queued behind it, and writing `read`
+ // then would put the superseded key back.
+ if (generation != _generation) return;
+ await _secure.write(
+ key: _kKey,
+ value: read,
+ iOptions: _apple,
+ mOptions: _macos,
+ );
+ await prefs.setBool(_kKeyPresent, true);
+ });
}
} else if (trusted) {
// Foreground, so the keychain is readable and an empty answer is the
@@ -225,24 +270,32 @@ class CoachConfig extends ChangeNotifier {
// other order leaves memory holding a key that was never persisted (lost
// at the next launch, with no marker to even flag it as missing), or
// hiding one that is still stored.
- if (k.isEmpty) {
- await _secure.delete(key: _kKey, iOptions: _apple, mOptions: _macos);
- // The marker follows the keychain, and its own failure is not worth
- // failing the save: a stale `true` costs a retry, never a lost key.
- try {
- await prefs.setBool(_kKeyPresent, false);
- } catch (_) {/* re-established by the next load */}
- } else {
- await _secure.write(
- key: _kKey,
- value: k,
- iOptions: _apple,
- mOptions: _macos,
- );
- try {
- await prefs.setBool(_kKeyPresent, true);
- } catch (_) {/* re-established by the next load */}
- }
+ await _serialized(() async {
+ if (k.isEmpty) {
+ await _secure.delete(key: _kKey, iOptions: _apple, mOptions: _macos);
+ // The marker follows the keychain, and its own failure is not worth
+ // failing the save: a stale `true` costs a retry, never a lost key.
+ try {
+ await prefs.setBool(_kKeyPresent, false);
+ } catch (_) {/* re-established by the next load */}
+ } else {
+ await _secure.write(
+ key: _kKey,
+ value: k,
+ iOptions: _apple,
+ mOptions: _macos,
+ );
+ try {
+ await prefs.setBool(_kKeyPresent, true);
+ } catch (_) {/* re-established by the next load */}
+ }
+ });
+ // The second bump: see [_generation]. Anything that read the keychain
+ // while that write was in flight now fails its check and drops its
+ // answer, instead of clearing the key and filing the marker false.
+ // Skipped when the write threw, on purpose — nothing landed, so a
+ // straddling read's "no key" is the truth.
+ _generation++;
_key = k.isEmpty ? null : k;
_keyUnreadable = false;
_keyUndetermined = false;
diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart
index 337026ad..573b130a 100644
--- a/lib/compute/derivation_engine.dart
+++ b/lib/compute/derivation_engine.dart
@@ -40,11 +40,12 @@ import '../data/series_codec.dart';
import '../notify/fired_keys.dart';
import '../notify/notification_center.dart';
import '../notify/notification_event.dart';
-import '../notify/tap_router.dart' show kRouteWorkoutSuggestion;
+import '../notify/tap_router.dart' show workoutSuggestionRoute;
import '../telemetry/telemetry_service.dart';
import 'crossday_pipeline.dart';
import 'derive_pacing.dart';
-import 'hr_max.dart' show estimatedMaxHr, kHrFloorBpm;
+import 'hr_max.dart'
+ show estimatedMaxHr, kHrFloorBpm, smoothedMaxHr, smoothedMinHr;
import 'movement_floor_policy.dart' as mfp;
import 'sleep_profile_policy.dart';
import 'derive_prepare.dart';
@@ -1233,7 +1234,60 @@ import 'substrate.dart';
// deliberately so — it is gen5/MG-only, so gating on it makes the same
// night answer differently on two straps. The refusal's construct argument
// is untouched and is the one that carries it.
-const int kAlgoVersion = 74;
+// v75 — THE ISSUE AUDIT. Every issue and discussion ever filed was re-checked
+// against the shipped tree; these are the ones that were still true. Four
+// numbers move, and each moved because it was wrong, not because it was tuned:
+// 1. READINESS carries its fourth driver. `tempInput` refused on every night
+// ever shipped, because `settledFraction` was never passed from this side
+// — the driver was documented, weighted 0.10, and unreachable. The other
+// three renormalised over 0.90 and quietly absorbed it. Nights the strap
+// cannot vouch for (device_family NULL, pre-schema-41, imports, gen5) are
+// refused BY NAME now instead of silently.
+// 2. READINESS BANDS are the score's own quantiles. The composite is a
+// logistic with no scale parameter, so its centre is 50 — and 50 was
+// labelled "Take it easy". Half of every user's nights read as a warning
+// by construction, and "Good to go" needed every input ~1.4 SD above
+// personal median at once. The score did not change; the verdict did.
+// 3. PEAK HR stopped contradicting itself. The workout producers smoothed
+// through hr_max.dart, the day peak still did reduce(math.max) over raw
+// 1 Hz — so the strain card and the timeline printed different numbers off
+// the same beats (#127, closed once already). Manual saves and the
+// below-coverage reconcile fed it unsmoothed too.
+// 4. CALORIES and STRAIN follow the analytics gates above, and both abstain
+// rather than guess: a day with no resting HR now has no calorie figure
+// instead of billing every waking minute as active.
+// Also here, changing nothing derived: a night never re-stages shorter than the
+// one already banked (#242 — the guard only fired on a FAILED pass and never
+// compared tst_sec, which is why a fixed night came back wrong a few syncs
+// later), and absent accel stays absent instead of coalescing to zero.
+// v76 — IMPORTED DAYS WERE SETTING THE BASELINE THEY ARE SUPPOSED TO STAY OUT
+// OF. The rule that a vendor export never feeds a personal baseline was enforced
+// on the WRITE path only — three call sites check `isMeasuredDay`, while
+// `_BaselineHistoryCache.load()` read `metric_series` with no source filter at
+// all. Both importers write real series rows through `putDayResult`, so four of
+// the eight baselines (`rhr`, `rmssd`, `readiness`, `resp_rate`) were being set
+// partly by somebody else's algorithm. The other four escaped by accident, not
+// design — the importers happen to write `skin_temp_z` rather than
+// `skin_temp_adc`.
+//
+// NOOP is NOT foreign, which is the part worth remembering: `NoopIngest` holds a
+// DerivationEngine and feeds it reconstructed 1 Hz substrate, so those days are
+// our own maths and are stamped `source: 'band'`. Only `whoop_export` and
+// `cloud_v2` are somebody else's.
+//
+// `source` is NULL for every day written before schema 43 and the backfill
+// deliberately never fills it, so filtering on `source = 'band'` would have
+// deleted genuine early history — a pollution bug traded for a data-loss one.
+// It is decidable anyway: both importers put `imported: true` in the day
+// bundle, which is what the write path has always tested. `importedDates()` is
+// the union of both eras and the seam everything else reads through.
+//
+// Readiness, the illness CUSUM, and the training-zone and live-strain RHR
+// anchors all move for anyone with an import in range. For a user who never
+// imported this is a strict no-op: the set is empty and every read is unchanged.
+// Days already finalized keep the score they were derived with — raw is pruned,
+// so no bump can heal them.
+const int kAlgoVersion = 76;
/// The sibling SHAs this version was derived against, asserted against
/// pubspec.yaml in test/db_serve_version_and_reads_test.dart.
@@ -1244,14 +1298,20 @@ const int kAlgoVersion = 74;
/// so it is not repairable after the fact. That is exactly what happened
/// between v67 and v68. Repinning without touching this block fails the suite,
/// one line above the constant you then have to bump.
-// Both siblings are on MAIN now (protocol #29, analytics #46, merged
-// 2026-08-19). kAlgoVersion is deliberately NOT bumped with this repin: the
-// analytics hop is two comment lines in tests and touches no lib/ file at all,
-// and the protocol hop only adds `rr_ms` to decodeFrame's R10 branch, which
-// nothing in edge reads. No derived number moves, so forcing every install to
-// recompute would be churn with nothing on the other side of it.
-const String kAnalyticsPin = 'bfea5e56e74f336c3e3d83743123e58da225617d';
-const String kProtocolPin = 'fe3b681a3e9ca76f8a0865339035f949f36f6000';
+// Both siblings move with this bump, and both move NUMBERS this time — which
+// is the whole reason the version goes up. analytics: one active-energy gate
+// on heart-rate reserve instead of %HRmax (the day and the bout used to
+// disagree by 8-35 bpm depending on age and rest), and a measured quiet-waking
+// level under strain instead of a population constant that scored a day with
+// no activity at all somewhere between 6.9 and 12.1 out of 21. protocol: v25
+// stops emitting a gravity vector from offsets that were refuted on real data.
+// Both siblings moved again after their own review passes, and kAlgoVersion
+// deliberately did NOT: those fixes reject NaN and ±inf, which no sensor ever
+// produced and no baseline ever held. For a user whose data is valid, every
+// number out of both packages is byte-identical, so a bump would invalidate
+// every stored day to recompute the same answers.
+const String kAnalyticsPin = '3174a493472a5e6280b11a0ab11fec82483507e1';
+const String kProtocolPin = 'c761f29bcbed73886b1b059dcd9e92e4333574f5';
// Fold idempotency, the minimum-nights warm-up, and legacy-payload handling
// all live in SleepProfilePolicy (pure, unit-tested) — see
@@ -1430,7 +1490,21 @@ class _BaselineHistoryCache {
/// window possible at all. It must NOT be given a `limit` (that is `date ASC
/// LIMIT n`, i.e. the OLDEST n — the opposite of a trailing window); the
/// trailing window is taken here, in Dart, per target day.
+ ///
+ /// IMPORTED DAYS ARE EXCLUDED. `LocalDb.isMeasuredDay` kept another vendor's
+ /// export from OVERWRITING a measured day, but nothing kept it out of the
+ /// window on the way back in: a WHOOP or cloud import writes real
+ /// `metric_series` rows for `rhr`, `rmssd`, `readiness` and `resp_rate`, and
+ /// this load read them like any other day. Their scores are a different
+ /// algorithm's output over a different (or no) substrate, so blending them in
+ /// moves the median every personal z-score is taken against — silently, for
+ /// as long as the window is, and worst exactly when someone imports their
+ /// history on day one and has nothing else in the window at all.
+ ///
+ /// The mask is taken ONCE per load and applied to every key, because the
+ /// query behind it scans day bundles (see [LocalDb.importedDates]).
static Future<_BaselineHistoryCache> load() async {
+ final imported = await LocalDb.importedDates();
Future> hist(String key) async {
final rows = await LocalDb.metricSeries(key);
final out = <_DatedValue>[];
@@ -1438,6 +1512,7 @@ class _BaselineHistoryCache {
final date = row['date'];
final value = row['value'];
if (date is! String || date.isEmpty || value is! num) continue;
+ if (imported.contains(date)) continue;
out.add((date: date, value: value.toDouble()));
}
return out;
@@ -2264,6 +2339,41 @@ class DerivationEngine {
final candidate = SleepSessionCandidate.fromJson(
(jsonDecode(candidateJson) as Map).cast());
if (override == null) {
+ // NEVER RE-STAGE A NIGHT SHORTER THAN THE ONE ALREADY BANKED (#242).
+ //
+ // A day re-stages on every pass for its first 48 h, and the substrate it
+ // stages over does not only grow: `pruneDecodedBeforeRecTs` runs once the
+ // covering day is derived, so a later pass can look at the same night
+ // through less data and produce a shorter one — which then REPLACED the
+ // good candidate, and the day rebuilt from it. That is the reported "it
+ // got fixed, then a few syncs later it went back", and it is a write-path
+ // defect, not a staging one (a mid-night wake bridges and sums correctly).
+ //
+ // The guard belongs HERE rather than on the day result: the candidate is
+ // upstream of the sleep block, the hypnogram AND every sleep scalar, so
+ // keeping the richer one keeps the whole day internally consistent.
+ // Swapping a richer sleep block into a thinner day's bundle would pair
+ // last pass's night with this pass's stage minutes.
+ //
+ // Keyed at this algo version, so a bump still re-stages from scratch —
+ // that is what a bump is for. An override never reaches this branch, so a
+ // user shortening their own night is untouched.
+ final stored = await LocalDb.sleepSessionCandidate(dayId, kAlgoVersion);
+ final storedJson = stored?['payload_json'];
+ if (storedJson is String && storedJson.isNotEmpty) {
+ try {
+ final prev = SleepSessionCandidate.fromJson(
+ (jsonDecode(storedJson) as Map).cast());
+ if (isRicherSleep(prev, candidate)) {
+ _log('derive $dayId: kept the banked night '
+ '(${_tstSec(prev)} s) over this pass\'s '
+ '${_tstSec(candidate)} s — less substrate, not a shorter night');
+ return prev;
+ }
+ } catch (_) {
+ // Undecodable stored candidate — the fresh one is strictly better.
+ }
+ }
await LocalDb.putSleepSessionCandidate(
dayId: dayId,
algoVersion: kAlgoVersion,
@@ -3381,21 +3491,39 @@ class DerivationEngine {
});
}
final nb = blocks.notifBout;
- if (nb != null) {
+ // Only for a bout that is STILL waiting on an answer. The detector is
+ // pure and re-derives the same bouts every pass; dismissing one, logging
+ // it, or logging any session that covers its window retires the row
+ // (`supersededSuggestionIds`), and none of that reaches the detector. So
+ // the live table is what decides, not the detection — a notification
+ // about a workout already in the log is how someone turns all of them
+ // off. `putWorkoutSuggestion` ran a few lines up, so the row is there.
+ final live = nb == null
+ ? false
+ : (await LocalDb.activeWorkoutSuggestions())
+ .any((r) => r['id'] == nb.id);
+ if (nb != null && live) {
await NotificationCenter.instance.emit(
NotificationEvent(
// Per-bout, not per-day — a per-day key silently swallowed the
// notification for a second real workout later the same day
- // (fire-once-per-key by design). endSec is stable across re-derive
- // passes re-detecting the SAME bout, so that case still dedupes.
- dedupeKey: '${day.date}:auto_workout:${nb.endSec}',
- category: NotifCategory.recovery,
+ // (fire-once-per-key by design). The suggestion id is stable across
+ // re-derive passes re-detecting the SAME bout, so that case still
+ // dedupes, and it is date-prefixed so the fired-key store prunes it.
+ dedupeKey: '${nb.id}:auto_workout',
+ // NOT `recovery`. That channel is where "your recovery is ready"
+ // lived and `classOf` drops everything on it, so this notification
+ // has never once reached anybody: the suggestion row was written,
+ // the user was never told. This is a prompt about something that
+ // happened — reminders channel, NotifClass.prompt, and it respects
+ // quiet hours like every prompt should.
+ category: NotifCategory.reminders,
priority: NotifPriority.normal,
title: 'Did you work out?',
body: 'We spotted ~${nb.durationMin} min of elevated activity. '
'Tap to log it.',
date: day.date,
- route: kRouteWorkoutSuggestion,
+ route: workoutSuggestionRoute(nb.id),
),
// This runs from headless background derivation too — never prompt
// for permission from a background context (violates the OS
@@ -3750,6 +3878,33 @@ class DerivationEngine {
return carried;
}
+ /// The night's measured total sleep, seconds. Null when this candidate has no
+ /// night in it at all.
+ static num? _tstSec(SleepSessionCandidate c) =>
+ c.sleepJson['tst_sec'] as num?;
+
+ /// Whether the already-banked [prev] night is RICHER than the freshly staged
+ /// [next] one, measured by total sleep time (#242).
+ ///
+ /// TST, not confidence and not the window: it is the quantity the user sees
+ /// change, and the failure mode this guards is a re-stage over a pruned
+ /// substrate seeing less of the same night. A night that grows is a night the
+ /// band handed over more of, and it wins.
+ ///
+ /// A candidate with no night at all is never richer than one that has one, and
+ /// EQUAL is not richer — a pass that reproduces the same night writes, so an
+ /// otherwise-identical candidate still refreshes.
+ @visibleForTesting
+ static bool isRicherSleep(
+ SleepSessionCandidate prev,
+ SleepSessionCandidate next,
+ ) {
+ final p = _tstSec(prev);
+ if (p == null) return false;
+ final n = _tstSec(next);
+ return n == null || p > n;
+ }
+
/// How a day should be filed after its second half failed and the previous
/// result's detail was carried forward.
///
@@ -4609,10 +4764,15 @@ class DerivationEngine {
static ({double active, double basal, double total})? wakeDayEnergy(
List wakeHrPerMin, {
required Profile profile,
+ required double? restingHr,
int? dayMinutes,
String? deviceFamily,
}) {
if (!profile.hasCalorieAnchors) return null;
+ // The active gate is a %HRR flex point, so it needs BOTH ends of the
+ // reserve. No resting HR, no gate — and no gate means every wake minute
+ // bills as active. Abstain, same as an absent ceiling below.
+ if (restingHr == null) return null;
// `dailyEnergy`'s flex gate is a fraction of HRmax, so an absent ceiling is
// an absent gate — the whole triple abstains rather than bill a day against
// some other strap's number. See hr_max.dart.
@@ -4636,8 +4796,13 @@ class DerivationEngine {
sex: _workoutSex(profile.sex),
),
hrmax: hrmax,
+ restingHr: restingHr,
dayMinutes: dayMinutes ?? 1440,
);
+ // Anchors that cannot define an active gate are an ABSENT day's energy,
+ // not a day billed entirely as active. `dailyEnergy` abstains; so does the
+ // day, which is what every other caller of this method already expects.
+ if (e == null) return null;
return (active: e.active, basal: e.basal, total: e.total);
}
@@ -5278,6 +5443,9 @@ class DerivationEngine {
final score = ana.strainScoreMetric(
trimp.value,
wakeMinutes: perMin.length.toDouble(),
+ // Reference level, not this user's — see onehz_pipeline's
+ // `strainMetric` for why, and edge#226 for the fix.
+ quietHrr: ana.quietWakingHrr,
female: _workoutSex(sex) == 'female',
);
if (score.present) strain = score.value;
@@ -5331,6 +5499,9 @@ class DerivationEngine {
final energy = wakeDayEnergy(
perMin,
profile: profile,
+ // The same anchor the TRIMP above is scored against — a nocturnal RHR
+ // or the one the user entered, never a daytime fallback.
+ restingHr: rhrForTrimp,
dayMinutes: motion.length,
deviceFamily: daySub.deviceFamily,
);
@@ -5340,11 +5511,19 @@ class DerivationEngine {
caloriesBasal = energy.basal;
}
}
+ // Same peak, same smoothing as the pipeline's copy and as every workout
+ // producer — see `hr_max.dart` and the note beside the pipeline's `hrStats`.
+ // A bare max over raw 1 Hz let one PPG transient be the day's "Peak HR"
+ // (#127).
+ final dayHrInt = [for (final h in dayHrValid) h.round()];
+ final age = profile.ageYears?.round();
final hrStats = dayHrValid.isEmpty
? null
: {
- 'max': dayHrValid.reduce(math.max).round(),
- 'min': dayHrValid.reduce(math.min).round(),
+ 'max': smoothedMaxHr(dayHrInt, age: age) ??
+ dayHrValid.reduce(math.max).round(),
+ 'min': smoothedMinHr(dayHrInt, age: age) ??
+ dayHrValid.reduce(math.min).round(),
'avg': _meanWake(dayHrValid)?.round(),
};
return {
@@ -6856,11 +7035,15 @@ class DerivationEngine {
// don't resurface 90 days of prompts.
final recent = (dataNowSec - dayEndSec) < 36 * 3600;
final toPersist =