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 battery Your 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 = >[]; - ({int endSec, int durationMin})? notif; + ({String id, int durationMin})? notif; + // ONE definition of the row id. The notification checks the table by it + // and opens the screen on it, so a second copy of the format here would + // drift into a prompt that silently never fires again. + String sugId(int startSec) => '$date:$startSec'; if (recent && bouts.isNotEmpty) { for (final b in bouts) { toPersist.add({ - 'id': '$date:${b.startSec}', + 'id': sugId(b.startSec), 'date': date, 'start_ts': b.startSec, 'end_ts': b.endSec, @@ -6878,7 +7061,10 @@ class DerivationEngine { // above so they surface in the Workouts screen; we just don't ping for them. final newest = bouts.reduce((a, b) => a.endSec >= b.endSec ? a : b); if ((dataNowSec - newest.endSec) < 2 * 3600) { - notif = (endSec: newest.endSec, durationMin: newest.durationMin); + notif = ( + id: sugId(newest.startSec), + durationMin: newest.durationMin, + ); } } return _WorkoutCompute( @@ -7381,7 +7567,7 @@ class _DayBlocksOutput { final Map wake; final List> suggestionsToPersist; final List<(String, double)> sessionHrrWrites; - final ({int endSec, int durationMin})? notifBout; + final ({String id, int durationMin})? notifBout; const _DayBlocksOutput({ required this.bundlePatch, required this.seriesPatch, @@ -7400,7 +7586,7 @@ class _WorkoutCompute { final double? hrrTauS; final List<(String, double)> sessionHrrWrites; final List> suggestionsToPersist; - final ({int endSec, int durationMin})? notifBout; + final ({String id, int durationMin})? notifBout; const _WorkoutCompute({ required this.boutJson, required this.hrrBpm, diff --git a/lib/compute/manual_session.dart b/lib/compute/manual_session.dart index 35330dd1..1a2af375 100644 --- a/lib/compute/manual_session.dart +++ b/lib/compute/manual_session.dart @@ -31,6 +31,7 @@ import 'dart:convert'; import 'package:openstrap_analytics/onehz.dart' as ana; +import 'hr_max.dart' show smoothedMaxHr; import 'profile.dart'; /// Shortest window we accept. Below a minute the 1 Hz substrate cannot say @@ -270,6 +271,9 @@ double? strainFromPerMinuteHr( final score = ana.strainScoreMetric( trimp.value, wakeMinutes: perMinuteHr.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', ); return score.present ? score.value : null; @@ -322,10 +326,17 @@ ManualSessionStats computeManualSessionStats({ if (worn.isEmpty) return const ManualSessionStats(); final avg = worn.reduce((a, b) => a + b) / worn.length; - final peak = worn.reduce((a, b) => a > b ? a : b); final perMin = hrPerMinute(wornTs, worn); final age = profile.ageYears?.toDouble(); + // THE peak, spike-suppressed, at the point every save goes through (#127). + // This was a raw `reduce(max)` and one caller re-smoothed it afterwards, so a + // manually logged or retimed session banked the transient — and once the raw + // window is pruned there is nothing left to correct it from. Smoothing here + // means the stored value is the same quantity the re-score and the Heart page + // report, rather than three producers agreeing by convention. + final peak = smoothedMaxHr(worn, age: age?.round()) ?? + worn.reduce((a, b) => a > b ? a : b); final weightKg = profile.weightKg; final sex = profile.sex?.toLowerCase(); @@ -562,7 +573,26 @@ ReconciledSessionScore reconcileSessionScore({ final strain = better(liveStrain, substrate.strain); final calories = better(liveCalories, substrate.calories); - final maxHr = better(liveMaxHr, substrate.maxHr); + // MAX HR IS NOT A LOWER BOUND, so `better` is the wrong rule for it (#127). + // Strain and calories accumulate: over a subset of the window each is a floor, + // and the larger of two floors is the better estimate. A maximum moves the + // other way — an artefact only ever makes it BIGGER, so `max(live, substrate)` + // is a ratchet that a single PPG transient wins forever. It did: a session + // saved before the peak was smoothed carries a spike in `max_hr`, the + // substrate re-scores it to the real figure, and the ratchet put the spike + // straight back on every pass under 90 % coverage. + // + // The substrate is the same band's record of the same window with artefact + // rejection applied, and it is what the Heart page and the day's Peak HR are + // read from — so when it has a peak, that is the peak, and every surface says + // the same number. The live value survives only where the substrate has none. + // + // THE COST, accepted: a window the band never fully hands over can report a + // peak lower than the live tally saw. That is not a new understatement — it + // is the same one the session's HR trace and the day's Peak HR already show + // for those minutes, and #127 is a complaint about two screens disagreeing, + // not about the peak being low. + final maxHr = substrate.maxHr ?? liveMaxHr; // Zone minutes are a vector of the same lower-bound quantity, so take the // side with more total measured minutes rather than mixing two partial diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 29b8e5a6..6df7c356 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -29,7 +29,8 @@ import 'package:openstrap_analytics/onehz.dart'; // does not compromise this file's isolate safety. It is here so the sex // normalisation has ONE definition across the pipeline and the coordinator // instead of two that can drift. -import 'hr_max.dart' show estimatedMaxHr, trainingZones; +import 'hr_max.dart' + show estimatedMaxHr, smoothedMaxHr, smoothedMinHr, trainingZones; import 'profile.dart' show workoutSex; // Same argument: a pure `DateTime` lookup, no DB / IO / Flutter binding. It is // the ONE definition of "the UTC offset in effect at this instant" in the tree, @@ -480,6 +481,33 @@ Map deriveDayBundle(Map inputJson) { final double? skinTempCoverage = (inBedSec == null || inBedSec <= 0) ? null : (tempValid.length / inBedSec).clamp(0.0, 1.0); + // HOW MUCH OF THE NIGHT THE STRAP SPENT AT SKIN TEMPERATURE (#250). + // + // `tempInput` refuses readiness's temp driver outright when this is null, and + // nothing in this app has ever passed it — so the documented FOURTH DRIVER + // has never contributed on any night, the other three renormalised over 0.90, + // and "Skin temperature" could not appear in a breakdown. This is the number + // it wants: the share of the night's valid samples sitting within the + // family's settle band of the night's OWN median (warm-up and off-body read + // low; a fever reads high and passes through). + // + // MEASURED HERE, GATED IN `tempInput` — hence `minSettledFraction: 0`. + // `nightlySkinTemp` would otherwise go absent on an unsettled night and the + // fraction would be lost, which lands on the "nobody measured it" refusal + // instead of the true "the strap was cold for two hours" one. It still goes + // absent for a family whose settle band nobody has measured (gen5 has none) + // and for a night under sixty samples, and those genuinely ARE "no fraction + // measured". + // + // Ts is not read by `nightlySkinTemp` (it is a median + a mean over the + // night's samples), and `tempValid` has no parallel timestamp series, so 0 + // is passed rather than a fabricated clock. + final settledTemp = nightlySkinTemp( + [for (final v in tempValid) AdcSample(0, v)], + deviceFamily: d.deviceFamily, + minSettledFraction: 0.0, + ); + final double? skinTempSettledFrac = settledTemp.value?.settledFraction; // STEP 2 — z-score today's RAW mean against the RAW-ADC baseline history (NOT // the previously-computed z-scores; that unit mismatch was the bug). Gated on // ≥3 prior raw means. @@ -509,7 +537,16 @@ Map deriveDayBundle(Map inputJson) { // Feed the RAW ADC mean + the RAW-ADC baseline so the composite computes its // own oriented robust-z internally (consistent with the other inputs, which // pass raw values + their raw baselines). - tempInput(skinTempAdc, d.skinTempAdcHistory), + // + // The mean stays RAW — value and baseline have to be the same quantity, and + // the stored history is a series of raw nightly means. The settled fraction + // is the GATE on using it at all: below 0.80 the driver is refused for this + // night, by name, and readiness renormalises over the three that are left. + tempInput( + skinTempAdc, + d.skinTempAdcHistory, + settledFraction: skinTempSettledFrac, + ), ]); // Diagnostic only — populated when readiness comes back absent, so the main // isolate can log WHY to Crashlytics instead of a bare null (this runs @@ -545,6 +582,9 @@ Map deriveDayBundle(Map inputJson) { 'value': skinTempAdc != null, 'baseline_n': d.skinTempAdcHistory.length, 'baseline_sd': _stddev(d.skinTempAdcHistory), + // The gate, not the value: a temp driver can be refused with a perfectly + // good mean and a full baseline. Null = the fraction was unmeasurable. + 'settled_frac': skinTempSettledFrac, }, 'note': composite.note, }; @@ -684,10 +724,16 @@ Map deriveDayBundle(Map inputJson) { // active figure this line publishes (about 117 kcal/day across a // 150-195 cm profile). `wakeDayEnergy` abstains for that reason; so does // this, or Today shows an imputed number the derived day then withdraws. + // + // The RESTING HR is required for the same class of reason: `dailyEnergy`'s + // active gate is a %HRR flex point, so without the lower reserve anchor + // there is no gate and every wake minute bills as active. `wakeDayEnergy` + // abstains without it; so does this, or the two drift again. if (age != null && sex != null && weightKg != null && - heightCm != null) { + heightCm != null && + rhrForTrimp != null) { caloriesKcal = Calories.dailyEnergy( perMin, profile: WorkoutUserProfile( @@ -697,7 +743,11 @@ Map deriveDayBundle(Map inputJson) { sex: workoutSex(sex), ), hrmax: hrMax, - ).active; // active-energy component (Keytel surplus over basal) + restingHr: rhrForTrimp, + // `?.` — `dailyEnergy` abstains outright when the anchors cannot + // define a gate, rather than billing every waking minute as active. + // Absent stays absent here, same as every other input on this seam. + )?.active; // active-energy component (Keytel surplus over basal) } } @@ -708,6 +758,17 @@ Map deriveDayBundle(Map inputJson) { final strainMetric = strainScoreMetric( rawTrimp, wakeMinutes: perMin.isEmpty ? null : perMin.length.toDouble(), + // THE REFERENCE LEVEL, NOT THIS USER'S (edge#226 is still open). analytics + // stopped defaulting the quiet-waking level so every caller has to state + // which one it means; `quietWakingHrr` is the constant the anchor table was + // generated at, so passing it reproduces the strain this app ships today + // and nobody's number moves on this commit. The real level is + // `dailyQuietWakingHrr` fed through a rolling personal median — a trait, + // not a day, and the workout scorers need the same one the day uses or a + // bout subtracts its own effort away. That plumbing is edge#226. + // ponytail: population constant, swap for the rolling personal median when + // edge#226 lands — see the same comment at the other four call sites. + quietHrr: quietWakingHrr, female: workoutSex(sex) == 'female', ); @@ -1008,11 +1069,23 @@ Map deriveDayBundle(Map inputJson) { } // ── HR stats over the day's valid HR (for the strain detail hr {max,avg,min}). + // + // THE DAY PEAK GOES THROUGH THE SAME SMOOTHING AS EVERY WORKOUT PEAK (#127). + // This used to be a bare `reduce(math.max)` over raw 1 Hz, so one PPG motion + // transient WAS the day's "Peak HR" on the strain card while the Heart page — + // reading per-minute means — showed the real peak: the 160-vs-143 pair the + // issue reported, moved to a different screen rather than fixed. `hr_max.dart` + // is the one definition (physiological reject + 5 s rolling median, which + // steps over a 1-2 s spike but keeps a genuine brief effort peak). Min is the + // symmetric case: a 1 s dropout must not define the day's low either. + final dayHrInt = [for (final h in dayHrValid) h.round()]; final hrStats = dayHrValid.isEmpty ? null : { - 'max': dayHrValid.reduce(math.max).round(), - 'min': dayHrValid.reduce(math.min).round(), + 'max': smoothedMaxHr(dayHrInt, age: age?.round()) ?? + dayHrValid.reduce(math.max).round(), + 'min': smoothedMinHr(dayHrInt, age: age?.round()) ?? + dayHrValid.reduce(math.min).round(), 'avg': _mean(dayHrValid)!.round(), }; @@ -1236,6 +1309,13 @@ Map deriveDayBundle(Map inputJson) { 'skin_temp_coverage_frac': skinTempCoverage == null ? null : _round(skinTempCoverage, 4), + // RD-15 — the settled fraction readiness's temp driver is gated on, so a + // night whose driver was refused can be told apart from one where the + // gate never ran. NULL means the fraction itself is unmeasurable (no + // settle band for this band's family, or under sixty samples). + 'skin_temp_settled_frac': skinTempSettledFrac == null + ? null + : _round(skinTempSettledFrac, 4), 'sdnn': hrvT.present ? hrvT.value!.sdnn : null, // CV-03 — deceleration capacity (ms). Personal trend only: PRSA anchors on // decelerations and pulse-arrival jitter attenuates DC by an amount that @@ -1519,7 +1599,14 @@ List> _strainCurve( out.add({ 't': p.tsSec, 'v': _round( - strainScore(trimp, wakeMinutes: wakeMin, female: female), + strainScore( + trimp, + wakeMinutes: wakeMin, + // Reference level, not this user's — see onehz_pipeline's + // `strainMetric` for why, and edge#226 for the fix. + quietHrr: quietWakingHrr, + female: female, + ), 2, ), }); diff --git a/lib/compute/strain_backfill.dart b/lib/compute/strain_backfill.dart index 5a670caa..46762b80 100644 --- a/lib/compute/strain_backfill.dart +++ b/lib/compute/strain_backfill.dart @@ -71,7 +71,14 @@ double? rescaledStrain({ required bool female, }) { if (trimp == null || wakeMinutes == null || wakeMinutes <= 0) return null; - return ana.strainScore(trimp, wakeMinutes: wakeMinutes, female: female); + return ana.strainScore( + trimp, + wakeMinutes: wakeMinutes, + // Reference level, not this user's — see onehz_pipeline's + // `strainMetric` for why, and edge#226 for the fix. + quietHrr: ana.quietWakingHrr, + female: female, + ); } /// Rescale every stored day that can no longer be re-derived from raw. diff --git a/lib/data/db.dart b/lib/data/db.dart index f989acac..bd049433 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -3829,9 +3829,15 @@ class LocalDb { counter: r.counter, hr: r.hr, rrIntervalsMs: List.from(r.rrIntervalsMs), - ax: r.accelG.isNotEmpty ? r.accelG[0] : 0, - ay: r.accelG.length > 1 ? r.accelG[1] : 0, - az: r.accelG.length > 2 ? r.accelG[2] : 0, + // ABSENT ACCEL STAYS ABSENT. These used to coalesce to 0, which is + // a reading — a perfectly still wrist — and it is the same + // fabricated stillness the nullable columns and the v25 refusal + // above exist to prevent. protocol now returns an empty `accelG` + // for a record whose accelerometer it will not vouch for, so the + // fallback is null, exactly as the gen5 `gravityG` path above does. + ax: r.accelG.isNotEmpty ? r.accelG[0] : null, + ay: r.accelG.length > 1 ? r.accelG[1] : null, + az: r.accelG.length > 2 ? r.accelG[2] : null, spo2RedRaw: r.spo2RedRaw, spo2IrRaw: r.spo2IrRaw, // raw column passthrough, same as the ble path. not read as a temp. @@ -6086,6 +6092,71 @@ class LocalDb { return true; } + /// SQL selecting the `date` of every day whose scalars were IMPORTED. + /// A fragment so the mask and the filters that apply it cannot drift apart. + /// + /// TWO SIGNALS, because the exact one is younger than the data: + /// + /// * `metric_series_version.source` — precise, but the column only exists + /// from schema v43 and is deliberately NEVER retro-filled (a guessed + /// provenance is worse than none). Every day written before v43 reads + /// NULL, which is most of any real user's history. + /// * `day_result.payload_json`'s `"imported": true` — the marker BOTH + /// importers have written since they existed, and the same flag + /// [isMeasuredDayRow] tests on the write path. + /// + /// The second is what makes a NULL `source` DECIDABLE rather than ambiguous: + /// NULL means "the column did not exist yet", not "unknown vendor", and the + /// bundle behind that day still says who wrote it. So NULL is resolved + /// against the payload rather than treated as suspect — dropping every + /// NULL-source day would delete the user's genuine pre-v43 history from + /// their own baselines, i.e. fabricate a baseline out of a short recent + /// window, which is the worse fault of the two. + /// + /// A substring match, not `json_extract`: `jsonEncode` emits no spaces and + /// this app is the only writer of the flag, so the literal is exact, and it + /// does not assume a JSON1-enabled sqlite on every platform we ship to. + /// + /// The `IS NOT NULL` guards are not decoration. SQLite does not enforce NOT + /// NULL on a declared PRIMARY KEY column of a legacy rowid table, and a + /// single NULL inside a `NOT IN (…)` list makes the whole predicate NULL for + /// EVERY row — one stray row would silently empty every baseline in the app + /// rather than filter one day out of it. + /// THE SERVED VERSION ONLY on the `day_result` half, for the same reason + /// every other reader uses [_servedDayJoin]: `PRIMARY KEY (day_id, + /// algo_version)` makes versions siblings, so an imported day that the band + /// LATER re-derived keeps its old imported row sitting beside the new + /// measured one. Testing every row made that day imported FOREVER — masked + /// out of the baselines it is now entitled to be in, with no way back + /// short of deleting the superseded row. `metric_series_version` needs no + /// such guard: it is `PRIMARY KEY (date)` and the last writer replaces it, + /// which is exactly why the stamp exists. + static const String _importedDatesSql = + 'SELECT date FROM metric_series_version ' + "WHERE date IS NOT NULL AND source IS NOT NULL AND source <> 'band' " + 'UNION ' + 'SELECT r.day_id FROM day_result r ' + '$_servedDayJoin ' + "WHERE r.day_id IS NOT NULL AND r.payload_json LIKE '%\"imported\":true%'"; + + /// Day labels whose stored scalars are ANOTHER vendor's derived numbers. + /// + /// THE MASK for every baseline read, and the inverse of [isMeasuredDay]: + /// that one guards the WRITE path (an import must not clobber a measured + /// day), and nothing guarded the read path — so imported days were feeding + /// the readiness and illness baselines the user's own scores are measured + /// against. A window that mixes them is not a baseline of this person. + /// + /// Returned as a set rather than applied inside each query on purpose: the + /// scan behind it is over `day_result.payload_json` (whole day bundles), so + /// a caller reading several series takes it ONCE and filters in Dart. + static Future> importedDates() async { + final db = await instance; + return { + for (final r in await db.rawQuery(_importedDatesSql)) r['date'] as String, + }; + } + /// Import another device's exported OpenStrap DB ([path], from [exportCopy] + /// share) by MERGING its rows into this one (INSERT-OR-REPLACE). Covers derived /// results, the metric series, user data, and the raw ledger so the receiving @@ -6854,14 +6925,23 @@ class LocalDb { } /// A long-format metric series (oldest first) for trends/sparklines. + /// + /// [measuredOnly] drops days another vendor's export wrote (see + /// [importedDates]). OFF by default: a trend line is a picture of the user's + /// history and imported days belong in it. Turn it ON for anything that + /// COMPUTES against the series — a baseline, a personal percentile, a + /// seed-versus-band comparison — where a foreign algorithm's output is not + /// the same measurement. static Future>> metricSeries( String key, { int? limit, + bool measuredOnly = false, }) async { final db = await instance; return db.query( 'metric_series', - where: 'key = ? AND value IS NOT NULL', + where: 'key = ? AND value IS NOT NULL' + '${measuredOnly ? ' AND date NOT IN ($_importedDatesSql)' : ''}', whereArgs: [key], orderBy: 'date ASC', limit: limit, @@ -6873,11 +6953,20 @@ class LocalDb { /// OLDEST n days), this is the right window for a rolling baseline. Because /// metric_series is keyed `(date, key)` with REPLACE, there is exactly one row /// per day, so the result is inherently de-duplicated. - static Future> trailingSeriesValues(String key, int n) async { + /// + /// [measuredOnly] defaults ON here, unlike [metricSeries]: this helper exists + /// to build a rolling baseline, and a baseline blended with another vendor's + /// derived numbers is not a baseline of this person (see [importedDates]). + static Future> trailingSeriesValues( + String key, + int n, { + bool measuredOnly = true, + }) async { final db = await instance; final rows = await db.rawQuery( 'SELECT value FROM metric_series ' 'WHERE key = ? AND value IS NOT NULL ' + '${measuredOnly ? 'AND date NOT IN ($_importedDatesSql) ' : ''}' 'ORDER BY date DESC LIMIT ?', [key, n], ); diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index eda57b68..212f0604 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -2672,7 +2672,7 @@ class LocalRepositoryImpl extends LocalRepository { final profile = Profile.fromMap(getProfileMap()); final hrBpm = [for (final e in hrRows) (e['hr'] as num).toInt()]; - final raw = computeManualSessionStats( + final stats = computeManualSessionStats( hrTs: [for (final e in hrRows) (e['rec_ts'] as num).toInt()], hrBpm: hrBpm, profile: profile, @@ -2682,19 +2682,10 @@ class LocalRepositoryImpl extends LocalRepository { zoneSet: _zoneSetFor( row['device_family'] as String?, await _zoneAnchors()), ); - // `computeManualSessionStats` reports the raw 1 Hz peak. Persisting that - // writes a PPG spike into the column `getWorkout` deliberately refuses to - // floor against (issue #127) — and once raw ages out past retention the - // list has no smoothed value left to prefer, so the artefact would become - // permanent. Store the spike-suppressed peak instead. - final stats = ManualSessionStats( - avgHr: raw.avgHr, - maxHr: smoothedMaxHr(hrBpm, age: _profileAge()) ?? raw.maxHr, - strain: raw.strain, - calories: raw.calories, - zoneMinutes: raw.zoneMinutes, - hrSampleCount: raw.hrSampleCount, - ); + // The peak is smoothed inside `computeManualSessionStats` now — one + // definition for the manual save, this re-score and the workout list + // (#127), instead of the raw peak being re-smoothed here and banked raw + // everywhere else. Nothing to re-wrap. // "Complete" = the band has handed over essentially the whole window. // 1 Hz means one sample per second, so sample count vs window seconds is @@ -3169,14 +3160,28 @@ class LocalRepositoryImpl extends LocalRepository { }, ]; + // MEASURED DAYS ONLY. This is a comparison of the user against + // themselves; an imported day is another vendor's derived score on a + // different scale, and it lands in the window mean every tagged day is + // priced against (see LocalDb.importedDates). The chart underneath still + // shows those days — a picture may be spliced, a statistic may not. + // + // TAKEN ONCE AND FILTERED IN DART, which is what the mask is a Set for. + // `measuredOnly: true` inlines it as a subquery, and the half of it that + // matters is a LIKE over `day_result.payload_json` — whole day bundles, + // tens of kilobytes each, re-scanned per series. Four outcomes made that + // four full passes over the user's entire history to build four maps off + // one answer that cannot change between them. + final imported = await LocalDb.importedDates(); // date → value maps for each outcome. final maps = >{}; for (final od in outcomeDefs) { final key = od['key'] as String; final m = {}; for (final r in await LocalDb.metricSeries(key)) { + final d = r['date']; final v = (r['value'] as num?)?.toDouble(); - if (v != null) m[r['date'] as String] = v; + if (v != null && d is String && !imported.contains(d)) m[d] = v; } maps[key] = m; } @@ -3389,7 +3394,10 @@ class LocalRepositoryImpl extends LocalRepository { Future> getWeekdayEffect({ String key = 'readiness', }) async { - final rows = await LocalDb.metricSeries(key); + // MEASURED DAYS ONLY — a permutation test over a series spliced from two + // different algorithms reports the splice, not the weekday (same reasoning + // as the journal outcomes above). + final rows = await LocalDb.metricSeries(key, measuredOnly: true); if (rows.isEmpty) return const {}; final dates = []; final values = []; diff --git a/lib/data/models.dart b/lib/data/models.dart index 3a3be661..02eede79 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -382,4 +382,40 @@ class DeviceState { String? generation; DeviceState({this.connection = 'disconnected'}); + + /// Back to "no band has ever connected this process". + /// + /// The engine holds ONE of these for its whole life, so without this a + /// forget leaves the old strap's serial, name, generation and battery in + /// place — and a re-pair with a different band then shows them until the new + /// link happens to overwrite each one. `generation` is the one that is not + /// cosmetic: it is the key every sensor-dependent metric looks its constants + /// up under, and the device page states it as a calibration fact. + /// + /// The bond/radio verdicts go too. They are findings about the band that was + /// forgotten — an `autoReconnectPaused` left standing would silently pause + /// the reconnect loop for the NEXT band as well. + void reset() { + address = null; + serial = null; + batteryPct = null; + charging = null; + chargingTs = null; + wristOn = null; + liveHr = null; + liveHrAt = null; + alarmEpoch = null; + strapName = null; + generation = null; + connection = 'disconnected'; + standardHrFallback = false; + needsRepairGuide = false; + bondRefusals = 0; + autoReconnectPaused = false; + syncClockLost = false; + strapNeedsReboot = false; + syncChunkQuarantined = false; + dataRangeOldest = null; + dataRangeNewest = null; + } } diff --git a/lib/data/off_lookup.dart b/lib/data/off_lookup.dart index bd213d05..993b4681 100644 --- a/lib/data/off_lookup.dart +++ b/lib/data/off_lookup.dart @@ -3,9 +3,9 @@ // THIS IS AN OUTBOUND NETWORK CALL, and this app's whole position is that it // makes none it did not ask you about. So it is shaped like the only other one // that touches your data (ui2/activity/tiles.dart, the map basemap): -// · [offLookupAllowed] is off until the user turns it on, and every entry -// point here refuses without it — there is no code path that fetches by -// accident; +// · [offLookupAllowed] gates every entry point here, so turning it off in +// Settings stops the lookup dead — there is no code path that fetches +// around it; // · it is user-initiated, one product per scan, never a batch and never a // background job; // · what leaves is the barcode. Not the meal, not the day, not who you are; @@ -60,16 +60,29 @@ const _userAgent = // ══════════════════ CONSENT ══════════════════ -/// Whether the user has said openfoodfacts.org may be asked about a barcode. +/// Whether openfoodfacts.org may be asked about a barcode. /// -/// Default OFF and persisted, like every other outbound path in this app -/// (crash reports, health contribution, update checks, map tiles). Revocable -/// from Settings › Privacy, and the scanner is fully usable without it in the -/// only sense that matters: typing the numbers off the pack was always the -/// fallback and still is. +/// Default ON — with the update check, and unlike every path that would send +/// something ABOUT YOU (crash reports, health contribution), which stay off +/// until asked. The line between them is what leaves: this sends a number +/// printed on a packet by its manufacturer, and a scanner that refuses to scan +/// until you have found a settings toggle is a scanner nobody uses. +/// +/// Still persisted and still revocable from Settings › Privacy, and the food +/// log is entirely usable with it off: typing the numbers off the pack was +/// always the fallback and still is. +/// +/// Default-on and fail-closed are not in tension, because they answer two +/// different questions. `Prefs.getBool`'s fallback covers BOTH "loaded, no key +/// yet" (a fresh install — default on, deliberately) and "prefs never loaded" +/// (we cannot see the answer). Reading the second as the first sends the +/// barcode of someone who explicitly opted out, which is the one thing a +/// revocable consent must never do. So storage has to be there before the +/// default counts. const kOffConsentKey = 'nutrition.barcode_lookup'; -bool get offLookupAllowed => Prefs.getBool(kOffConsentKey, false); +bool get offLookupAllowed => + Prefs.loaded && Prefs.getBool(kOffConsentKey, true); void setOffLookupAllowed(bool on) => Prefs.setBool(kOffConsentKey, on); diff --git a/lib/gestures/device_action.dart b/lib/gestures/device_action.dart index f12320b4..943bc0b7 100644 --- a/lib/gestures/device_action.dart +++ b/lib/gestures/device_action.dart @@ -2,14 +2,18 @@ // (today: double-tap) can trigger. The enum is the single source of truth shared by // the settings UI, the persisted mapping, and the native dispatch channel. // -// Adding a new action is one entry here + one `case` in the native handlers -// (ActionHandler.kt / ActionBridge.swift). Whether a platform actually SUPPORTS an -// action is reported at runtime by DeviceActions.capabilities() — the UI only offers -// what the current OS can do, so e.g. volume control simply doesn't appear on iOS. +// Adding a new NATIVE action is one entry here + one `case` in the native handlers: +// NativeChannels.kt on Android, the ActionBridge enum in AppDelegate.swift on iOS. +// An IN-APP action needs neither — one `case` in GestureDispatcher and a handler +// wired from AppState, and it works on every platform. +// +// Whether a platform actually SUPPORTS a native action is reported at runtime by +// DeviceActions.capabilities() — the UI only offers what the current OS can do, so +// e.g. volume control simply doesn't appear on iOS. // // FUTURE (deliberately not wired yet — each needs more than a no-risk API or a // product decision): answer/reject call (Android ANSWER_PHONE_CALLS; impossible on -// iOS), "mark a moment" journal tag, workout lap/stop, torch (camera permission). +// iOS), workout lap. enum DeviceAction { none, @@ -24,6 +28,7 @@ enum DeviceAction { // (iOS can't reach other apps, but it can always do these). markMoment, workoutToggle, + logWater, // Native broadcast — sends an Android broadcast intent for Tasker to subscribe // to (see NativeChannels.kt). Only offered on Android. broadcastToTasker, @@ -54,6 +59,8 @@ extension DeviceActionX on DeviceAction { return 'mark_moment'; case DeviceAction.workoutToggle: return 'workout_toggle'; + case DeviceAction.logWater: + return 'log_water'; case DeviceAction.broadcastToTasker: return 'broadcast_to_tasker'; } @@ -82,6 +89,8 @@ extension DeviceActionX on DeviceAction { return 'Mark a moment'; case DeviceAction.workoutToggle: return 'Start / stop workout'; + case DeviceAction.logWater: + return 'Log water'; case DeviceAction.broadcastToTasker: return 'Broadcast to Tasker'; } @@ -110,6 +119,9 @@ extension DeviceActionX on DeviceAction { return 'Tag the current moment in your journal.'; case DeviceAction.workoutToggle: return 'Begin or end a workout from your wrist.'; + case DeviceAction.logWater: + return 'Add a glass to today\'s water, same step as the + on the ' + 'nutrition screen.'; case DeviceAction.broadcastToTasker: return 'Fire a broadcast intent so Tasker can trigger any automation.'; } @@ -118,7 +130,9 @@ extension DeviceActionX on DeviceAction { /// In-app actions act on our own app/backend (handled in Dart, no native call, /// available on every platform). Everything else (except `none`) is native. bool get isInApp => - this == DeviceAction.markMoment || this == DeviceAction.workoutToggle; + this == DeviceAction.markMoment || + this == DeviceAction.workoutToggle || + this == DeviceAction.logWater; bool get isNative => this != DeviceAction.none && !isInApp; diff --git a/lib/gestures/gesture_dispatcher.dart b/lib/gestures/gesture_dispatcher.dart index f9125724..c6dc3974 100644 --- a/lib/gestures/gesture_dispatcher.dart +++ b/lib/gestures/gesture_dispatcher.dart @@ -21,12 +21,14 @@ class GestureDispatcher { /// platform channel instead. final Future Function()? onMarkMoment; final Future Function()? onWorkoutToggle; + final Future Function()? onLogWater; GestureDispatcher({ required this.settings, this.log, this.onMarkMoment, this.onWorkoutToggle, + this.onLogWater, }); static const int _doubleTapEventId = 14; // EventId.doubleTap @@ -66,7 +68,14 @@ class GestureDispatcher { case DeviceAction.workoutToggle: onWorkoutToggle?.call(); break; + case DeviceAction.logWater: + onLogWater?.call(); + break; default: + // isInApp said yes and there is no case for it — an action that is + // offered in the picker and then does nothing, which is the exact + // failure the picker exists to end. Say so rather than return quietly. + log?.call('[gesture] ${action.id} is in-app with no handler'); break; } return; diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 5bd82fe6..f4681ade 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -23,6 +23,7 @@ import 'dart:io' show Platform; import 'package:android_intent_plus/android_intent.dart'; import 'package:flutter/foundation.dart'; import 'package:health/health.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../data/db.dart'; import '../data/series_codec.dart'; @@ -39,14 +40,38 @@ enum HealthLinkState { unsupported, // no health store on this device (iPad / simulator) } +/// The user's "sync to Apple Health / Health Connect" switch. `AppState` owns +/// the toggle; the key lives here so an export seam reached without an +/// AppState can honour the same answer instead of keeping a second copy of the +/// string. +const String kHealthSyncPref = 'health_sync'; + const _sleepHealthTypes = { HealthDataType.SLEEP_DEEP, HealthDataType.SLEEP_REM, HealthDataType.SLEEP_LIGHT, HealthDataType.SLEEP_AWAKE, + // The night's envelope. Health Connect models it as a SleepSessionRecord + // parent; HealthKit has no session record, so the enclosing bar is an + // `inBed` sleepAnalysis sample. Only one of the two is ever asked for — + // see `_types` and `_sleepEnvelopeFor` — but both belong to the sleep + // delete SCOPE, which is what this set answers. HealthDataType.SLEEP_SESSION, + HealthDataType.SLEEP_IN_BED, }; +/// The envelope type the OTHER store uses, which this one must never be sent. +/// +/// SLEEP_SESSION is Health-Connect-only. Handing it to HealthKit is not a +/// harmless no-op: the plugin resolves an unknown key to bodyMass and runs a +/// sample query for a type we never asked permission for, which errors — and +/// the error path never calls back, so `delete()` never completes. That hangs +/// the day's export, which is the stall (#239/#225) this whole seam exists to +/// stop, re-entered through the delete side. +HealthDataType _foreignSleepEnvelope(bool isApplePlatform) => isApplePlatform + ? HealthDataType.SLEEP_SESSION + : HealthDataType.SLEEP_IN_BED; + List healthDeleteTypes({required bool isApplePlatform}) { final types = [ HealthDataType.RESTING_HEART_RATE, @@ -58,7 +83,8 @@ List healthDeleteTypes({required bool isApplePlatform}) { HealthDataType.ACTIVE_ENERGY_BURNED, HealthDataType.BASAL_ENERGY_BURNED, HealthDataType.STEPS, - ..._sleepHealthTypes, + for (final t in _sleepHealthTypes) + if (t != _foreignSleepEnvelope(isApplePlatform)) t, HealthDataType.WORKOUT, ]; return isApplePlatform @@ -72,6 +98,24 @@ List healthDeleteTypes({required bool isApplePlatform}) { .toList(); } +/// The span a day's SLEEP-type delete has to cover. +/// +/// The calendar day is not it. Stage samples are written at TRUE epoch, so a +/// night that began at 23:10 sits in the PREVIOUS day — deleting only +/// `[dayStart, dayEnd)` leaves that half behind and every re-export appends +/// another copy of it. Widen to the union of the day and the night; with no +/// night to write, the day window is already right. +({DateTime start, DateTime end}) sleepCleanupWindow({ + required DateTime dayStart, + required DateTime dayEnd, + HealthSleepSession? night, +}) => ( + start: (night != null && night.start.isBefore(dayStart)) + ? night.start + : dayStart, + end: (night != null && night.end.isAfter(dayEnd)) ? night.end : dayEnd, +); + bool shouldAttemptHealthExport({ required int attempts, required int maxAttempts, @@ -168,6 +212,38 @@ class HealthExporter { : _androidHeartRate = androidHeartRate ?? MethodChannelHealthConnectHeartRateWriter(); + /// The process-wide exporter. `AppState` holds this one, and so does every + /// seam that lands a session without a widget tree to read AppState from — + /// the coach's `add_completed_workout` tool has only a [LocalRepository]. + /// Lazily built, so importing this file starts no platform channels. + static final HealthExporter shared = HealthExporter(); + + /// [exportWorkout] for a caller that holds the ID it just wrote rather than + /// the row: `logManualWorkout` returns `workout_id`, not the session. This + /// is the seam issue #130 is actually about — a workout logged from the + /// coach (or any non-UI path) otherwise reaches the health store only if a + /// full-day export happens to run afterwards, which needs a `day_result` + /// row AND a derive pass, so a hand-logged session can sit unexported for + /// hours. + /// + /// GATED ON [kHealthSyncPref], because unlike `AppState.stopWorkout` these + /// callers have no `healthSyncEnabled` to check first — and writing to the + /// platform store with the switch off is exactly the thing the switch is + /// for. Best-effort: never throws, false when nothing was written. + static Future exportWorkoutId(String? id) async { + if (id == null || id.isEmpty) return false; + try { + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(kHealthSyncPref) != true) return false; + final row = await LocalDb.session(id); + if (row == null) return false; + return await shared.exportWorkout(row); + } catch (e) { + debugPrint('[health] exportWorkoutId $id: $e'); + return false; + } + } + /// True on iOS/macOS (Apple Health); false on Android (Health Connect). static bool get isApple => Platform.isIOS || Platform.isMacOS; @@ -202,7 +278,10 @@ class HealthExporter { HealthDataType.SLEEP_REM, HealthDataType.SLEEP_LIGHT, HealthDataType.SLEEP_AWAKE, - HealthDataType.SLEEP_SESSION, + // The envelope, in whichever form the platform actually has. Asking + // for the other one sends a type name that store has never heard of + // (SLEEP_SESSION is Health-Connect-only, SLEEP_IN_BED HealthKit-only). + isApple ? HealthDataType.SLEEP_IN_BED : HealthDataType.SLEEP_SESSION, HealthDataType.WORKOUT, ]; @@ -679,14 +758,30 @@ class HealthExporter { // Outside the success accounting on purpose — see the method doc. await _purgeLegacyStepsIfNeeded(date, dayStart, dayEnd); + // The night this day owns, normalized ONCE: stages clipped to the sleep + // window, sorted, de-overlapped. Shared by the delete window below and the + // Apple write further down so both cover exactly the same span. Android + // gets this from its native writer instead (see [_androidSleep] above). + final night = isApple ? normalizeHealthSleepSession(b) : null; + + // Sleep deletes are night-scoped, everything else stays day-scoped — + // `HealthConnectSleepWriter.sleepCleanupRange` already does the equivalent + // on Android. + final sleepWindow = sleepCleanupWindow( + dayStart: dayStart, + dayEnd: dayEnd, + night: night, + ); + // Idempotency: remove OUR previously-written samples for this day (HealthKit / // Health Connect only let an app delete its own data), then re-write fresh. for (final t in _rewriteTypes) { + final isSleep = _sleepHealthTypes.contains(t); try { final deleted = await _health.delete( type: t, - startTime: dayStart, - endTime: dayEnd, + startTime: isSleep ? sleepWindow.start : dayStart, + endTime: isSleep ? sleepWindow.end : dayEnd, ); if (!deleted) { debugPrint('[health] delete ${t.name} returned false'); @@ -898,21 +993,42 @@ class HealthExporter { // health 11.1.1 generic SLEEP_* writer instead creates one parent record // per call, fragmenting a night. Android therefore uses our typed native // replace API; Apple Health keeps its existing per-stage samples. - if (isApple) { - final segs = (_sub(b, 'series')?['hypnogram'] as List?) ?? const []; - for (final s in segs) { - if (s is! Map) continue; - final st = (s['start'] as num?)?.toInt(); - final en = (s['end'] as num?)?.toInt(); - final stage = healthSleepStageOf(s['stage']?.toString()); - if (st == null || en == null || en <= st || stage == null) continue; - final type = _sleepType(stage); + if (isApple && night != null) { + // THE ENVELOPE FIRST. Bare stage bars with nothing enclosing them is why + // readers (Bevel and friends) reconstruct a night as a short sleep plus a + // scatter of naps — HealthKit has no session record, so the wrapper is an + // `inBed` sleepAnalysis sample spanning the night. + // + // The span is the DETECTED sleep window, which is the same wall-clock + // number the app already reports as in-bed time (`in_bed_sec` is + // offset - onset). Nothing is invented: no window, no envelope, and a + // bundle without one writes no stages either — which is also why an + // unstaged night (an import, a night staging refused) contributes no + // fragments here. + try { + final wrote = await _health.writeHealthData( + value: 0, + type: HealthDataType.SLEEP_IN_BED, + startTime: night.start, + endTime: night.end, + ); + if (!wrote) success = false; + } catch (e) { + debugPrint('[health] write sleep envelope: $e'); + success = false; + } + // Stages come from the SAME normalization Android uses, so they are + // clipped to the sleep window instead of spilling past either end of it + // — which is what let a pre-midnight segment survive the day-scoped + // delete and pile up a fresh copy on every retry. + for (final seg in night.stages) { + final type = _sleepType(seg.stage); try { final wrote = await _health.writeHealthData( value: 0, type: type, - startTime: DateTime.fromMillisecondsSinceEpoch(st * 1000), - endTime: DateTime.fromMillisecondsSinceEpoch(en * 1000), + startTime: seg.start, + endTime: seg.end, ); if (!wrote) success = false; } catch (e) { diff --git a/lib/health/health_rhr_seed.dart b/lib/health/health_rhr_seed.dart index 93d58690..f0a265ad 100644 --- a/lib/health/health_rhr_seed.dart +++ b/lib/health/health_rhr_seed.dart @@ -272,7 +272,10 @@ class RhrSeedImporter { static Future compareAgainstBand() async { final seed = await storedSeedBaseline(); if (seed == null) return null; - final rows = await LocalDb.metricSeries('rhr'); + // THE BAND'S OWN nightly values — an imported day is another vendor's + // resting HR, and comparing a phone seed against that answers a different + // question than the one this gate asks (see LocalDb.importedDates). + final rows = await LocalDb.metricSeries('rhr', measuredOnly: true); final band = [ for (final r in rows) (r['value'] as num?)?.toDouble(), ]; diff --git a/lib/import/import_container.dart b/lib/import/import_container.dart index 3746f6c0..32773067 100644 --- a/lib/import/import_container.dart +++ b/lib/import/import_container.dart @@ -111,6 +111,106 @@ Future sniffFile(String path) async { } } +/// The header row every NOOP raw-sensor CSV starts with. Same signature the +/// reader itself matches on (noop_import.dart), so the router and the parser +/// cannot disagree about what a NOOP CSV is. +const String kNoopCsvHeader = 'unix_s,'; + +/// How much of a text file the router reads to find its first record. +const int _headBytes = 4096; + +/// True when the first RECORD of [text] is one the NOOP reader accepts. +/// +/// The router used to ask whether byte zero began the header. The reader does +/// not: it skips blank and `#` lines first, and it falls back to the +/// documented positional layout when a file carries no header at all. So a +/// NOOP export with a preamble, or a legacy headerless one, was handed to the +/// vendor importer and refused with a confident wrong message — the same class +/// of misroute (#160, #199) this function exists to end. +/// +/// [truncated] says the buffer stopped mid-file, in which case the trailing +/// fragment is not a whole line and is not judged. +bool noopCsvFirstRecordMatches(String text, {bool truncated = false}) { + final lines = text.split('\n'); + if (truncated && !text.endsWith('\n')) lines.removeLast(); + for (var line in lines) { + line = line.trimRight(); // a CRLF export's \r + if (line.isEmpty || line.startsWith('#')) continue; + if (line.startsWith(kNoopCsvHeader)) return true; + // Headerless, i.e. [NoopImporter._defaultCols]: unix seconds in column 0 + // and the full documented column count behind it. Deliberately structural + // — a vendor CSV's first field is a formatted date, never an epoch. + final f = line.split(','); + final ts = f.isEmpty ? null : int.tryParse(f.first.trim()); + return f.length >= 15 && ts != null && ts > 1000000000 && ts < 4100000000; + } + return false; +} + +/// True when [path] is a NOOP export — judged by CONTENT, not by name. +/// +/// The onboarding router used to switch on the extension: `.noopbak`/`.zip` +/// meant NOOP, anything else meant the vendor importer. Both halves were wrong +/// in opposite directions (#160, #199). NOOP's Android "raw sensor CSV" export +/// is a plain `.csv`, so it went to the vendor importer and the user was told +/// to re-download it with WHOOP set to English. A WHOOP "My Data" export is a +/// ZIP of CSVs — the shape WHOOP actually hands you — so it went to the NOOP +/// importer and was refused for holding too many files. Two confident, wrong +/// messages for two correct files. +/// +/// The signatures: a raw-sensor CSV's FIRST RECORD is [kNoopCsvHeader] or the +/// documented positional layout ([noopCsvFirstRecordMatches]); a `.noopbak` +/// (or a backup someone unpacked by hand) is a SQLite database; a WHOOP export +/// is an archive of several named CSVs and matches neither. +Future isNoopExport(String path) async { + final List head; + final raf = await File(path).open(); + try { + // Enough to reach the first RECORD, not just the first byte — see + // [noopCsvFirstRecordMatches]. The container sniff still only reads the + // magic at the front. + head = await raf.read(_headBytes); + } finally { + await raf.close(); + } + switch (sniffImportContainer(head.take(64).toList())) { + case ImportContainer.text: + return noopCsvFirstRecordMatches(String.fromCharCodes(head), + truncated: head.length == _headBytes); + case ImportContainer.sqlite: + return true; + case ImportContainer.zip: + return _zipHoldsNoopExport(path); + default: + // gzip, UTF-16, binary: not something the NOOP path claims. Whatever + // picks them up owns the message. + return false; + } +} + +Future _zipHoldsNoopExport(String path) async { + final input = InputFileStream(path); + try { + final files = + ZipDecoder().decodeStream(input).files.where((f) => f.isFile); + // A `.noopbak` is a ZIP around NOOP's SQLite database. + if (files.any((f) => _isDbMember(f.name))) return true; + // ponytail: member COUNT, not member content. A ZIP member is deflated and + // this package can only inflate it whole, so reading one header line off a + // hundreds-of-megabyte raw export would materialise the entire thing just + // to classify it. A WHOOP export always ships several named CSVs; the only + // NOOP CSV-in-a-ZIP is one a user zipped by hand. If a single-file vendor + // export ever turns up, this needs a bounded member read instead. + return files.where((f) => _isCsvMember(f.name)).length == 1; + } catch (_) { + // Unreadable as an archive. Not a NOOP export as far as routing goes; the + // importer that takes it produces the message. + return false; + } finally { + await input.close(); + } +} + /// True for a ZIP member we can actually parse as an export. bool _isCsvMember(String name) { final base = p.basename(name).toLowerCase(); diff --git a/lib/import/noop_import.dart b/lib/import/noop_import.dart index 6cfe992f..7aa1d54c 100644 --- a/lib/import/noop_import.dart +++ b/lib/import/noop_import.dart @@ -182,7 +182,7 @@ class NoopImporter { await for (final line in lines) { if (line.isEmpty || line.startsWith('#')) continue; firstLine ??= line; - if (line.startsWith('unix_s,')) { + if (line.startsWith(kNoopCsvHeader)) { sawHeader = true; // Header → (re)build the name→index map and skip. final h = line.split(','); diff --git a/lib/notify/notification_center.dart b/lib/notify/notification_center.dart index 7b34e9b3..59b1bd32 100644 --- a/lib/notify/notification_center.dart +++ b/lib/notify/notification_center.dart @@ -11,7 +11,7 @@ // // Whether an emitted event fires an OS notification is decided by // NotificationPrefs: -// • it must be one of the three sanctioned NotifClasses (see classOf), AND +// • it must be one of the four sanctioned NotifClasses (see classOf), AND // • its category must be enabled, AND // • either we're outside quiet hours, or the event is critical and the user // allowed critical-overrides-quiet. @@ -31,6 +31,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../ai/ai_prefs.dart'; import '../ai/reminder_plan.dart'; import '../data/day_label.dart'; +import '../data/journal_fields.dart'; +import '../data/med_store.dart'; import 'fired_keys.dart'; import 'notification_event.dart'; import 'notification_prefs.dart'; @@ -196,27 +198,155 @@ class NotificationCenter { /// /// [bedtimeMinOfDay] is no longer read: it timed the wind-down nudge. Kept so /// the existing caller compiles unchanged; drop both together. + /// [checkInDoneToday] — whether the day's self-report is already written + /// (see [checkInDone]). The prompt is not armed for a day that is already + /// answered, which is the whole reason the caller reads it. NULL means the + /// caller could not tell, and the check-in is then left exactly as it is. + /// + /// [medDefs] / [medDosesToday] come straight from `MedDb` and are only read + /// when `prefs.medsEnabled` is on. NULL means the caller did not read the + /// schedule at all — the notifications screen re-asserting after an + /// unrelated toggle, or a read that threw — and the armed doses are then + /// left exactly as they are. An EMPTY list is an answer: there are no + /// medications, and the old slots go. They stay parameters rather than a query + /// in here for the same reason [weeklyFinding] does: this method is the + /// policy, and a policy that opens the database cannot be tested without + /// one. Future scheduleStandingReminders( NotificationPrefs prefs, { double? bedtimeMinOfDay, String? weeklyFinding, + bool? checkInDoneToday, + List? medDefs, + Map>> medDosesToday = const {}, }) async { final svc = NotificationService.instance; await svc.cancel(NotificationService.idWindDown); await svc.cancel(NotificationService.idWeeklyRecap); - await svc.cancel(NotificationService.idStillness); + // The check-in and the medication band follow the same rule as the + // movement nudge below: cancel what the user just switched off, and + // otherwise only what THIS call can put back. + // + // `checkInDoneToday` null means the caller does not know whether today is + // already written — the notifications screen re-asserting after an + // unrelated toggle. Cancelling then would drop tonight's prompt, and + // re-arming would risk asking for a day already answered, so neither + // happens and the next foreground pass (which does know) decides. + if (!prefs.checkInEnabled || checkInDoneToday != null) { + await svc.cancel(NotificationService.idCheckIn); + } + // The medication band is cancelled when the switch is OFF — that is where + // a reminder the user just turned off actually goes away — or when we were + // handed the schedule and can therefore re-arm from it a few lines down. + // + // NOT unconditionally. This method also runs from the notifications screen + // after any unrelated toggle, with no schedule passed, and an unconditional + // cancel there would bin every armed dose for a user who came in to change + // their quiet hours. Cancel only what this call can put back. + // + // NULL, NOT EMPTY, is what "no schedule passed" means — and that is the + // whole distinction. "There are no medications" and "the medication table + // could not be read" are different facts with opposite correct answers: + // an EMPTY schedule must cancel, or a user who deleted their last + // medication keeps getting reminded to take it, forever, because nothing + // else ever cancels these. An UNAVAILABLE one must preserve, because + // re-arming is impossible and cancelling would silently disarm doses that + // are still real. Collapsing both into `const []` chose preserve for both, + // so the deleted-medication case never got its cancel. + if (!prefs.medsEnabled || medDefs != null) { + for (var i = 0; i < NotificationService.maxMedSlots; i++) { + await svc.cancel(NotificationService.idMedsBase + i); + } + } + // idStillness is NOT a standing schedule and must not be cancelled with + // them. It is a one-shot armed by live movement + // (`AppState._rescheduleStillnessNudge`), nothing in this method re-arms + // it, and this method runs on EVERY foreground resume — so the fix for + // issue #123 was cancelling itself: open the app and the nudge was binned. + // The re-arm needs a connected band streaming foreground IMU AND is + // throttled to once per ten minutes, so it is not a gap that closes on its + // own; with the band off the wrist it never closes at all. + // + // The one cancel that IS correct here is the user's own switch: this is + // where a movement nudge that was just turned off actually goes away. + if (!prefs.movementEnabled) { + await svc.cancel(NotificationService.idStillness); + } for (var i = 0; i < NotificationService.maxWaterSlots; i++) { await svc.cancel(NotificationService.idWaterBase + i); } final water = waterSlotMinutes(prefs); final wantWeekly = prefs.remindersEnabled && weeklyFinding != null; - if (water.isEmpty && !wantWeekly) return; + final now = DateTime.now(); + final checkIn = checkInDoneToday == null + ? null + : checkInSlot(prefs, bedtimeMinOfDay, + doneToday: checkInDoneToday, nowMin: now.hour * 60 + now.minute); + final meds = + medPromptSlots(prefs, medDefs ?? const [], medDosesToday, now: now); + if (water.isEmpty && !wantWeekly && checkIn == null && meds.isEmpty) return; // Re-resolve the zone first: this runs on every foreground resume, and the // instants below are wall-clock. A phone that flew somewhere would otherwise // keep arming Sunday 18:00 in the zone the app first launched in. await svc.ensureTimezone(); await _armWaterSlots(svc, water); if (wantWeekly) await _armWeeklyLookback(svc, weeklyFinding); + if (checkIn != null) await _armCheckIn(svc, checkIn); + await _armMedSlots(svc, meds); + } + + /// One notification per dose still due — never one per day, never a summary. + /// + /// ONE-SHOT per slot, at the minute the user entered. A daily repeat cannot + /// know whether today's dose was already taken, and a reminder for a pill + /// already swallowed is exactly the notification people turn everything off + /// over. The cost of the one-shot is that cover only reaches as far as + /// [medPromptSlots]' horizon from the last foreground pass; the reminder + /// re-arms on every resume, which for anyone who opens the app daily is + /// always ahead of the doses. + /// + /// Quiet hours are deliberately NOT applied: this is the user's own entered + /// time, the same reasoning that exempts the alarm. Someone who takes a pill + /// at 23:00 typed 23:00. + Future _armMedSlots(NotificationService svc, List slots) async { + for (var i = 0; i < slots.length; i++) { + final s = slots[i]; + final at = medSlotInstant(s); + if (at == null) continue; + await svc.scheduleOnce( + id: NotificationService.idMedsBase + i, + category: NotifCategory.reminders, + // NO MEDICATION NAME, deliberately. This lands on a lock screen, in + // front of whoever is in the room, and "which drug" is the most + // sensitive fact in the app. The checklist behind the tap says which — + // one unlock away, which is where that belongs. It is also why the + // body is not a dose or a count. + title: 'Medication', + // Not an adherence score, not a streak, and nothing about a dose that + // was missed: this is the reminder, not the report. + body: 'A dose is due.', + at: at, + route: kRouteMeds, + ); + } + } + + /// The daily check-in, as a ONE-SHOT at the next [minuteOfDay]. + /// + /// One-shot for the same reason the meds slots are: whether the day is + /// already written changes daily, and a repeat would go on asking after the + /// journal was filled in. Re-armed on every foreground pass, and the caller + /// suppresses it outright once the day has any rating in it. + Future _armCheckIn(NotificationService svc, int minuteOfDay) async { + await svc.scheduleOnce( + id: NotificationService.idCheckIn, + category: NotifCategory.reminders, + title: 'How was today?', + // No guilt, no count, no reference to a day that was missed. + body: 'Mood, energy, stress — a minute of it.', + at: svc.nextDailyInstant(minuteOfDay ~/ 60, minuteOfDay % 60), + route: kRouteJournalCompose, + ); } /// One daily-repeating notification per hydration slot. @@ -312,6 +442,139 @@ class NotificationCenter { return slots; } + // ── the daily check-in ────────────────────────────────────────────────── + // + // ONE prompt for the whole self-report, not one per field. Mood, energy, + // stress, soreness and sleep quality are all written on the same screen, so + // five prompts would be five interruptions for one minute of typing. + + /// Fixed fallback time when nothing has learned a bedtime yet: 20:30. Late + /// enough that the day is over, early enough to be well clear of the default + /// quiet window. + static const int checkInFallbackMin = 20 * 60 + 30; + + /// How long before the recommended bedtime the check-in lands. + static const int checkInBeforeBedMin = 60; + + /// Never before this — a "how was today?" at teatime is asking about a day + /// that has not happened. + static const int checkInEarliestMin = 17 * 60; + + /// Whether the day's self-report is already written, from + /// `journal_metric` for that day. + /// + /// RATINGS only. Water and caffeine are logged as they happen and say + /// nothing about whether the day has been reflected on; mood, energy, stress, + /// soreness and sleep quality are the answer the prompt is asking for. A + /// single one of them is enough — the screen is one screen, and someone who + /// filled in mood and stopped has been asked. + static bool checkInDone(Map todayMetrics) { + for (final f in kJournalFields) { + if (f.isRating && todayMetrics.containsKey(f.key)) return true; + } + return false; + } + + /// The check-in's wall-clock minute, or null when it must not be armed. + /// + /// TIMED OFF THE PERSON where the data supports it: an hour before the + /// bedtime the Sleep Coach learned from their own nights, so a late + /// chronotype is not asked about their day at what is, for them, mid-evening. + /// [bedtimeMinOfDay] null (no recommendation yet) falls back to a fixed + /// [checkInFallbackMin], stated rather than pretended. + /// + /// The window is then bounded on both sides. Quiet hours do not gate an OS + /// schedule — the OS fires it with no Dart running — so the ceiling is + /// applied HERE instead: half an hour before the quiet window opens, and + /// never after it. A 01:00 bedtime must not produce a midnight prompt. + static int? checkInMinute(NotificationPrefs prefs, double? bedtimeMinOfDay) { + if (!prefs.checkInEnabled) return null; + var t = bedtimeMinOfDay == null + ? checkInFallbackMin + : bedtimeMinOfDay.round() - checkInBeforeBedMin; + if (prefs.quietEnabled && prefs.quietStartMin > prefs.quietEndMin) { + final cap = prefs.quietStartMin - 30; + if (t > cap) t = cap; + } + if (t < checkInEarliestMin) t = checkInEarliestMin; + // A degenerate quiet window (one that swallows the whole evening) leaves + // nowhere honest to put this. Nothing is armed rather than something at + // a time the user has already said not to interrupt. + if (prefs.inQuietHours(t) || t >= 24 * 60) return null; + return t; + } + + /// [checkInMinute], with the "already answered" rule applied. + /// + /// Suppressed only when the slot would land TODAY and today is already + /// written. A day that is done at 21:00 still arms tomorrow's — the prompt + /// is re-armed on every foreground pass, but a user who does not open the + /// app tomorrow would otherwise never be asked again. + static int? checkInSlot( + NotificationPrefs prefs, + double? bedtimeMinOfDay, { + required bool doneToday, + required int nowMin, + }) { + final t = checkInMinute(prefs, bedtimeMinOfDay); + if (t == null) return null; + if (doneToday && t > nowMin) return null; // would land today, already asked + return t; + } + + // ── medication ────────────────────────────────────────────────────────── + + /// How far ahead doses are armed. Three days rather than one because these + /// are one-shots: nothing re-arms them while the app is closed, and a + /// weekend without opening the app should not silently drop a prescription. + /// Not more, because a slot armed days out cannot know it was taken early. + static const int medHorizonDays = 3; + + /// The doses to arm: every slot still UPCOMING across [medHorizonDays], + /// soonest first, capped at [NotificationService.maxMedSlots]. + /// + /// `DoseState.upcoming` is the whole rule-4 answer and it is already + /// computed by [slotsForDay]: a dose marked taken, a dose deliberately + /// skipped, and a slot that has already passed are all something other than + /// upcoming, and none of them is armed. [dosesToday] only covers today + /// because that is the only day a dose can already have been recorded for. + static List medPromptSlots( + NotificationPrefs prefs, + List defs, + Map>> dosesToday, { + DateTime? now, + }) { + if (!prefs.medsEnabled || defs.isEmpty) return const []; + final at = now ?? DateTime.now(); + final out = []; + for (var d = 0; d < medHorizonDays; d++) { + final day = dayLabelOf(DateTime(at.year, at.month, at.day + d)); + for (final s in slotsForDay(defs, day, d == 0 ? dosesToday : const {}, + now: at)) { + if (s.state != DoseState.upcoming) continue; + // Two pills at 08:00 are ONE interruption. The list is in time order, + // so an instant equal to the last kept one is the same moment — and + // the notification names nothing anyway, so a second copy of it would + // carry no extra information and burn an id from the band. + if (out.isNotEmpty && + out.last.date == s.date && + out.last.slotMin == s.slotMin) { + continue; + } + out.add(s); + if (out.length >= NotificationService.maxMedSlots) return out; + } + } + return out; + } + + /// The absolute instant [s] is due, or null when its day cannot be resolved. + static DateTime? medSlotInstant(MedSlot s) { + final start = localDayStartSec(s.date); + if (start == null) return null; + return DateTime.fromMillisecondsSinceEpoch((start + s.slotMin * 60) * 1000); + } + /// Re-assert the three AI slots (morning briefing, nightly sweep, pre-sleep /// journal prompt). /// diff --git a/lib/notify/notification_event.dart b/lib/notify/notification_event.dart index 95783873..b2e9e3ff 100644 --- a/lib/notify/notification_event.dart +++ b/lib/notify/notification_event.dart @@ -12,11 +12,13 @@ // quiet-hours decision: `critical` can break through; everything else respects // the user's quiet window. +import 'tap_router.dart'; + enum NotifCategory { health, recovery, reminders, device } enum NotifPriority { critical, normal, low } -/// The three — and only three — things this app may EMIT into the notification +/// The four — and only four — things this app may EMIT into the notification /// shade. Everything else is an in-app card. /// /// This governs the present path only. A standing schedule (the weekly @@ -41,10 +43,21 @@ enum NotifClass { /// The weekly lookback, and ONLY when the week actually contained something. lookback, + + /// Something happened that only the user can confirm, and the app cannot + /// record it for them: an auto-detected workout. Not a nudge — a nudge asks + /// you to go and do something, this reports a thing that already happened and + /// asks whether to keep it. One per detected bout, never a reminder series. + /// + /// It gates exactly like [exception] today (category switch + quiet hours), + /// which is deliberate rather than redundant: this enum is the ledger of what + /// may interrupt, and filing "did you work out?" under `exception` would make + /// the one honest list in the notification system lie. + prompt, } /// Which class [e] belongs to, or null for anything that is not one of the -/// three — which [NotificationPrefs.shouldFireOs] then drops. +/// four — which [NotificationPrefs.shouldFireOs] then drops. /// /// Classified from the category, because that is already the axis the emit /// sites express: health/device signals are exceptions; a `reminders` event at @@ -53,11 +66,27 @@ enum NotifClass { /// both nudges, and its genuine findings (low readiness, a shifted resting-HR /// trend) are folded into the day's health exception at the point they are /// computed rather than fired one at a time. +/// +/// The ONE exception to classifying on the category alone is the detected +/// workout, which is keyed on its route. It is a reminders-channel prompt at +/// normal priority, and that pair has to keep meaning "no" for everything else +/// — it is the pair every one of the nineteen deleted nudges would arrive on. +/// So the route names the single event allowed to claim it, rather than the +/// gate opening for a whole category. `shouldFireOs` already reads the route +/// for the same reason (the auto-detect off switch). NotifClass? classOf(NotificationEvent e) => switch (e.category) { NotifCategory.health || NotifCategory.device => NotifClass.exception, NotifCategory.reminders when e.priority == NotifPriority.critical => NotifClass.alarm, + // Priority as well as route. The doc above says "reminders at NORMAL + // priority", and without the second half a low-priority event carrying + // this route walks through the OS gate on the strength of its route + // alone — which is the whole thing this case was narrowed to prevent. + NotifCategory.reminders + when e.priority == NotifPriority.normal && + routePath(e.route ?? '') == kRouteWorkoutSuggestion => + NotifClass.prompt, NotifCategory.reminders || NotifCategory.recovery => null, }; diff --git a/lib/notify/notification_prefs.dart b/lib/notify/notification_prefs.dart index c74367e4..877486a7 100644 --- a/lib/notify/notification_prefs.dart +++ b/lib/notify/notification_prefs.dart @@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'notification_event.dart'; +import 'tap_router.dart'; class NotificationPrefs { /// The day's aggregated health exception (illness, unusual physiology, @@ -50,6 +51,48 @@ class NotificationPrefs { static const int waterIntervalMinAllowed = 30; static const int waterIntervalMaxAllowed = 360; + /// Whether the auto-detected-workout surfaces are on: the "did you work out?" + /// notification and the review cards the detector feeds. Asked for twice + /// (issues #102, #149) and never built — the detector has never had an off + /// switch of any kind. + /// + /// WHAT IT DOES NOT DO: stop the detection itself. The bouts are computed + /// inside the day derivation and written to `workout_suggestions` there; this + /// switch silences every surface that shows them, which is the part the user + /// experiences. The rows stay, unread, and turning it back on shows them + /// again rather than losing a week of them. + final bool autoDetectEnabled; + + /// The "time to move" nudge: a one-shot OS notification two hours after the + /// last movement the band's live IMU saw, re-armed on every movement so it + /// only ever fires on a genuinely uninterrupted still stretch. + /// + /// Opt-in, off by default, and it is what earns the nudge its place on + /// [NotificationService.schedulableIds] — the rule that list enforces is that + /// a scheduled slot must be one the user asked for by name. Without a switch + /// it was refused, which is why it has never fired for anyone (issue #123). + final bool movementEnabled; + + /// The medication reminder: one notification per scheduled dose the user + /// entered themselves, and ONLY for a dose that is still upcoming — a slot + /// already marked taken or deliberately skipped is not armed at all. + /// + /// This is the one prompt in the app whose time is not a guess: it is the + /// schedule in `med_def.schedule_json`, which the user typed. Opt-in and off + /// by default like every other outbound path, because someone who wants a + /// water reminder has not thereby asked to be told about their pills. + final bool medsEnabled; + + /// The daily check-in: one prompt, once, to write the day's self-report + /// (mood, energy, stress, soreness, sleep quality — the whole journal, not + /// one field at a time). + /// + /// Suppressed for the day the moment any rating is written, so it can never + /// ask for something already answered. It is NOT armed for a day that was + /// missed — there is no catching up on a self-report, and a prompt that + /// fires because yesterday is blank is a streak wearing a different hat. + final bool checkInEnabled; + const NotificationPrefs({ this.healthEnabled = true, this.recoveryEnabled = true, @@ -61,6 +104,10 @@ class NotificationPrefs { this.criticalOverridesQuiet = true, this.waterEnabled = false, this.waterIntervalMin = 120, // every 2 hours + this.autoDetectEnabled = true, + this.movementEnabled = false, + this.medsEnabled = false, + this.checkInEnabled = false, }); static const _kHealth = 'notif_health'; @@ -73,6 +120,10 @@ class NotificationPrefs { static const _kCriticalOverride = 'notif_critical_override'; static const _kWater = 'notif_water'; static const _kWaterInterval = 'notif_water_interval'; + static const _kAutoDetect = 'notif_auto_detect'; + static const _kMovement = 'notif_movement'; + static const _kMeds = 'notif_meds'; + static const _kCheckIn = 'notif_checkin'; static Future load() async { final p = await SharedPreferences.getInstance(); @@ -87,6 +138,10 @@ class NotificationPrefs { criticalOverridesQuiet: p.getBool(_kCriticalOverride) ?? true, waterEnabled: p.getBool(_kWater) ?? false, waterIntervalMin: p.getInt(_kWaterInterval) ?? 120, + autoDetectEnabled: p.getBool(_kAutoDetect) ?? true, + movementEnabled: p.getBool(_kMovement) ?? false, + medsEnabled: p.getBool(_kMeds) ?? false, + checkInEnabled: p.getBool(_kCheckIn) ?? false, ); } @@ -102,6 +157,10 @@ class NotificationPrefs { await p.setBool(_kCriticalOverride, criticalOverridesQuiet); await p.setBool(_kWater, waterEnabled); await p.setInt(_kWaterInterval, waterIntervalMin); + await p.setBool(_kAutoDetect, autoDetectEnabled); + await p.setBool(_kMovement, movementEnabled); + await p.setBool(_kMeds, medsEnabled); + await p.setBool(_kCheckIn, checkInEnabled); } NotificationPrefs copyWith({ @@ -115,6 +174,10 @@ class NotificationPrefs { bool? criticalOverridesQuiet, bool? waterEnabled, int? waterIntervalMin, + bool? autoDetectEnabled, + bool? movementEnabled, + bool? medsEnabled, + bool? checkInEnabled, }) => NotificationPrefs( healthEnabled: healthEnabled ?? this.healthEnabled, @@ -128,6 +191,10 @@ class NotificationPrefs { criticalOverridesQuiet ?? this.criticalOverridesQuiet, waterEnabled: waterEnabled ?? this.waterEnabled, waterIntervalMin: waterIntervalMin ?? this.waterIntervalMin, + autoDetectEnabled: autoDetectEnabled ?? this.autoDetectEnabled, + movementEnabled: movementEnabled ?? this.movementEnabled, + medsEnabled: medsEnabled ?? this.medsEnabled, + checkInEnabled: checkInEnabled ?? this.checkInEnabled, ); bool categoryEnabled(NotifCategory c) => switch (c) { @@ -155,6 +222,16 @@ class NotificationPrefs { /// a check at each of the emit sites, which is how twenty-two kinds accreted /// in the first place. bool shouldFireOs(NotifEvent event, int minuteOfDay) { + // The auto-detect off switch, applied before anything else: it is the one + // gate the user set for THIS notification, and route is what identifies it + // (the category it is emitted on is shared with everything else on the + // recovery channel). + // (On the PATH: the payload carries the bout as `?id=…`, and an equality + // check against the bare route would miss every real one.) + if (!autoDetectEnabled && + routePath(event.route ?? '') == kRouteWorkoutSuggestion) { + return false; + } final klass = classOf(event); if (klass == null) return false; // not one of the three — never fires // The alarm is the one thing quiet hours must not silence: the user armed diff --git a/lib/notify/notification_relay.dart b/lib/notify/notification_relay.dart index 5de52fea..0a908092 100644 --- a/lib/notify/notification_relay.dart +++ b/lib/notify/notification_relay.dart @@ -10,6 +10,7 @@ import 'dart:async'; import 'dart:io' show Platform; +import 'dart:typed_data'; import 'package:flutter/services.dart' show MethodChannel; import 'package:flutter/widgets.dart'; @@ -36,6 +37,12 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver { static const _kEnabled = 'notif_relay_enabled'; static const _kPackages = 'notif_relay_packages'; + static const _kSeen = 'notif_relay_seen'; + + /// How many apps the "seen" list remembers. A phone posts from a long tail + /// of packages over a week; past this the list stops being a list you can + /// read. + static const int maxSeen = 60; /// Only Android can observe other apps' notifications. Everything below is a /// no-op when this is false, and the UI hides the feature entirely. @@ -47,6 +54,24 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver { bool _granted = false; bool get permissionGranted => _granted; + /// Packages that have actually posted a notification while the listener was + /// running, most recent first. This is what the picker offers. + /// + /// The alternative — enumerating installed apps — needs QUERY_ALL_PACKAGES, + /// which was deliberately removed from the manifest with `tools:node=remove` + /// as the most policy-expensive permission there is. It is also the worse + /// list: two hundred packages to scroll, against the dozen that actually + /// interrupt you. + final List _seen = []; + + /// Per-package icon, straight off the notification the OS handed us. RAM + /// only, deliberately: the packages persist, the bitmaps do not, and a + /// freshly-launched app simply shows names until each one posts again. + final Map _icons = {}; + + List get seenPackages => List.unmodifiable(_seen); + Uint8List? iconFor(String pkg) => _icons[pkg]; + final Set _packages = {}; Set get packages => _packages; bool isAppEnabled(String pkg) => _packages.contains(pkg); @@ -73,6 +98,15 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver { _packages ..clear() ..addAll(prefs.getStringList(_kPackages) ?? const []); + _seen + ..clear() + ..addAll(prefs.getStringList(_kSeen) ?? const []); + // An app already on the allow-list belongs in the picker whether or not it + // has posted since launch — otherwise turning the feature on and reopening + // the screen shows an empty list with your choices invisibly still active. + for (final p in _packages) { + if (!_seen.contains(p)) _seen.add(p); + } WidgetsBinding.instance.addObserver(this); await refreshPermission(); _resync(); @@ -189,12 +223,42 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver { } catch (_) {/* handler absent on this plugin build — ignore */} } + /// Remember that [pkg] notifies, so the picker has something to offer. + /// + /// Persisted only when the package is NEW: the in-memory order changes on + /// every ping and a SharedPreferences write per notification would be a + /// disk write per notification. + void _noteSeen(String pkg, Uint8List? icon) { + if (icon != null && icon.isNotEmpty) _icons[pkg] = icon; + final known = _seen.remove(pkg); + _seen.insert(0, pkg); + if (_seen.length > maxSeen) { + _seen.removeRange(maxSeen, _seen.length); + // The icons go with them. `_seen` is bounded, `_icons` was not — an + // evicted package left its bitmap resident for the life of the process, + // and on a phone with a lot of chatty apps that is the picker's whole + // icon set held for a list it is no longer on. + // ponytail: O(n) scan over 60 entries, only on eviction. + _icons.removeWhere((k, _) => !_seen.contains(k)); + } + if (!known) { + SharedPreferences.getInstance() + .then((p) => p.setStringList(_kSeen, _seen)) + .catchError((_) => false); + } + notifyListeners(); + } + void _onNotification(ServiceNotificationEvent e) { // Only fresh, user-facing posts: skip removals and persistent/ongoing ones // (media players, foreground-service notifications) — those aren't "a ping". if (e.hasRemoved || e.onGoing) return; final pkg = e.packageName; - if (pkg.isEmpty || !_packages.contains(pkg)) return; + if (pkg.isEmpty) return; + // BEFORE the allow-list check: an app you have not chosen yet is exactly + // the one the picker needs to be able to offer you. + _noteSeen(pkg, e.appIcon); + if (!_packages.contains(pkg)) return; if (!isConnected()) return; final now = DateTime.now().millisecondsSinceEpoch; @@ -215,3 +279,29 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver { super.dispose(); } } + +/// A readable name for [pkg], from the package name alone. +/// +/// An app's own label lives behind `getApplicationLabel`, which needs the +/// package-visibility permission this feature deliberately does not have — so +/// the ICON beside it (taken off the notification itself) is the identifier a +/// human actually reads, and this is the caption under it. +/// +/// The last meaningful segment, capitalised: `com.whatsapp` → "Whatsapp", +/// `org.telegram.messenger` → "Messenger", `com.foo.android` → "Foo". Segments +/// that name a platform or a build rather than a product are stepped over, +/// because "Android" under every second icon is not a name. +String appLabel(String pkg) { + const generic = { + 'android', 'app', 'apps', 'client', 'mobile', 'main', 'ui', + 'free', 'pro', 'lite', 'beta', 'release', + }; + final parts = [for (final p in pkg.split('.')) if (p.isNotEmpty) p]; + if (parts.isEmpty) return pkg; + var i = parts.length - 1; + while (i > 0 && generic.contains(parts[i].toLowerCase())) { + i--; + } + final w = parts[i]; + return w[0].toUpperCase() + w.substring(1); +} diff --git a/lib/notify/notification_service.dart b/lib/notify/notification_service.dart index 97a77add..e9e47cc5 100644 --- a/lib/notify/notification_service.dart +++ b/lib/notify/notification_service.dart @@ -126,6 +126,17 @@ class NotificationService { static const int idMorningBrief = 2005; // scheduled daily (AI morning briefing) static const int idEveningBrief = 2006; // scheduled daily (AI evening recap) static const int idStillness = 2200; // provisional one-shot ("time to move", issue #123) + static const int idCheckIn = 2201; // daily ("how was today?" → the journal) + + /// Slot band [idMedsBase .. idMedsBase + maxMedSlots) — one ONE-SHOT per + /// scheduled dose that is still upcoming, armed by + /// [NotificationCenter.scheduleStandingReminders] from the user's own + /// `med_def` schedule. One-shot rather than a daily repeat because whether a + /// dose is still due changes every day and a repeat cannot know: it would go + /// on asking for a dose already taken, which is the fastest way to get every + /// notification in the app turned off. Re-armed on each foreground pass. + static const int idMedsBase = 2300; + static const int maxMedSlots = 12; /// Slot band [idWaterBase .. idWaterBase + maxWaterSlots) — one daily-repeating /// OS notification per hydration slot, armed by @@ -155,22 +166,45 @@ class NotificationService { /// reminder is switched on, at the interval the user picked. /// • [idEveningBrief] — armed only when the nightly sweep found something /// unusual for this user, and its body IS the finding. - /// Wind-down, the morning briefing, the journal prompt and the "time to move" - /// one-shot are none of those, and are still refused. Their callers keep - /// CANCELLING, which is how an upgrade cleans out whatever an older build - /// left standing. - static const Set schedulableIds = {idWeeklyRecap, idEveningBrief}; + /// • [idStillness] — armed only while `NotificationPrefs.movementEnabled` + /// is on (opt-in, off by default), and only by two hours of no movement + /// in the band's own live IMU. Its body IS that measurement. It was + /// refused here for as long as it had no switch, which is the real reason + /// issue #123 never fired: the cancel on every foreground resume was the + /// visible half, but `scheduleOnce` had been dropping it at this gate + /// before the cancel ever mattered. + /// • [idCheckIn] — armed only while `NotificationPrefs.checkInEnabled` is + /// on (opt-in, off by default), at a time derived from the user's own + /// bedtime, and NOT armed for a day whose self-report is already + /// written. + /// • the medication band ([isMedSlot]) — armed only while + /// `NotificationPrefs.medsEnabled` is on, at the times in the user's own + /// `med_def` schedule, and only for a dose still upcoming. + /// Wind-down, the morning briefing and the AI journal prompt are none of + /// those, and are still refused. Their callers keep CANCELLING, which is how + /// an upgrade cleans out whatever an older build left standing. + static const Set schedulableIds = { + idWeeklyRecap, + idEveningBrief, + idStillness, + idCheckIn, + }; /// Whether [id] is one of the hydration slots. A band rather than a set /// member, which is the only reason [maySchedule] exists as a function. static bool isWaterSlot(int id) => id >= idWaterBase && id < idWaterBase + maxWaterSlots; + /// Whether [id] is one of the medication slots — same band reasoning as + /// [isWaterSlot]. + static bool isMedSlot(int id) => + id >= idMedsBase && id < idMedsBase + maxMedSlots; + /// The gate itself — see [schedulableIds]. Public because /// [NotificationCenter.scheduleAiReminders] filters its plan through it /// rather than arming a slot and having it refused one line later. static bool maySchedule(int id) => - schedulableIds.contains(id) || isWaterSlot(id); + schedulableIds.contains(id) || isWaterSlot(id) || isMedSlot(id); AndroidNotificationChannel _channelFor(NotifCategory c) => switch (c) { NotifCategory.health => _healthChannel, diff --git a/lib/notify/tap_router.dart b/lib/notify/tap_router.dart index 55ff8b99..2930eaa2 100644 --- a/lib/notify/tap_router.dart +++ b/lib/notify/tap_router.dart @@ -20,8 +20,39 @@ const String kRouteWater = '/water'; /// pushes a focused review of the detected activity (log or adjust) — the plain /// `/workouts` route only selected the tab, leaving the suggestion buried in the /// history list (issue #113). +/// +/// Carries the bout it is about as `?id=` — see +/// [workoutSuggestionRoute]. The bare path still resolves (older payloads, and +/// anything that just wants the review screen). const String kRouteWorkoutSuggestion = '/workouts/suggestion'; +/// The deep link for ONE detected bout. The id is the `workout_suggestions` +/// row's, so the screen can open on that bout rather than a list the user has +/// to find it in. +String workoutSuggestionRoute(String id) => + Uri(path: kRouteWorkoutSuggestion, queryParameters: {'id': id}).toString(); + +/// A deep link's path, without the `?id=` a route may carry. +/// +/// EVERY route comparison goes through this. The tables below, `classOf`, +/// `shouldFireOs`'s auto-detect switch and app.dart's two switches all match on +/// route EQUALITY, so an id-carrying payload silently misses all of them — +/// which for the gate means the off switch stops working. +String routePath(String route) => Uri.tryParse(route)?.path ?? route; + +/// The bout/record id a deep link carries, or null when it carries none. +String? routeId(String route) => Uri.tryParse(route)?.queryParameters['id']; + +/// The medication reminder. Lands on Wellness, where the Medication tab's +/// checklist is the thing that records the dose. +/// +/// CEILING, and it is a real one: `WellnessScreen` holds its sub-tab in +/// private state with no constructor argument, so this lands on Wellness with +/// Medication one tap away in the sub-tab row rather than on the checklist +/// itself. Adding `initialTab` to that screen is the whole fix — see the note +/// on `screenForRoute` in app.dart. +const String kRouteMeds = '/meds'; + /// Emitted by the battery forecast and the device alerts. Profile is reached /// from the Home avatar rather than a tab of its own, so the base is Home and /// `screenForRoute` pushes the profile on top of it. @@ -71,15 +102,23 @@ const Map _screenRoutes = { kRouteJournalCompose: 0, kRouteBreathing: 0, kRouteWater: 0, + // Wellness has no index in the old five-tab vocabulary, so the base is Today + // and `domainForRoute` is what actually decides where it lands. The entry + // still has to exist: a route absent from this table produces no screen + // request at all, and the shell then falls back to the tab index. + kRouteMeds: 0, kRouteWorkoutSuggestion: 4, kRouteProfile: 0, kRouteRecap: 1, // 1|2|3 all fold into Health — see domainForTab }; TapTarget resolveTapRoute(String route) { - final tab = _tabRoutes[route]; + // Match on the PATH; hand the full route (id and all) back as the screen + // request, so whatever the shell pushes still knows which bout it is about. + final path = routePath(route); + final tab = _tabRoutes[path]; if (tab != null) return TapTarget(tab); - final base = _screenRoutes[route]; + final base = _screenRoutes[path]; if (base != null) return TapTarget(base, route); return const TapTarget(0); // unknown payload from an older build → Today } diff --git a/lib/platform/device_actions.dart b/lib/platform/device_actions.dart index 5ffa5789..dc938a67 100644 --- a/lib/platform/device_actions.dart +++ b/lib/platform/device_actions.dart @@ -2,9 +2,16 @@ // channel. Mirrors the edge_tracking / live_activity bridges: a thin wrapper that // asks native what it can do (capabilities) and tells it to do one thing (perform). // -// Native handlers: android/.../ActionHandler.kt (via MainActivity), ios ActionBridge. +// Native handlers, both registered at engine attach: +// Android — NativeChannels.kt (`DEVICE_ACTIONS_CHANNEL` + its `perform`). +// iOS — the `ActionBridge` enum in ios/Runner/AppDelegate.swift. +// There is no ActionHandler.kt and no ActionBridge.swift; this comment used to name +// both, which is two files' worth of grep that finds nothing. +// // All actions use no-risk OS APIs (media-key dispatch, system volume, a ringtone + -// vibrate) — no special runtime permissions beyond VIBRATE (a normal permission). +// vibrate, torch) — no special runtime permissions beyond VIBRATE (a normal +// permission). In-app actions never reach this channel at all; the dispatcher +// handles them in Dart. import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 64246c8f..06f3ba24 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -45,6 +45,9 @@ import '../compute/manual_session.dart' show strainFromPerMinuteHr; import '../compute/hr_max.dart'; import '../compute/profile.dart'; import '../data/day_label.dart'; +import '../data/journal_fields.dart' + show JournalMetricValue, kJournalFieldsByKey; +import '../data/med_store.dart' show MedDb, MedDef; import '../data/auto_backup.dart' show BackupCadence, BackupOutcome, runBackup; import '../stress/breath_phases.dart'; @@ -368,6 +371,11 @@ class AppState extends ChangeNotifier { onProgress: onProgress, ); lastNoopImport = res; + // The rows are durable — tell the screens that read them. Without this an + // import landed days, sessions and journal rows into a database every live + // tab had already finished reading, and the only way to see them was to + // relaunch the app. + bumpInsights(); notifyListeners(); return res.days; } @@ -396,6 +404,7 @@ class AppState extends ChangeNotifier { onProgress: onProgress, ); lastWhoopImport = res; + bumpInsights(); // see importNoopCsv — imported rows have to reach the tabs notifyListeners(); return res.days; } @@ -447,6 +456,7 @@ class AppState extends ChangeNotifier { } catch (e) { importRollupError = '$e'; } + bumpInsights(); // see importNoopCsv — imported rows have to reach the tabs notifyListeners(); // DAYS, not rows. `_days` is a distinct day_id count taken from the source // file; the caller reports "N days imported" and a row total is not that. @@ -454,12 +464,18 @@ class AppState extends ChangeNotifier { } // ── platform health export (Apple Health / Health Connect) ────────────────── - final HealthExporter _healthExport = HealthExporter(); + // The shared instance, not a private one: the coach and the log-workout + // sheet reach the exporter through `HealthExporter.exportWorkoutId` with no + // AppState in hand, and two exporters would mean two `Health()` handles and + // two Health-Connect availability probes doing the same work. + final HealthExporter _healthExport = HealthExporter.shared; final HealthExportSingleFlight _healthExportSingleFlight = HealthExportSingleFlight(); HealthLinkState healthState = HealthLinkState.unknown; bool healthSyncEnabled = false; - static const String _kHealthSync = 'health_sync'; + // Shared with `HealthExporter.exportWorkoutId`, which has to honour this + // switch from callers that never see this class. + static const String _kHealthSync = kHealthSyncPref; /// "Apple Health" (iOS) or "Health Connect" (Android). String get healthStoreName => HealthExporter.storeName; @@ -692,13 +708,19 @@ class AppState extends ChangeNotifier { } /// Session-triggered Health export for one just-finished workout (issue - /// #130) — used by callers outside this class (e.g. confirming an - /// auto-detected workout in workouts_screen.dart) that write a `sessions` - /// row directly rather than going through [stopWorkout]. See + /// #130) — for callers outside this class that write a `sessions` row + /// directly rather than going through [stopWorkout]. See /// [HealthExporter.exportWorkout] for why this can't just wait for the next /// day export. Best-effort, never throws. - Future exportWorkoutToHealth(Map session) => - _healthExport.exportWorkout(session); + /// + /// This used to take the row, and its only two call sites went out with the + /// old `lib/ui/workouts` — leaving it callerless while `logManualWorkout` + /// paths (the coach, the log-workout sheet) exported nothing at all. Those + /// callers hold the `workout_id` the repo hands back, not the row, and most + /// of them have no AppState to reach for either, so the seam that matters is + /// [HealthExporter.exportWorkoutId] and this just forwards to it. + Future exportWorkoutToHealth(String? sessionId) => + HealthExporter.exportWorkoutId(sessionId); // ── companion: anonymous telemetry + health-data contribution ──────────────── // All anchored to a stable anonymous install id (no account). Two SEPARATE @@ -1138,6 +1160,7 @@ class AppState extends ChangeNotifier { log: _log, onMarkMoment: _markMomentFromGesture, onWorkoutToggle: _toggleWorkoutFromGesture, + onLogWater: _logWaterFromGesture, ); engine = BleEngine( onRecord: _onRecord, @@ -1225,6 +1248,7 @@ class AppState extends ChangeNotifier { log: _log, onMarkMoment: _markMomentFromGesture, onWorkoutToggle: _toggleWorkoutFromGesture, + onLogWater: _logWaterFromGesture, ); this.engine = engine ?? BleEngine( @@ -1772,6 +1796,13 @@ class AppState extends ChangeNotifier { if (nowMs - _lastStillnessScheduleMs < 10 * 60 * 1000) return; _lastStillnessScheduleMs = nowMs; try { + // Opt-in, off by default. Read here rather than cached because this runs + // at most once every ten minutes and SharedPreferences is already in + // memory — and because the switch has to bite on the next movement, not + // at the next launch. It is also what makes the slot allow-listed at all + // (NotificationService.schedulableIds): a nudge with no off switch was + // refused there, and had never once fired. + if (!(await NotificationPrefs.load()).movementEnabled) return; await NotificationService.instance.cancel(NotificationService.idStillness); final at = DateTime.fromMillisecondsSinceEpoch(nowMs).add(const Duration(hours: 2)); @@ -2197,9 +2228,13 @@ class AppState extends ChangeNotifier { try { final prefs = await NotificationPrefs.load(); final bedtimeMin = await _recommendedBedtimeMin(); + final meds = await _medScheduleToday(prefs); await NotificationCenter.instance.scheduleStandingReminders( prefs, bedtimeMinOfDay: bedtimeMin, + checkInDoneToday: await _checkInDoneToday(), + medDefs: meds.defs, + medDosesToday: meds.doses, ); // AI slots. The nightly sweep is armed only when today actually produced // a finding — see [_sweepHeadlineNow], which is also where the body of @@ -2237,6 +2272,49 @@ class AppState extends ChangeNotifier { } } + /// Whether today's self-report is already written — the check-in prompt's + /// "do not ask for something already logged" gate. + /// + /// NOT `BriefingStore.journalDoneToday()`, which reads a flag that + /// `markJournalDone` would set and nothing anywhere calls: it is false for + /// every user on every day. The journal rows are the truth. + /// NULL, NOT FALSE, when the journal could not be read. The scheduler reads + /// `false` as "today is known to be unanswered" and arms the prompt on it — + /// so a transient read failure asked a user who had already written their + /// rating how their day was. Null is the answer it already has a branch for: + /// leave the check-in exactly as it is and let the next pass decide. + Future _checkInDoneToday() async { + try { + return NotificationCenter.checkInDone( + await LocalDb.journalMetricsForDay(todayLabel())); + } catch (_) { + return null; + } + } + + /// The medication schedule + today's recorded doses. Two indexed reads, only + /// on the path that will use them. + /// + /// NULL `defs` means UNREAD — the switch is off, or the read threw — and is + /// not the same answer as an empty list, which means "this user has no + /// medications". The scheduler cancels the armed doses on the second and + /// preserves them on the first; returning `[]` for a failed read handed it + /// the wrong one of those. + Future<({List? defs, Map>> doses})> + _medScheduleToday(NotificationPrefs prefs) async { + const empty = >>{}; + if (!prefs.medsEnabled) return (defs: null, doses: empty); + try { + final db = await LocalDb.instance; + return ( + defs: await MedDb.defs(db), + doses: await MedDb.dosesForDay(db, todayLabel()), + ); + } catch (_) { + return (defs: null, doses: empty); + } + } + String? _sweepHeadline; String _sweepDay = ''; int _lastSweepScanMs = 0; @@ -2310,7 +2388,16 @@ class AppState extends ChangeNotifier { } } - void _bumpInsightsRevision() { + void _bumpInsightsRevision() => bumpInsights(); + + /// Say that the DURABLE data changed, so every screen reading it re-reads. + /// + /// Public because the writers are not all in here: the log-workout sheet + /// writes a session, and an import writes days, sessions and journal rows. + /// `notifyListeners` is NOT that signal — it also ticks at ~1 Hz with live + /// HR, so screens listen to this instead and re-read only when something + /// actually landed. + void bumpInsights() { insightsRevision.value = insightsRevision.value + 1; } @@ -3496,6 +3583,12 @@ class AppState extends ChangeNotifier { await engine.disconnect(); _releaseForegroundLease(); await PairedDevice.clear(); + // Everything the old band told us about itself. The engine's DeviceState + // lives as long as the process and the persisted strap name outlives even + // that, so without both of these a re-pair — with a DIFFERENT band — + // inherits the forgotten one's name, serial, generation and bond verdicts. + device.reset(); + Prefs.setString(_kStrapName, ''); paired = null; notifyListeners(); } @@ -5199,6 +5292,39 @@ class AppState extends ChangeNotifier { } } + /// One water write at a time. `_logWaterFromGesture` reads the day, awaits, then + /// writes the whole map back, and `postJournalMetrics` REPLACES the day — so two + /// taps overlapping that await both read the same total and the second write eats + /// the first glass. Same guard the nutrition screen's `+` already uses. This is not + /// a second debounce (the dispatcher owns that); it is the read-modify-write lock. + bool _writingWaterFromGesture = false; + + /// Double-tap → add one glass to today's water. Step and ceiling come from the + /// journal field spec, so a wrist tap and the on-screen `+` always agree. + Future _logWaterFromGesture() async { + final r = repo; + if (r == null || _writingWaterFromGesture) return; + _writingWaterFromGesture = true; + try { + final spec = kJournalFieldsByKey['water_ml']!; + final date = todayLabel(); + // Inside the try: the READ can throw too, and a guard set before it would + // stay set forever. Spread into a fresh map — postJournalMetrics rewrites + // the whole day from what it is handed. + final fields = {...await r.getJournalMetrics(date)}; + final now = fields['water_ml']?.value ?? 0; + fields['water_ml'] = + JournalMetricValue((now + spec.step).clamp(0, spec.max).toDouble()); + await r.postJournalMetrics(date, fields); + _log('[gesture] water logged (+${spec.step.round()} ${spec.unit})'); + await HapticFeedback.mediumImpact(); + } catch (e) { + _log('[gesture] log water failed: $e'); + } finally { + _writingWaterFromGesture = false; + } + } + /// Double-tap → stamp a timestamped tag onto today's journal (read-modify-write so /// existing tags/note survive). "Remember this" for a spike, a set, a feeling. Future _markMomentFromGesture() async { @@ -5365,6 +5491,20 @@ class LiveWorkoutState { calories = 0.0; return; } + // THE gate, from the one place that defines it. This used to be the + // arithmetic inlined below, which is the third copy of it — and + // `Calories`' own docstring says a second copy is how the day and the bout + // came to disagree in the first place. It also got none of the anchor + // validation: a non-finite resting HR makes the gate NaN, every + // `bpm < gate` is then false, and EVERY sample bills at the active rate. + // Null means the anchors cannot define a gate, and the live gauge abstains + // exactly as the re-score does. + final gate = ana.Calories.activeGateHr(maxHr, rhr); + if (gate == null) { + _caloriesScored = false; + calories = 0.0; + return; + } if (_secondsByBpm.isEmpty && _lastSampleHr == null) { _caloriesScored = false; calories = 0.0; @@ -5378,7 +5518,6 @@ class LiveWorkoutState { // floor. Defaulted to match `computeManualSessionStats`, so the two paths // cannot disagree for a profile that carries no height. final heightCm = profile.heightCm ?? 170.0; - final gate = rhr + ana.Calories.activeHRRFraction * (maxHr - rhr); final restingRate = ana.Calories.restingKcalPerS(coeffs, weightKg, heightCm, age); diff --git a/lib/state/prefs.dart b/lib/state/prefs.dart index 465db398..287b89a9 100644 --- a/lib/state/prefs.dart +++ b/lib/state/prefs.dart @@ -24,6 +24,15 @@ class Prefs { } catch (_) {/* reads fall back to defaults */} } + /// Whether storage is actually available, i.e. whether a `getX` default is + /// "the key is unset" or "we cannot see what you chose". + /// + /// For a tab index those are the same answer. For a CONSENT they are not: + /// an on-by-default switch read through unavailable storage would send on + /// behalf of somebody who turned it off. Anything gating an outbound call + /// checks this first — see `offLookupAllowed`. + static bool get loaded => _sp != null; + // ── synchronous read (fall back to default until loaded) ──────────────────── static int getInt(String key, int fallback) => _sp?.getInt(key) ?? fallback; static String getString(String key, String fallback) => diff --git a/lib/ui2/README.md b/lib/ui2/README.md index e6de6fe2..a3aed425 100644 --- a/lib/ui2/README.md +++ b/lib/ui2/README.md @@ -191,8 +191,23 @@ profile with an age costs more trust than no button at all. ```dart MetricRow(IconData icon, Color color, String name, String value, - {String sub = '', String unit = '', List spark = const [], + {String sub = '', String unit = '', List series = const [], + Rising rising = Rising.neither, String? status, VoidCallback? onTap}) +``` + +The trailing slot is a DIRECTION ARROW, not a sparkline: a 52 pt line chart +showed a shape nobody could read a number off. `series` is read by `trendOf`, +which only calls a direction if the newest three values clear half a standard +deviation of up to fourteen before them — four is enough, so seven recorded +values already produce a direction — inside that it is steady, and with +fewer than seven recorded days it is nothing at all (an empty slot, with the +reason in the semantics, because a flat arrow would claim a measured "no +change"). `rising` says which way is good news for THIS metric and is the only +thing the hue carries; the glyph carries the direction on its own, for the +readers who cannot see the hue. + +```dart InlineMetrics(List<(String label, String value, Color color)> items) ``` diff --git a/lib/ui2/grammar.dart b/lib/ui2/grammar.dart index b73c0e2b..231f9a6f 100644 --- a/lib/ui2/grammar.dart +++ b/lib/ui2/grammar.dart @@ -487,7 +487,7 @@ class TrendCard extends StatelessWidget { /// [delta] as the caller wrote it ("no baseline") and stops there. final bool? good; - /// DENSE — see [MetricRow.spark]. + /// DENSE — see [MetricRow.series]. final List series; final Color color; final VoidCallback? onTap; @@ -1081,6 +1081,75 @@ class DeepDiveCard extends StatelessWidget { } // ══════════════════ ROWS — for lists, not cards ══════════════════ + +/// Which way is good news for THIS metric. +/// +/// Resting heart rate falling is good, HRV rising is good, and skin +/// temperature moving is neither — it is a deviation signal, and calling a +/// rise "worse" would be a claim this project does not make. Metrics with no +/// settled direction get [neither] and an arrow with no hue: the direction is +/// still stated, the judgement is not invented. +enum Rising { good, bad, neither } + +/// Which way a series is going, or null when there is no basis for saying. +enum Trend { rising, falling, steady } + +/// The direction of the newest few days against the ones before them. +/// +/// THE RULE, so it is one rule and not a feeling: the mean of the newest 3 +/// recorded values against the mean of up to 14 before them, and the move +/// only counts as a direction if it clears HALF A STANDARD DEVIATION of that +/// baseline (Cohen's small effect, 1988). Inside that, a day-to-day wobble +/// and a trend look identical, and an arrow would be pointing at a coin flip +/// — so it reads [Trend.steady]. +/// +/// Null is a different answer from steady: fewer than 3 + 4 recorded values +/// is not a weak comparison, it is no comparison, and the row draws nothing +/// rather than a flat arrow that would read as a measured "no change". +/// +/// A baseline with zero spread (a quantized series that really did sit still) +/// does NOT abstain — any move off it is a real move. Abstaining on a zero +/// spread is the readiness bug this codebase has already paid for once. +Trend? trendOf(List series) { + final v = [ + for (final x in series) + if (x != null && x.isFinite) x, + ]; + const recentN = 3, baseMax = 14, baseMin = 4; + if (v.length < recentN + baseMin) return null; + double mean(Iterable l) => + l.fold(0, (a, b) => a + b) / l.length; + final recent = v.sublist(v.length - recentN); + final base = v.sublist( + math.max(0, v.length - recentN - baseMax), v.length - recentN); + final mb = mean(base); + final delta = mean(recent) - mb; + final sd = math.sqrt( + base.map((x) => (x - mb) * (x - mb)).fold(0, (a, b) => a + b) / + (base.length - 1)); + if (delta.abs() <= 0.5 * sd) return Trend.steady; + return delta > 0 ? Trend.rising : Trend.falling; +} + +/// Whether this move is good news — hue only, never direction. +/// +/// Steady is not good or bad news, and a metric with no settled direction has +/// no news at all: both draw in ink. +Color _trendHue(P p, Trend trend, Rising rising) { + if (trend == Trend.steady || rising == Rising.neither) return p.ink3; + final good = (trend == Trend.rising) == (rising == Rising.good); + return good ? p.on(C.green) : p.on(C.orange); +} + +/// What the arrow says, in words, for the screen reader — including the case +/// where there is no arrow, so an empty slot is not a silent hole. +String _trendWord(Trend? t) => switch (t) { + Trend.rising => 'trending up', + Trend.falling => 'trending down', + Trend.steady => 'steady', + null => 'no trend yet, not enough days recorded', + }; + /// A metric in a list: name → value → trend. class MetricRow extends StatelessWidget { final IconData icon; @@ -1089,7 +1158,15 @@ class MetricRow extends StatelessWidget { /// DENSE — one slot per calendar day, `null` for a day with no record. A /// compacted list draws a gap as continuity. - final List spark; + /// + /// Read for a DIRECTION, not drawn: the trailing slot used to hold a 52 pt + /// sparkline, which at that size showed a shape nobody could read a number + /// off. See [trendOf] for what counts as a direction. + final List series; + + /// Which way is good news here. Defaults to [Rising.neither] — a caller that + /// has not said gets a direction and no judgement, never a guess. + final Rising rising; final String? status; final VoidCallback? onTap; @@ -1102,7 +1179,8 @@ class MetricRow extends StatelessWidget { super.key, this.sub = '', this.unit = '', - this.spark = const [], + this.series = const [], + this.rising = Rising.neither, this.status, this.onTap, }); @@ -1143,28 +1221,45 @@ class MetricRow extends StatelessWidget { ], ], ); - // The trailing slot is fixed only for the spark, which genuinely has a - // fixed size. `status` is a word — 'ON TRACK' needs 92 pt at 1.0× and was - // being silently clipped inside a 52 pt box before any scaling at all — so - // it gets measured space instead. + // `status` is a word — 'ON TRACK' needs 92 pt at 1.0× and was being + // silently clipped inside a 52 pt box before any scaling at all — so it + // gets measured space rather than the arrow's fixed slot. + final trend = trendOf(series); + // NO ARROW AND NO EXPLANATION IN THE ROW: a metric with too little history + // has nothing to say here, and a horizontal arrow would say "no change", + // which is a measurement it has not made. The reason goes to the screen + // reader and the row stays quiet — see [_trendWord]. final trailing = status != null ? Text( status!, style: F.over.copyWith(color: p.on(C.green)), textAlign: TextAlign.end, ) - : spark.isEmpty + : trend == null ? const SizedBox.shrink() - : SizedBox( - width: 52, - height: 22, - child: CustomPaint( - painter: LineChart(spark, p.on(color), fill: false), - ), + : Icon( + switch (trend) { + Trend.rising => LucideIcons.arrowUpRight, + Trend.falling => LucideIcons.arrowDownRight, + Trend.steady => LucideIcons.arrowRight, + }, + size: 18, + // THE GLYPH CARRIES THE DIRECTION and the hue only carries the + // judgement, because roughly one man in twelve cannot read the + // hue at all. Green/orange is the pair TrendCard already spends on + // this judgement — red in this system is the heart's category + // colour, not a verdict. + color: _trendHue(p, trend, rising), ); return Pressable( onTap: onTap, - semanticLabel: '$name, $value $unit'.trim(), + // WHAT THE ROW SHOWS IS WHAT IT SAYS. `status` REPLACES the arrow in the + // trailing slot, so announcing the trend under it described a glyph that + // is not on screen — a row reading 'ON TRACK' told a screen reader + // 'trending up'. + semanticLabel: '$name, $value $unit ${status ?? _trendWord(trend)}' + .replaceAll(RegExp(r'\s+'), ' ') + .trim(), child: Padding( padding: const EdgeInsets.symmetric(vertical: S.x2), child: bigText(c) diff --git a/lib/ui2/onboarding/welcome.dart b/lib/ui2/onboarding/welcome.dart index 9d2e6171..920a19d4 100644 --- a/lib/ui2/onboarding/welcome.dart +++ b/lib/ui2/onboarding/welcome.dart @@ -19,6 +19,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import '../../import/backup_crypto.dart'; +import '../../import/import_container.dart'; import '../../import/journal_csv_import.dart'; import '../../state/app_state.dart'; import '../ui2.dart'; @@ -321,9 +322,16 @@ Future runImport( // backup selected alongside a vendor CSV imported the backup and threw the // CSV away without a word. final db = [...plain.where(_isDbBackup), ...decrypted]; - final raw = plain.where(_isRawExport).toList(); - final csv = - plain.where((p) => !_isDbBackup(p) && !_isRawExport(p)).toList(); + // Raw-vs-vendor is decided by what the file HOLDS, not by what it is called. + // See [isNoopExport]: routing on the extension sent NOOP's raw-sensor `.csv` + // to the vendor importer and WHOOP's `.zip` to the NOOP one — both files + // fine, both refused, both with advice for the other file. + final raw = []; + final csv = []; + for (final p in plain) { + if (_isDbBackup(p)) continue; + (await isNoopExport(p) ? raw : csv).add(p); + } if (decrypted.isNotEmpty) sources.add('Encrypted backup'); if (plain.any(_isDbBackup)) sources.add('OpenStrap backup'); @@ -363,13 +371,31 @@ Future runImport( // through to the vendor importer below. final vendor = []; for (final p in csv) { + // Only a TEXT file can be a journal export, and `importJournalCsvFile` + // reads it as a string. Vendor exports arrive here as ZIPs now that routing + // is by content, and reading one as a string is #199 all over again — it + // comes back as `FileSystemException: Failed to decode data using encoding + // 'utf-8'`, which no catch below was going to turn into advice. The vendor + // path unwraps archives (and gzip) properly, so hand them straight over. + if (await sniffFile(p) != ImportContainer.text) { + vendor.add(p); + continue; + } try { final r = await importJournalCsvFile(p); journalRows += r.imported; + // This one writes straight to the journal store rather than through + // AppState, so it has to raise the signal itself — every other importer + // here does it from its AppState method. + if (r.imported > 0) app.bumpInsights(); rejected.addAll(r.rejected.map((x) => x.toString())); if (!sources.contains('Journal CSV')) sources.add('Journal CSV'); } on JournalCsvFormatException { vendor.add(p); + } on FormatException { + // Text, but not UTF-8 — a latin1/cp1252 CSV out of a spreadsheet. The + // sniff above cannot see that, and the vendor importer decodes leniently. + vendor.add(p); } } @@ -460,11 +486,6 @@ bool _isDbBackup(String path) { p.contains('.db.unopenable-'); } -bool _isRawExport(String path) { - final p = path.toLowerCase(); - return p.endsWith('.noopbak') || p.endsWith('.zip'); -} - class WelcomeView extends StatelessWidget { final bool busy; final ImportOutcome? outcome; diff --git a/lib/ui2/profile/band_notifications.dart b/lib/ui2/profile/band_notifications.dart new file mode 100644 index 00000000..6b7832a5 --- /dev/null +++ b/lib/ui2/profile/band_notifications.dart @@ -0,0 +1,277 @@ +// BAND NOTIFICATIONS — buzz the strap when a phone app notifies you. +// ANDROID ONLY, and silently absent everywhere else: iOS has no API to observe +// another app's notifications, so there is no "unavailable on this device" +// copy to write. +// +// WHY THIS FILE HAD TO COME BACK. The relay itself never stopped working: +// `AppState` still bootstraps it, and the manifest still declares +// BIND_NOTIFICATION_LISTENER_SERVICE for it. What the UI rebuild deleted was +// every control — so the app shipped a notification-listener permission with +// no way to reach the feature it exists for. A permission a reviewer can read +// in the manifest and a user cannot find in the app is the problem, more than +// the missing feature is. +// +// WHERE THE APP LIST COMES FROM. Apps that have actually posted a notification +// while the listener was running, not the installed set. Enumerating installed +// packages needs QUERY_ALL_PACKAGES, which the sweep removed from the manifest +// with `tools:node="remove"` and called the most policy-expensive permission +// there is — that decision stands. It also happens to be the better list: the +// dozen apps that interrupt you, rather than two hundred to scroll past. The +// cost is that the list starts empty and fills over the first minutes, which +// the empty state says in as many words rather than looking broken. + +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; + +import '../../notify/notification_relay.dart'; +import '../../state/app_state.dart'; +import '../ui2.dart'; +import 'profile.dart' show SetRow, settingsGroup; + +/// One row's worth of the picker. +class RelayApp { + const RelayApp(this.package, {this.icon, this.on = false}); + final String package; + final Uint8List? icon; + final bool on; +} + +/// The route. Reads the live [NotificationRelay] off [AppState] and hands +/// [BandNotificationsView] plain values — the view is what the tests pump, and +/// it never asks the platform anything. +class BandNotifications extends StatefulWidget { + const BandNotifications({super.key}); + + @override + State createState() => _BandNotificationsState(); +} + +class _BandNotificationsState extends State + with WidgetsBindingObserver { + NotificationRelay get _relay => context.read().notificationRelay; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Back from the system Notification-access page: re-read the real grant + // rather than trusting what the user said they did. + if (state == AppLifecycleState.resumed && mounted) { + _relay.refreshPermission(); + } + } + + @override + Widget build(BuildContext c) { + final relay = _relay; + return AnimatedBuilder( + animation: relay, + builder: (c, _) => BandNotificationsView( + supported: relay.supported, + enabled: relay.enabled, + granted: relay.permissionGranted, + apps: [ + for (final p in relay.seenPackages) + RelayApp(p, icon: relay.iconFor(p), on: relay.isAppEnabled(p)), + ], + onEnabled: relay.setEnabled, + onGrant: relay.requestPermission, + onApp: relay.setAppEnabled, + ), + ); + } +} + +/// The screen, as a pure function of its inputs. +class BandNotificationsView extends StatelessWidget { + const BandNotificationsView({ + super.key, + this.supported = true, + this.enabled = false, + this.granted = false, + this.apps = const [], + this.onEnabled, + this.onGrant, + this.onApp, + }); + + final bool supported, enabled, granted; + final List apps; + final ValueChanged? onEnabled; + final VoidCallback? onGrant; + final void Function(String pkg, bool on)? onApp; + + /// How many apps are actually armed — the one number that says whether the + /// feature will do anything at all. + int get _armed => apps.where((a) => a.on).length; + + @override + Widget build(BuildContext c) { + final p = P.of(c); + return Scaffold( + backgroundColor: p.bg, + body: SafeArea( + child: Column(children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: S.x4), + child: NavBar('Band notifications', sub: 'WHAT MAKES THE STRAP BUZZ'), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x10), + children: [ + if (!supported) + const StatusCard( + 'This phone cannot do it', + 'Reading which app posted a notification is an Android ' + 'capability. iOS gives no app that access, including ' + 'this one.', + icon: LucideIcons.smartphone, + ) + else ...[ + settingsGroup(c, 'Relay', [ + SetRow(LucideIcons.bellRing, C.purple, 'Buzz on app notifications', + // What is actually true, and no more. The relay reads + // no content and sends nothing anywhere — but it DOES + // keep the package names on this phone, because that + // list is the only way the picker below can offer you + // an app without asking for the permission that + // enumerates every app you have installed. "Nothing is + // stored" was the wrong claim to make about it. + sub: 'The strap buzzes when one of the apps below ' + 'notifies you. What a notification says is never ' + 'read or sent — only which app posted, kept on ' + 'this phone to build the list', + value: enabled ? 'On' : 'Off', + chevron: false, + onTap: () => onEnabled?.call(!enabled)), + if (enabled && granted) + SetRow(LucideIcons.listChecks, C.teal, 'Apps armed', + value: '$_armed', chevron: false), + ]), + if (enabled && !granted) ...[ + const SizedBox(height: S.x4), + StatusCard( + 'Android needs to let us see notifications', + 'The permission says which app posted, and that is all ' + 'this uses it for. The names stay on this phone and ' + 'nothing leaves it.', + fix: 'Grant notification access', + icon: LucideIcons.shieldCheck, + onFix: onGrant, + ), + ], + if (enabled && granted) ...[ + if (apps.isEmpty) + Padding( + padding: const EdgeInsets.only(top: S.x4), + child: StatusCard( + 'No app has notified you yet', + // Absence with its reason, not an empty list: this + // is the cost of not asking for the permission that + // enumerates every installed app, and it resolves + // itself within minutes of ordinary use. + 'Apps appear here the first time each one notifies ' + 'you while the relay is on. Nothing is missed in ' + 'the meantime — the first ping is what puts an ' + 'app on this list, and the second can buzz.', + icon: LucideIcons.hourglass, + ), + ) + else + settingsGroup(c, 'Apps that notify you', [ + for (final a in apps) + _AppRow(a, onChanged: onApp), + ]), + ], + const SizedBox(height: S.x4), + const StatusCard( + 'One buzz, not a stream', + 'Repeat posts from the same app are ignored for four ' + 'seconds, ongoing notifications (media players, ' + 'downloads) never buzz, and nothing buzzes at all ' + 'while the band is disconnected.', + icon: LucideIcons.waves, + ), + ], + ], + ), + ), + ]), + ), + ); + } +} + +/// One app. The icon is the identifier a human reads — [appLabel] is only the +/// caption under it, derived from the package name because the app's real +/// label is behind a permission this feature does not ask for. +class _AppRow extends StatelessWidget { + const _AppRow(this.app, {this.onChanged}); + final RelayApp app; + final void Function(String pkg, bool on)? onChanged; + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final icon = app.icon; + return Pressable( + onTap: () => onChanged?.call(app.package, !app.on), + semanticLabel: + '${appLabel(app.package)}, ${app.on ? 'buzzes' : 'does not buzz'}', + child: Padding( + padding: const EdgeInsets.symmetric(vertical: S.x3), + child: Row(children: [ + ClipRRect( + borderRadius: R.rSm, + child: icon != null && icon.isNotEmpty + ? Image.memory(icon, + width: 32, height: 32, gaplessPlayback: true) + : Container( + width: 32, + height: 32, + alignment: Alignment.center, + decoration: BoxDecoration( + color: p.wash(C.purple), borderRadius: R.rSm), + child: Icon(LucideIcons.appWindow, + size: 16, color: p.on(C.purple)), + ), + ), + const SizedBox(width: S.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(appLabel(app.package), + style: F.body.copyWith(color: p.ink), + maxLines: 1, + overflow: TextOverflow.ellipsis), + Text(app.package, + style: F.over.copyWith(color: p.ink3), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ]), + ), + const SizedBox(width: S.x2), + Text(app.on ? 'Buzzes' : 'Off', + style: F.cap.copyWith( + color: app.on ? p.on(C.green) : p.ink3, + fontWeight: FontWeight.w600)), + ]), + ), + ); + } +} diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index c9cab86c..1daa90f0 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -253,6 +253,18 @@ class MyDevicesView extends StatelessWidget { Widget build(BuildContext c) { final p = P.of(c); final fault = status?.isFault == true ? status : null; + // A BAND, not "a source". The phone is a source and it is not a substitute + // for one: gating this on `sources.isEmpty` meant a phone counting steps + // hid the only route back to pairing, and forgetting a band left the user + // stranded with a steps row and no way to add another. + final hasBand = sources.any((s) => s.isBand); + + // THE PHONE ROW IS LISTED WHENEVER THE TOGGLE IS ON, connected or not — + // `connected` there is "steps have actually been banked", because on iOS + // the toggle sits on while a denied READ permission returns nothing. So + // "a source exists" was the wrong test for this card: it told a user + // whose phone was measuring nothing that "the phone counts steps". + final phoneCounting = sources.any((s) => !s.isBand && s.connected); return Scaffold( backgroundColor: p.bg, body: SafeArea( @@ -265,6 +277,28 @@ class MyDevicesView extends StatelessWidget { child: ListView( padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x10), children: [ + // FIRST, in the tier-2 slot the missing band would occupy — + // not appended under the phone. Someone who has just forgotten + // a band is here to add one, and the row they can see is not + // the one they came for. + if (!hasBand) ...[ + StatusCard( + phoneCounting + ? 'No band is paired' + : 'Nothing is measuring yet', + phoneCounting + ? 'The phone counts steps and nothing else. Heart ' + 'rate, sleep, recovery and temperature all abstain ' + 'until a band is paired.' + : 'No band is paired and no phone steps are arriving, ' + 'so every metric in the app will abstain rather ' + 'than estimate.', + fix: 'Pair a band', + icon: LucideIcons.watch, + onFix: onPair, + ), + if (sources.isNotEmpty) const SizedBox(height: S.x3), + ], for (final s in sources) ...[ SourceRow(s, onTap: () => goto(c, DeviceDetail(s))), if (s.isBand && fault != null) ...[ @@ -275,15 +309,6 @@ class MyDevicesView extends StatelessWidget { ], const SizedBox(height: S.x3), ], - if (sources.isEmpty) - StatusCard( - 'Nothing is measuring yet', - 'No band is paired and phone steps are off, so every ' - 'metric in the app will abstain rather than estimate.', - fix: 'Pair a band', - icon: LucideIcons.watch, - onFix: onPair, - ), const SizedBox(height: S.x5), Text('THE QUALITY LADDER', style: F.over.copyWith(color: p.ink3)), diff --git a/lib/ui2/profile/gallery.dart b/lib/ui2/profile/gallery.dart index 13ade8cc..0ae5cae3 100644 --- a/lib/ui2/profile/gallery.dart +++ b/lib/ui2/profile/gallery.dart @@ -61,6 +61,16 @@ import 'profile.dart'; final _series = List.generate(24, (i) => 52 + (i * 37 % 23) - (i % 5) * 2.0); +/// The four answers [trendOf] can give: a move clear of its own noise in +/// either direction, a move inside it, and a series too short to compare at +/// all. [_falling] is [_rising] backwards — without it no fixture in this +/// gallery ever produced `Trend.falling`, so the down arrow was the one glyph +/// in the trailing slot nobody could look at. +const _rising = [50, 51, 50, 52, 51, 53, 58, 59, 60]; +const _falling = [60, 59, 58, 53, 51, 52, 50, 51, 50]; +const _flat = [50, 51, 50, 51, 50, 51, 50, 51, 50]; +const _tooShort = [50, 51, 50]; + // ── the three rings, in the four states a ring has ── // // Fed as HomeData through the SAME mapping the screen uses, so a state the @@ -196,8 +206,23 @@ Map goldenCases() => { size: Size.infinite, painter: LineChart(_series, C.purple)), )), 'metric_row': const Column(children: [ + // The trailing states, in order: good news up, bad news up, bad news + // DOWN (the arrow that no fixture used to reach), a move inside its + // own noise, and a series with no basis for a direction (which draws + // nothing rather than a flat arrow that would read as a measured "no + // change"). + MetricRow(LucideIcons.activity, C.green, 'HRV', '64', + unit: 'ms', series: _rising, rising: Rising.good), + MetricRow(LucideIcons.heart, C.red, 'Resting heart rate', '58', + unit: 'bpm', series: _rising, rising: Rising.bad), + MetricRow(LucideIcons.moon, C.indigo, 'Sleep efficiency', '84', + unit: '%', series: _falling, rising: Rising.good), MetricRow(LucideIcons.thermometer, C.orange, 'Skin temperature', '+0.3', - sub: 'RELATIVE TO BASELINE', unit: '°'), + sub: 'RELATIVE TO BASELINE', unit: '°', series: _rising), + MetricRow(LucideIcons.brain, C.purple, 'Stress', '31', + unit: '/100', series: _flat, rising: Rising.bad), + MetricRow(LucideIcons.wind, C.teal, 'Respiratory rate', '14.2', + unit: 'br/min', series: _tooShort), // A long name, a thousands-separated value and a word in the trailing // slot — 'ON TRACK' needs 92 pt at 1.0x and was clipped inside a fixed // 52 pt box before any scaling at all. diff --git a/lib/ui2/profile/gestures.dart b/lib/ui2/profile/gestures.dart new file mode 100644 index 00000000..690dc41d --- /dev/null +++ b/lib/ui2/profile/gestures.dart @@ -0,0 +1,167 @@ +// What a double-tap on the band does. +// +// The engine for this shipped a long time ago — the event decode, the recency +// and debounce guards, the persisted mapping, the native channel — and then the +// screen that sets it died with the old `lib/ui` tree. So the mapping sat on its +// `none` default with nothing able to change it: a feature that ran on every +// live event and could never do anything. This is the missing half. +// +// The list is not a fixed menu. It is whatever THIS phone said it can actually +// do — `GestureSettings.supported`, seeded from `DeviceActions.capabilities()`. +// An action drawn here and then silently doing nothing is worse than one that +// was never offered: iOS cannot touch system volume or a third-party player, and +// only Android has the Tasker broadcast, so on an iPhone those are simply not in +// the list. When native answers with nothing at all, the phone actions are +// absent AND SAY SO, rather than leaving a gap to guess at. + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; + +import '../../gestures/device_action.dart'; +import '../../state/app_state.dart'; +import '../ui2.dart'; +import 'profile.dart'; + +class BandGestures extends StatelessWidget { + const BandGestures({super.key}); + + @override + Widget build(BuildContext c) { + // `gestureSettings` is a ChangeNotifier the dispatcher reads live, so the + // screen listens to the same object rather than keeping its own copy — + // picking an action has to move the thing the band is about to consult. + final g = c.read().gestureSettings; + return ListenableBuilder( + listenable: g, + builder: (c, _) => BandGesturesView( + chosen: g.doubleTap, + supported: g.supported, + onPick: g.setDoubleTap, + ), + ); + } +} + +class BandGesturesView extends StatelessWidget { + final DeviceAction chosen; + + /// What this phone can do. Always contains [DeviceAction.none]. + final Set supported; + + final ValueChanged? onPick; + + const BandGesturesView({ + super.key, + required this.chosen, + required this.supported, + this.onPick, + }); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + // Enum order, filtered to this phone: nothing first (it is the default and + // the way back out), then the in-app actions, then whatever the OS offered. + final offered = [ + DeviceAction.none, + ...DeviceAction.values.where((a) => a.isInApp && supported.contains(a)), + ...DeviceAction.values.where((a) => a.isNative && supported.contains(a)), + ]; + final noPhoneActions = !offered.any((a) => a.isNative); + + return Scaffold( + backgroundColor: p.bg, + body: SafeArea( + child: Column(children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: S.x4), + child: NavBar('Double-tap'), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x10), + children: [ + Section( + 'Tap the band twice', + Surface( + child: Text( + 'Only while the app is connected and awake. A tap the ' + 'band stored while your phone was away arrives later with ' + 'an old timestamp, and is ignored rather than fired hours ' + 'after you meant it.', + style: F.body.copyWith(color: p.ink2, height: 1.4), + ), + ), + ), + settingsGroup(c, 'It does', [ + for (final a in offered) + _ActionRow( + action: a, + selected: a == chosen, + onTap: onPick == null ? null : () => onPick!(a), + ), + ]), + if (noPhoneActions) ...[ + const SizedBox(height: S.x5), + Section( + 'Nothing on the phone?', + Surface( + child: Text( + 'Ringing your phone and the flashlight are missing ' + 'because the app could not reach the system to ask what ' + 'this device allows. Reopen the app and come back; the ' + 'in-app actions above work either way.', + style: F.body.copyWith(color: p.ink2, height: 1.4), + ), + ), + ), + ], + ], + ), + ), + ]), + ), + ); + } +} + +/// One choice. Label, what it does, and a tick when it is the live mapping. +class _ActionRow extends StatelessWidget { + final DeviceAction action; + final bool selected; + final VoidCallback? onTap; + + const _ActionRow({required this.action, required this.selected, this.onTap}); + + @override + Widget build(BuildContext c) { + final p = P.of(c); + return Pressable( + onTap: onTap, + semanticLabel: + '${action.label}. ${action.blurb}${selected ? ' Selected.' : ''}', + child: Padding( + padding: const EdgeInsets.symmetric(vertical: S.x3), + child: Row(children: [ + // THE ROW RULE (see SetRow): exactly one flexible child, so every + // tick in the list lands on the same right edge. Two would split the + // width by ratio instead. + Expanded( + child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(action.label, + style: F.body.copyWith( + color: selected ? p.on(C.indigo) : p.ink, + fontWeight: selected ? FontWeight.w600 : null)), + Text(action.blurb, style: F.over.copyWith(color: p.ink3)), + ]), + ), + const SizedBox(width: S.x2), + Icon(selected ? LucideIcons.check : LucideIcons.circle, + size: 17, color: selected ? p.on(C.indigo) : p.line), + ]), + ), + ); + } +} diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart index 751768db..ce8c9e09 100644 --- a/lib/ui2/profile/settings.dart +++ b/lib/ui2/profile/settings.dart @@ -23,7 +23,6 @@ import '../../data/off_lookup.dart'; import '../../health/health_export.dart' show HealthLinkState; import '../../health/health_import_state.dart'; import '../../health/health_profile_import.dart'; -import '../../notify/notification_center.dart'; import '../../platform/tasker_bridge.dart'; import '../../notify/notification_prefs.dart'; import '../../notify/notification_service.dart'; @@ -35,8 +34,10 @@ import '../../telemetry/health_uploader.dart'; import '../../theme/theme_controller.dart'; import '../ui2.dart'; import 'alarm.dart'; +import 'band_notifications.dart'; import 'data.dart'; import 'gallery.dart'; +import 'gestures.dart'; import 'profile.dart'; /// Unwind the profile stack back to the gate. @@ -497,7 +498,7 @@ class MoreSettingsView extends StatelessWidget { this.healthState = HealthLinkState.unknown, this.healthStore = 'Apple Health', this.telemetry = false, - this.barcodeLookup = false, + this.barcodeLookup = true, this.cycleTracking = false, this.showHealthShare = false, this.healthShare = false, @@ -600,6 +601,14 @@ class MoreSettingsView extends StatelessWidget { onTap: onToggleHealthSync), ]), settingsGroup(c, 'Automation', [ + // The picker died with the old ui tree and the engine kept + // running against a mapping nothing could set — the whole + // feature was live code pinned at "do nothing". + Builder( + builder: (c) => SetRow( + LucideIcons.hand, C.orange, 'Double-tap', + sub: 'What a double-tap on the band does', + onTap: () => goto(c, const BandGestures()))), SetRow(LucideIcons.workflow, C.indigo, 'Tasker and Shortcuts', // The row states the asymmetry rather than leaving it to // the screen: someone on an iPhone should learn what they @@ -727,13 +736,25 @@ class _NotificationSettingsState extends State { }); } + /// Whether the strap-buzz relay exists on this platform. Android only — + /// iOS gives no app access to another app's notifications — and the row is + /// absent rather than disabled there, so there is nothing to explain. + bool get _relaySupported => + defaultTargetPlatform == TargetPlatform.android; + Future _apply(NotificationPrefs next) async { setState(() => _prefs = next); await next.save(); // Re-run the scheduler so a switch that was just turned off actually // cancels what it was standing for, rather than taking effect at some // later resume. - await NotificationCenter.instance.scheduleStandingReminders(next); + // + // Through AppState, not straight at the NotificationCenter: the medication + // slots need the med schedule and the check-in needs today's journal, and + // only AppState can read either. Calling the centre directly cancels what + // the switch turned off and arms nothing back, so meds stayed silent until + // the next foreground pass. + if (mounted) await context.read().refreshAiReminders(); // the water buzz is an in-memory timer, not an OS slot — re-arm it here or // the switch only takes effect at the next launch. if (mounted) await context.read().armWaterReminder(next); @@ -753,6 +774,7 @@ class _NotificationSettingsState extends State { prefs: p ?? const NotificationPrefs(), loaded: p != null, granted: _granted, + relaySupported: _relaySupported, onChanged: _apply, onRequestPermission: _requestPermission, ); @@ -762,6 +784,11 @@ class _NotificationSettingsState extends State { class NotificationSettingsView extends StatelessWidget { final NotificationPrefs prefs; final bool loaded, granted; + + /// Android only. False hides the strap-buzz relay row entirely rather than + /// showing a control that cannot work. + final bool relaySupported; + final Future Function(NotificationPrefs next)? onChanged; final VoidCallback? onRequestPermission; @@ -770,6 +797,7 @@ class NotificationSettingsView extends StatelessWidget { this.prefs = const NotificationPrefs(), this.loaded = true, this.granted = true, + this.relaySupported = false, this.onChanged, this.onRequestPermission, }); @@ -828,6 +856,56 @@ class NotificationSettingsView extends StatelessWidget { chevron: false, onTap: () => set(prefs.copyWith( remindersEnabled: !prefs.remindersEnabled))), + // The auto-detector's off switch, asked for twice (#102, + // #149) and never built: the bouts were written, the + // prompt was emitted, and nothing anywhere could stop + // either. The sub-line says exactly what it stops, + // because it does NOT stop the detection itself. + SetRow(LucideIcons.radar, C.green, 'Detected workouts', + sub: 'Ask about efforts the band spotted that you did ' + 'not start. Off hides the prompt and the review ' + 'cards; the band goes on measuring either way', + value: prefs.autoDetectEnabled ? 'On' : 'Off', + chevron: false, + onTap: () => set(prefs.copyWith( + autoDetectEnabled: !prefs.autoDetectEnabled))), + // Off by default, and it is the switch that lets the nudge + // be scheduled at all — see + // NotificationService.schedulableIds. It had none, so it + // was refused there and had never once fired. + SetRow(LucideIcons.footprints, C.orange, 'Movement nudge', + sub: 'One notification after two hours with no ' + 'movement at all, and only while the band is on ' + 'and connected. Never inside 21:00–09:00', + value: prefs.movementEnabled ? 'On' : 'Off', + chevron: false, + onTap: () => set(prefs.copyWith( + movementEnabled: !prefs.movementEnabled))), + // The one prompt whose time is not a guess: it is the + // schedule already typed into the Medication tab. Only a + // dose still due is armed, and the notification names no + // drug — it lands on a lock screen in front of whoever is + // in the room. + SetRow(LucideIcons.pill, C.blue, 'Medication reminders', + sub: 'One notification per scheduled dose, at the ' + 'times you entered. Nothing is sent for a dose ' + 'already marked taken or skipped', + value: prefs.medsEnabled ? 'On' : 'Off', + chevron: false, + onTap: () => + set(prefs.copyWith(medsEnabled: !prefs.medsEnabled))), + // ONE prompt for the whole journal, not one per field — + // mood, energy, stress and the rest are all the same + // screen, so five rows would be five interruptions for one + // minute of typing. + SetRow(LucideIcons.notebookPen, C.purple, 'Daily check-in', + sub: 'One prompt in the evening to write the day — ' + 'mood, energy, stress. Skipped once the day ' + 'already has a rating in it', + value: prefs.checkInEnabled ? 'On' : 'Off', + chevron: false, + onTap: () => set(prefs.copyWith( + checkInEnabled: !prefs.checkInEnabled))), // A prompt to log, not a reading. The app measures no // hydration and this row may never imply it does. SetRow(LucideIcons.glassWater, C.teal, 'Water reminder', @@ -848,6 +926,17 @@ class NotificationSettingsView extends StatelessWidget { waterIntervalMin: _nextEvery(prefs.waterIntervalMin)))), ]), + if (relaySupported) + settingsGroup(c, 'The strap', [ + // The other direction: not what this app sends you, but + // what your phone's apps make the band do. The permission + // for it has been in the manifest all along with nothing + // in the app that could reach it. + SetRow(LucideIcons.bellRing, C.purple, + 'Buzz on app notifications', + sub: 'Pick which phone apps make the strap buzz', + onTap: () => goto(c, const BandNotifications())), + ]), settingsGroup(c, 'Quiet hours', [ SetRow(LucideIcons.moon, C.indigo, 'Quiet hours', sub: 'Nothing buzzes inside this window', diff --git a/lib/ui2/revision.dart b/lib/ui2/revision.dart new file mode 100644 index 00000000..51284281 --- /dev/null +++ b/lib/ui2/revision.dart @@ -0,0 +1,130 @@ +// "The data under this screen changed — re-read it." +// +// One idiom, not two. A screen that loads in `initState` and never reads +// again is correct exactly until something else writes: an import lands sixty +// sessions, a derive rewrites the day, a workout is logged from the sheet, and +// the screen keeps rendering what it read at launch. Three of the five tabs +// are kept alive forever by the shell's IndexedStack, so "for the life of the +// widget" means "until the app is relaunched" — which is why the workaround +// was to leave the tab and come back. +// +// The signal already existed: [AppState.insightsRevision], a ValueNotifier the +// derive, the session writer and now the importers tick. Home and Workouts had +// each hand-rolled the same twenty lines of subscribe/compare/dispose around +// it; this is those twenty lines, once. +// +// WHY A ValueNotifier AND NOT notifyListeners: AppState ticks at ~1 Hz while a +// session is live (live HR, log lines). Anything watching AppState broadly +// rebuilds on every one of those — the reason `select` is used everywhere in +// this app. `insightsRevision` moves only when DURABLE data changed, and it is +// off the ChangeNotifier path entirely, so a subscriber re-reads on a derive +// or an import and never on a heartbeat. +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +import '../state/app_state.dart'; + +/// Re-read on [AppState.insightsRevision]. +/// +/// The screen keeps its own first load in `initState`; this only says when to +/// do it again. Mix in, implement [reload], and delete the plumbing. +mixin RevisionReload on State { + /// Held so [dispose] can unsubscribe without a context, and so a second + /// `didChangeDependencies` cannot subscribe twice — `addListener` stacks + /// duplicates. + ValueNotifier? _rev; + + /// The revision this screen's data was read at. + int _seen = -1; + + /// Read the database again. Called ONLY when the revision actually moves — + /// never on a rebuild, so it is safe for it to be expensive. + void reload(); + + /// The newest read claimed per resource key. See [beginRead]. + final Map _reads = {}; + + /// LAST WRITER WINS IS THE BUG. Two durable writes land in quick succession + /// — an import finishing while a derive commits — and this mixin starts a + /// second [reload] while the first one's queries are still in flight. They + /// are separate futures against a database that is being written, so they + /// can finish in either order, and the loser is whichever one the scheduler + /// resumes last. When that is the OLDER read, its `setState` puts + /// pre-import data back on screen and the screen stays wrong until the next + /// revision — which for a tab the IndexedStack keeps alive means until the + /// app is relaunched, the exact failure this mixin was written to end. + /// + /// So every loader claims a token before it awaits and checks it before it + /// commits: `final t = beginRead(#x); … if (stillNewest(#x, t)) setState(…)`. + /// + /// A TOKEN, NOT A QUEUE. Serialising reloads would be the other obvious fix + /// and is worse on both counts: the newest data waits behind a read it has + /// already superseded, and a loader that hangs blocks every later one + /// forever. Overlapping reads are fine — only overlapping COMMITS are not. + /// + /// One counter per [key], because a screen's sub-tabs are independent + /// resources: Vitals re-reading for a different day must not invalidate the + /// Labs read that started before it. Symbols (`#vitals`) are the cheap key. + int beginRead(Object key) => _reads[key] = (_reads[key] ?? 0) + 1; + + /// Whether [token] is still the newest read of [key] AND this State is still + /// mounted — the whole commit guard, so a call site cannot remember one half + /// of it and forget the other. + bool stillNewest(Object key, int token) => mounted && _reads[key] == token; + + /// Whether [key] has ever been read — the honest test for "this resource is + /// live", which "its cached value is non-null" only approximates. + /// + /// A [reload] that re-reads on the cache being non-null skips the sub-tab + /// whose FIRST read is still in flight: its cache is null, so no newer token + /// is issued for that key, so the pre-revision read passes [stillNewest] and + /// commits data older than the revision — the exact race the token exists to + /// stop, in the window where it matters most, the first load after an + /// import. Re-issuing is also what un-sticks it: dropping the old read + /// without starting a new one would leave the tab spinning forever. + /// + /// Still lazy: a sub-tab the user has never opened never called [beginRead], + /// so it is not re-read here either. + bool hasRead(Object key) => _reads.containsKey(key); + + /// False for a screen that was handed its data (a golden, the gallery, a + /// preview): there is nothing behind it to re-read. + bool get revisionReloads => true; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!revisionReloads) return; + // No AppState above us in a golden or a widget test — such a screen just + // renders what it has, exactly as it did before this mixin existed. + final AppState app; + try { + app = context.read(); + } catch (_) { + return; + } + if (identical(_rev, app.insightsRevision)) return; + _rev?.removeListener(_onRevision); + _rev = app.insightsRevision..addListener(_onRevision); + _seen = app.insightsRevision.value; + } + + // ponytail: a parked tab re-reads too — the IndexedStack keeps all five + // alive, so a derive costs five loads instead of one. They are the same + // queries opening the tab would run, and they run on a derive or an import, + // not on a frame. Gate on route/tab visibility if profiling ever says so. + void _onRevision() { + final r = _rev; + // A bump that landed while this screen was being torn down, or one it has + // already read, is not a reason to hit the database. + if (!mounted || r == null || r.value == _seen) return; + _seen = r.value; + reload(); + } + + @override + void dispose() { + _rev?.removeListener(_onRevision); + super.dispose(); + } +} diff --git a/lib/ui2/screens/ai_briefing.dart b/lib/ui2/screens/ai_briefing.dart index df6ce9cd..831a4459 100644 --- a/lib/ui2/screens/ai_briefing.dart +++ b/lib/ui2/screens/ai_briefing.dart @@ -190,8 +190,13 @@ class SentPayload extends StatelessWidget { return s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}'; } - static String _value(dynamic v) => - v is List ? v.join(', ') : v?.toString() ?? '—'; + /// Verbatim, because this is a preview of a payload and not a metric card. + /// [buildBriefingUserPrompt] writes `$v` for every entry, so a null reaches + /// the model as the word `null` and this has to say the same — an em dash + /// here would read as "withheld" for a value that was in fact sent, empty. + /// (`_put` drops absent metrics before they get this far, so this is the + /// belt and not the trousers.) + static String _value(dynamic v) => v is List ? v.join(', ') : '$v'; @override Widget build(BuildContext c) { diff --git a/lib/ui2/screens/cycle_screen.dart b/lib/ui2/screens/cycle_screen.dart index 00298734..6d37174d 100644 --- a/lib/ui2/screens/cycle_screen.dart +++ b/lib/ui2/screens/cycle_screen.dart @@ -191,7 +191,7 @@ class CycleTab extends StatefulWidget { State createState() => _CycleTabState(); } -class _CycleTabState extends State { +class _CycleTabState extends State with RevisionReload { CycleData? _d; /// The symptom look-back is folded away by default — the chips above it are @@ -213,12 +213,21 @@ class _CycleTabState extends State { WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } + @override + bool get revisionReloads => widget.data == null; + + /// Cycle logs and symptoms arrive by import as well as by tap, and this tab + /// lives inside Wellness — same IndexedStack, same never-disposed lifetime. + @override + void reload() => _load(); + Future _load() async { + final t = beginRead(#cycle); try { final d = await CycleData.load(context.read()); - if (mounted) setState(() => _d = d); + if (stillNewest(#cycle, t)) setState(() => _d = d); } catch (_) { - if (mounted) setState(() => _d = const CycleData()); + if (stillNewest(#cycle, t)) setState(() => _d = const CycleData()); } } diff --git a/lib/ui2/screens/health_screen.dart b/lib/ui2/screens/health_screen.dart index acdcc760..92104025 100644 --- a/lib/ui2/screens/health_screen.dart +++ b/lib/ui2/screens/health_screen.dart @@ -418,7 +418,7 @@ class HealthScreen extends StatefulWidget { State createState() => _HealthScreenState(); } -class _HealthScreenState extends State { +class _HealthScreenState extends State with RevisionReload { // EXPLORE SITS SECOND, not last. Five chips do not fit a 390 pt frame at 1×: // the fifth is clipped by the edge, and a half-visible chip is exactly the // discoverability failure this tab exists to fix. Labs takes the clip instead @@ -447,17 +447,46 @@ class _HealthScreenState extends State { WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } + /// Handed its data (golden, gallery) — nothing behind it to re-read. + @override + bool get revisionReloads => widget.data == null; + + /// A tab kept alive by the IndexedStack for the life of the process: it read + /// the database once at launch, so an import or a derive that landed after + /// that was invisible here until the app was relaunched. The already-loaded + /// sub-tabs are re-read too — they cache on `!= null`, which is the same + /// load-once bug one level down. + /// + /// EVERY SUB-TAB THAT HAS EVER READ, not every sub-tab that holds data. This + /// gated on `!= null` and so skipped the one case that matters most — a + /// sub-tab whose first read is still in flight when the revision lands, which + /// is the ordinary state of the first load after an import. See [hasRead]. + @override + void reload() { + _load(); + if (hasRead(#vitals)) _loadVitals(force: true); + if (hasRead(#labs)) { + _l = null; + _loadLabs(); + } + if (hasRead(#explore)) { + _e = null; + _loadExplore(); + } + } + Future _load() async { final repo = repoOf(context); if (repo == null) { if (mounted) setState(() => _loading = false); return; } + final t = beginRead(#day); try { final d = await HealthData.load(repo); - if (mounted) setState(() => (_d = d, _loading = false)); + if (stillNewest(#day, t)) setState(() => (_d = d, _loading = false)); } catch (_) { - if (mounted) setState(() => _loading = false); + if (stillNewest(#day, t)) setState(() => _loading = false); } } @@ -474,11 +503,14 @@ class _HealthScreenState extends State { Future _loadVitals({bool force = false}) async { final repo = repoOf(context); if (repo == null || (_v != null && !force)) return; + // Keyed per sub-tab: steering to another day starts a read that must beat + // the one already in flight, and neither may cancel Labs or Explore. + final t = beginRead(#vitals); try { final v = await VitalsData.load(repo, want: _vDay); - if (mounted) setState(() => (_v = v, _vFailed = false)); + if (stillNewest(#vitals, t)) setState(() => (_v = v, _vFailed = false)); } catch (_) { - if (mounted) setState(() => _vFailed = true); + if (stillNewest(#vitals, t)) setState(() => _vFailed = true); } } @@ -489,11 +521,12 @@ class _HealthScreenState extends State { Future _loadLabs() async { if (_l != null) return; + final t = beginRead(#labs); try { final l = await LabsData.load(); - if (mounted) setState(() => (_l = l, _lFailed = false)); + if (stillNewest(#labs, t)) setState(() => (_l = l, _lFailed = false)); } catch (_) { - if (mounted) setState(() => _lFailed = true); + if (stillNewest(#labs, t)) setState(() => _lFailed = true); } } @@ -510,11 +543,12 @@ class _HealthScreenState extends State { Future _loadExplore() async { if (_e != null) return; + final t = beginRead(#explore); try { final e = await ExploreData.load(); - if (mounted) setState(() => (_e = e, _eFailed = false)); + if (stillNewest(#explore, t)) setState(() => (_e = e, _eFailed = false)); } catch (_) { - if (mounted) setState(() => _eFailed = true); + if (stillNewest(#explore, t)) setState(() => _eFailed = true); } } @@ -558,9 +592,16 @@ class _HealthScreenState extends State { // measured gap. `overnight: false` is for a row that is not — a hole at // 2 AM says nothing about a daytime number, and offering it as the reason // would be the invented cause the whole absence layer refuses. + // `rising` is the ONE value judgement a row makes, and it is per metric: + // resting heart rate falling is good news, HRV rising is. A metric this + // project makes no directional claim about takes [Rising.neither] and + // draws its arrow in ink — the direction is stated, the verdict is not + // invented. void row(Metric m, IconData icon, Color col, String name, String sub, - String value, String unit, List spark, String metricKey, - {String? whyAbsent, bool overnight = true}) { + String value, String unit, List series, String metricKey, + {String? whyAbsent, + bool overnight = true, + Rising rising = Rising.neither}) { if (m.isEmpty) { final s = StatusCard.forMetric('No ${name.toLowerCase()}', m, why: whyAbsent ?? '', gap: overnight ? d.nightGap : null); @@ -570,7 +611,8 @@ class _HealthScreenState extends State { rows.add(MetricRow(icon, col, name, value, sub: sub, unit: unit, - spark: spark, + series: series, + rising: rising, onTap: () => go(c, MetricDetail(metricKey)))); } @@ -592,6 +634,10 @@ class _HealthScreenState extends State { // was scored" is often the wrong reason and contradicts the Sleep row // sitting two lines down. Only the branch this screen can SEE is // stated — the other named a beat-quality gate it never read. + // Nocturnal resting heart rate: lower is the direction every part of + // this app already treats as better — it is what the illness CUSUM + // watches for a RISE, and what readiness scores as `lowerIsBetter`. + rising: Rising.bad, whyAbsent: sleepMin.isEmpty ? 'Read from sleep, and no night was scored.' : ''); @@ -601,6 +647,7 @@ class _HealthScreenState extends State { ofNight('RMSSD, asleep'), hrvMetric.value == null ? '' : '${hrvMetric.value!.round()}', 'ms', d.spark('hrv', 24), 'hrv', + rising: Rising.good, // Blaming signal quality unconditionally told a day-one user their // sensor produced dirty data on a night that never happened. whyAbsent: sleepMin.isEmpty @@ -610,6 +657,9 @@ class _HealthScreenState extends State { row(sleepMin, LucideIcons.moon, C.blue, 'Sleep', night == null ? 'Last night' : prettyDay(night), hm(sleepMin.value), '', d.spark('sleep', 24), 'sleep', + // More sleep is the direction this app coaches towards — `sleepNeed` + // exists to say you are short of it, never over it. + rising: Rising.good, whyAbsent: 'No sleep period long enough to score was recorded.'); final stressBlock = d.today['stress']; @@ -628,6 +678,7 @@ class _HealthScreenState extends State { '/100', d.spark('stress', 24), 'stress', + rising: Rising.bad, // Was 'No resting stretch long enough last night.' — one of several // gates stress abstains on, asserted for all of them. whyAbsent: sleepMin.isEmpty @@ -640,6 +691,10 @@ class _HealthScreenState extends State { respMetric.value == null ? '' : respMetric.value!.toStringAsFixed(1), 'br/min', d.spark('resp_rate', 24), 'resp_rate', + // DELIBERATELY UNJUDGED. Readiness scores a rise as a cost, but that + // is a deviation from your own baseline, not a claim that breathing + // slower is better health — nobody here would tell you a falling + // respiratory rate is good news. Direction, no verdict. // THE ESTIMATOR'S OWN REASON when it left one, not a guess written // here. `respiration.rsa` records which gate it failed — too few beats, // artifact fraction over the gate, no stable HF peak, or a peak that @@ -1232,12 +1287,21 @@ class _HealthScreenState extends State { ..sort((a, b) => (byKey[a['marker']]?.label ?? '') .compareTo(byKey[b['marker']]?.label ?? '')); final lastDraw = l.results.isEmpty ? null : l.results.first['taken_on']; + // Markers the user named themselves — the only ones whose DEFINITION is + // theirs to remove. A catalogue marker is the app's and stays. + final mine = l.markers.where((m) => m.custom).toList(); + final counts = {}; + for (final r in l.results) { + final k = r['marker'].toString(); + counts[k] = (counts[k] ?? 0) + 1; + } return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (rows.isEmpty) - const StatusCard( - 'No lab results yet', - 'Nothing logged yet.', + StatusCard( + 'No lab results', + 'Nothing logged. Anything you add here stays on this device, and ' + 'anything you remove is gone from it.', icon: LucideIcons.testTube, ) else ...[ @@ -1246,7 +1310,9 @@ class _HealthScreenState extends State { child: Column(children: [ for (var i = 0; i < rows.length; i++) ...[ if (i > 0) Divider(color: p.line, height: 1), - _lab(p, byKey[rows[i]['marker'].toString()], rows[i], sex), + _lab(p, byKey[rows[i]['marker'].toString()], rows[i], sex, + () => _removeResult(byKey[rows[i]['marker'].toString()], + rows[i], l)), ], ]), ), @@ -1254,6 +1320,7 @@ class _HealthScreenState extends State { Text('Last panel ${lastDraw ?? ''} · logged by hand', style: F.over.copyWith(color: p.ink3)), ], + if (mine.isNotEmpty) _myMarkers(p, mine, counts), const SizedBox(height: S.x4), BigButton('Add a result', icon: LucideIcons.plus, @@ -1269,52 +1336,175 @@ class _HealthScreenState extends State { ]); } - Widget _lab(P p, LabMarker? m, Map r, String? sex) { + Widget _lab(P p, LabMarker? m, Map r, String? sex, + VoidCallback onRemove) { final v = (r['value'] as num?)?.toDouble(); final unit = (r['unit'] ?? m?.unit ?? '').toString(); final range = m?.rangeFor(sex); final inRange = v == null || m == null ? null : m.inRange(v, sex: sex); - return Padding( - padding: const EdgeInsets.symmetric(vertical: S.x3), - child: Row(children: [ - Container( - width: 7, - height: 7, - decoration: BoxDecoration( - // No interval means NO OPINION — a grey dot, never a green one. - color: inRange == null - ? p.ink3 - : (inRange ? p.on(C.green) : p.on(C.orange)), - shape: BoxShape.circle, + // The whole row is the control, with the bin as its affordance — the same + // shape a logged meal takes, and it costs no width, which at 3x text is + // the difference between a row that fits and one that overflows. + return Pressable( + onTap: onRemove, + semanticLabel: + 'Remove ${m?.label ?? r['marker']} from ${r['taken_on']}', + child: Padding( + padding: const EdgeInsets.symmetric(vertical: S.x3), + child: Row(children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + // No interval means NO OPINION — a grey dot, never a green one. + color: inRange == null + ? p.ink3 + : (inRange ? p.on(C.green) : p.on(C.orange)), + shape: BoxShape.circle, + ), ), - ), - const SizedBox(width: S.x3), - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(m?.label ?? r['marker'].toString(), - style: F.body.copyWith(color: p.ink)), - Text( - range == null - ? 'No reference interval · ${r['taken_on']}' - : 'Typical ${_num(range.low)}–${_num(range.high)} · ' - '${r['taken_on']}', - style: F.over.copyWith(color: p.ink3)), + const SizedBox(width: S.x3), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(m?.label ?? r['marker'].toString(), + style: F.body.copyWith(color: p.ink)), + Text( + range == null + ? 'No reference interval · ${r['taken_on']}' + : 'Typical ${_num(range.low)}–${_num(range.high)} · ' + '${r['taken_on']}', + style: F.over.copyWith(color: p.ink3)), + ]), + ), + const SizedBox(width: S.x2), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(v == null ? '' : (m?.format(v) ?? v.toString()), + style: F.n17.copyWith( + color: inRange == false ? p.on(C.orange) : p.ink)), + const SizedBox(width: 3), + Text(unit, style: F.over.copyWith(color: p.ink3)), + ]), + // This is the user's own blood work in an app that keeps it on their + // phone; being able to take it back out is the premise, not a setting. + const SizedBox(width: S.x2), + Icon(LucideIcons.trash2, size: 16, color: p.ink3), + ]), + ), + ); + } + + /// One reading of one marker on one date. Named in full before it goes: + /// there is no undo here, and a generic "are you sure?" over a column of + /// blood results is how the wrong one is lost. + Future _removeResult( + LabMarker? m, Map r, LabsData l) async { + final marker = r['marker'].toString(); + final takenOn = r['taken_on'].toString(); + final label = m?.label ?? marker; + final v = (r['value'] as num).toDouble(); + final unit = (r['unit'] ?? m?.unit ?? '').toString(); + // The row on screen is the NEWEST draw of its marker, so an earlier one + // takes its place rather than the marker disappearing — which without + // being told reads as the delete having failed. + final older = l.results.firstWhere( + (o) => o['marker'] == marker && o['taken_on'] != takenOn, + orElse: () => const {}, + )['taken_on']; + + final ok = await confirmRemove( + context, + title: 'Remove $label from $takenOn?', + body: 'The ${m?.format(v) ?? _num(v)} $unit you logged for that draw. ' + 'It leaves this device and there is no undo.' + '${older == null ? '' : ' Your $older draw stays, and shows here instead.'}', + ); + if (!ok || !mounted) return; + await LocalDb.deleteLabResult(marker, takenOn); + _l = null; + await _loadLabs(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(older == null + ? 'Removed $label from $takenOn. No $label results left.' + : 'Removed $label from $takenOn. Showing your $older draw now.'), + )); + } + + /// Markers the user named. Only the DEFINITION is theirs to remove here — + /// see [_removeMarker] for why one holding results is refused. + Widget _myMarkers(P p, List mine, Map counts) => + Section( + 'Markers you named', + Surface( + pad: const EdgeInsets.symmetric(horizontal: S.x4), + child: Column(children: [ + for (var i = 0; i < mine.length; i++) ...[ + if (i > 0) Divider(color: p.line, height: 1), + Pressable( + semanticLabel: 'Remove the ${mine[i].label} marker', + onTap: () => _removeMarker(mine[i], counts[mine[i].key] ?? 0), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: S.x3), + child: Row(children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(mine[i].label, + style: F.body.copyWith(color: p.ink)), + Text( + switch (counts[mine[i].key] ?? 0) { + 0 => 'Nothing logged under it', + 1 => '1 result · ${mine[i].unit}', + final n => '$n results · ${mine[i].unit}', + }, + style: F.over.copyWith(color: p.ink3)), + ]), + ), + const SizedBox(width: S.x2), + Icon(LucideIcons.trash2, size: 16, color: p.ink3), + ]), + ), + ), + ], ]), ), - const SizedBox(width: S.x2), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text(v == null ? '' : (m?.format(v) ?? v.toString()), - style: F.n17.copyWith( - color: inRange == false ? p.on(C.orange) : p.ink)), - const SizedBox(width: 3), - Text(unit, style: F.over.copyWith(color: p.ink3)), - ]), - ]), + ); + + /// Removing a marker DEFINITION, which is not the same act as removing its + /// readings — `deleteLabMarkerDef` deliberately leaves those alone, because + /// they were real draws and each row carries its own unit. + /// + /// But this screen labels a result THROUGH its marker, so a definition + /// deleted out from under one leaves the reading rendering as its raw + /// storage key with no interval. Both ways out of that are worse than this + /// one: deleting the readings too destroys blood work nobody asked to + /// destroy, and keeping them degrades a number this app calls absolute. So + /// a marker that still holds results is refused, and says how to proceed — + /// the results are one screen up, each removable on its own. + Future _removeMarker(LabMarker m, int results) async { + if (results > 0) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('${m.label} still holds $results ' + '${results == 1 ? 'result' : 'results'}. Remove those first — the ' + 'marker is what labels them.'), + )); + return; + } + final ok = await confirmRemove( + context, + title: 'Remove ${m.label}?', + body: 'It leaves the marker list, so you can no longer log it. Nothing ' + 'measured goes with it — you have no results under it.', ); + if (!ok || !mounted) return; + await LocalDb.deleteLabMarkerDef(m.key); + _l = null; + await _loadLabs(); } String _num(double v) => diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart index b76c7a3e..4b9f114b 100644 --- a/lib/ui2/screens/home_screen.dart +++ b/lib/ui2/screens/home_screen.dart @@ -479,11 +479,39 @@ String prettyDay(String? dayId) { /// palettes instead of each keeping a private copy of the cut-offs. They did, /// and a 65 rendered green on the phone, orange on the widget and yellow on /// the wrist. -1 = not scored. +/// +/// THE CUT-OFFS ARE THE SCORE'S OWN QUANTILES, NOT ROUND NUMBERS (issue #250). +/// `readinessComposite` is `100 / (1 + exp(-z̄))` with no scale parameter, and +/// z̄ is a weight-renormalised mean of per-input robust z's — each ~N(0,1) +/// against that person's OWN baseline. So the score is a percentile of self +/// whose CENTRE IS 50 BY CONSTRUCTION: a night exactly at personal median +/// scores 50, and the old 40/60/80 bands filed that median night under "Take it +/// easy". Roughly a quarter of all nights fell under "Rest today" and 1.7 % +/// could ever reach "Good to go" — it needed every input ~1.4 SD above median +/// at once. A warning that fires on the typical night is not a warning. +/// +/// z̄'s own SD is NOT 1: averaging the disclosed weights (.40/.30/.20/.10, +/// renormalised over present inputs) gives σ ≈ 0.55-0.60 if the inputs were +/// independent, ~0.70 at the positive correlation HRV/RHR/RR actually have. +/// σ ≈ 0.65 is the middle of that, and the cut-offs below are its quantiles: +/// +/// score = 100 / (1 + exp(-0.65 · Φ⁻¹(p))) +/// p=.05 → 26 p=.20 → 37 p=.75 → 61 +/// +/// which lands 5 % of nights on "Rest today", 15 % on "Take it easy", 55 % on +/// "Steady" and 25 % on "Good to go". The median night is now the neutral band, +/// which is the whole point. Under the old cut-offs the same distribution read +/// 27 / 47 / 25 / 2. +/// +/// σ is the one soft number here — it is a property of how correlated a given +/// person's four inputs are, and it moves with how many of them are present. +/// Re-derive it from a real `metric_series` readiness distribution when there +/// is one long enough to measure; do not nudge the cut-offs by feel. ({String label, Color color, int tier}) readinessBand(num? v) { if (v == null) return (label: 'Not scored', color: C.n400, tier: -1); - if (v >= 80) return (label: 'Good to go', color: C.green, tier: 3); - if (v >= 60) return (label: 'Steady', color: C.green, tier: 2); - if (v >= 40) return (label: 'Take it easy', color: C.orange, tier: 1); + if (v >= 61) return (label: 'Good to go', color: C.green, tier: 3); + if (v >= 37) return (label: 'Steady', color: C.green, tier: 2); + if (v >= 26) return (label: 'Take it easy', color: C.orange, tier: 1); return (label: 'Rest today', color: C.red, tier: 0); } @@ -1049,7 +1077,7 @@ class HomeScreen extends StatefulWidget { State createState() => _HomeScreenState(); } -class _HomeScreenState extends State { +class _HomeScreenState extends State with RevisionReload { HomeData? _d; bool _loading = true; @@ -1058,15 +1086,6 @@ class _HomeScreenState extends State { /// history that their band has never produced data is the wrong answer to it. bool _failed = false; - /// The revision this screen's data was loaded at, and the notifier it came - /// from — the same pattern `WorkoutScreen` already uses. Home used to load - /// once post-frame and never listen, so the "Sync the band" button it renders - /// could not change what the screen showed: the offload landed, the derive - /// ran, and Home kept saying "Nothing derived yet" until the app was - /// relaunched. - int _loadedAt = -1; - ValueNotifier? _rev; - @override void initState() { super.initState(); @@ -1078,36 +1097,16 @@ class _HomeScreenState extends State { WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } + /// Handed its data (golden, gallery) — the screen just renders what it has. @override - void didChangeDependencies() { - super.didChangeDependencies(); - if (widget.data != null) return; - // No AppState above us in a golden — the screen just renders what it has. - final AppState app; - try { - app = context.read(); - } catch (_) { - return; - } - if (!identical(_rev, app.insightsRevision)) { - _rev?.removeListener(_onRevision); - _rev = app.insightsRevision..addListener(_onRevision); - _loadedAt = app.insightsRevision.value; - } - } - - void _onRevision() { - final r = _rev; - if (!mounted || r == null || r.value == _loadedAt) return; - _loadedAt = r.value; - _load(); - } + bool get revisionReloads => widget.data == null; + /// Home used to load once post-frame and never listen, so the "Sync the + /// band" button it renders could not change what the screen showed: the + /// offload landed, the derive ran, and Home kept saying "Nothing derived + /// yet" until the app was relaunched. @override - void dispose() { - _rev?.removeListener(_onRevision); - super.dispose(); - } + void reload() => _load(); Future _load() async { final repo = repoOf(context); @@ -1115,11 +1114,14 @@ class _HomeScreenState extends State { if (mounted) setState(() => _loading = false); return; } + final t = beginRead(#home); try { final d = await HomeData.load(repo); - if (mounted) setState(() => (_d = d, _loading = false, _failed = false)); + if (stillNewest(#home, t)) { + setState(() => (_d = d, _loading = false, _failed = false)); + } } catch (_) { - if (mounted) setState(() => (_loading = false, _failed = true)); + if (stillNewest(#home, t)) setState(() => (_loading = false, _failed = true)); } } diff --git a/lib/ui2/screens/log_food.dart b/lib/ui2/screens/log_food.dart index 20c6b9b5..7e476b8c 100644 --- a/lib/ui2/screens/log_food.dart +++ b/lib/ui2/screens/log_food.dart @@ -147,11 +147,13 @@ class _LogFoodSheetState extends State { // ── the barcode path ────────────────────────────────────────────────────── - /// Scan, then look the code up — but only after the user has agreed to the - /// one outbound call this screen can make. + /// Scan, then look the code up. /// - /// The consent is asked BEFORE the camera opens, not after: someone who - /// would decline should not have pointed their phone at a packet first. + /// Lookup is on by default, so this normally goes straight to the camera. + /// The prompt below is for the person who turned it OFF and then tapped + /// Scan: refusing silently there reads as a broken scanner. It is asked + /// BEFORE the camera opens, not after — someone who would decline should not + /// have pointed their phone at a packet first. Future _scan() async { if (!offLookupAllowed) { final agreed = await _askLookupConsent(context); diff --git a/lib/ui2/screens/log_workout.dart b/lib/ui2/screens/log_workout.dart new file mode 100644 index 00000000..e20b61c3 --- /dev/null +++ b/lib/ui2/screens/log_workout.dart @@ -0,0 +1,777 @@ +// LOG A WORKOUT — the two places the athlete owns the times, and the review +// screen the auto-detector has been writing to for months with nobody reading. +// +// WHY THIS FILE EXISTS AT ALL. `LocalRepository.logManualWorkout` and +// `setWorkoutWindow` have been implemented, tested and reachable from the +// coach's tool layer since the manual-session work landed, and reachable from +// the app from nowhere: the UI rebuild deleted `lib/ui/workouts/` and lib/ui2 +// never replaced this part of it. Back-logging a session, or widening one the +// detector clipped, meant asking a BYOK language model to do it for you. +// +// The same deletion orphaned `workout_suggestions`. The detector still fills +// that table on every derive; `activeWorkoutSuggestions()` had exactly one +// reader and it only ever DISMISSED. `kRouteWorkoutSuggestion` survived, the +// tab mapping survived, and the destination did not — so the deep link fell +// through `screenForRoute`'s `_ => null` and landed on the plain Workouts tab. +// +// ONE WRITE SEAM. Confirming a detected bout is not a special kind of write: +// it is a manual session over the window the detector proposed, so it goes +// through `logManualWorkout` like every other. That is what gets it a strain +// and a calorie figure scored from the 1 Hz substrate — the old confirm path +// hand-built a row with neither and every confirmed suggestion landed in the +// log showing blanks. It also retires the suggestion on its own, inside the +// repo, via `supersededSuggestionIds`. +// +// WHAT THE DETECTOR REPORTS. The hard-effort CORE, not wall clock — see the +// header of `compute/manual_session.dart`. An hour of mixed training routinely +// detects as ~25 minutes, which is correct for a prompt and wrong for a log +// entry, and is exactly why "Adjust the times" sits beside "Log it" rather +// than three screens away. + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; + +import '../../compute/manual_session.dart'; +import '../../data/db.dart'; +import '../../data/journal_fields.dart' show formatMinuteOfDay; +import '../../health/health_export.dart'; +import '../../notify/notification_prefs.dart'; +import '../../state/app_state.dart'; +import '../activity/catalogue.dart'; +import '../profile/profile.dart' show SetRow, settingsGroup; +import '../ui2.dart'; +import 'home_screen.dart' show repoOf; + +/// One detected bout, as this screen needs it. Built straight off a +/// `workout_suggestions` row. +class Suggestion { + const Suggestion({ + required this.id, + required this.startTs, + required this.endTs, + this.sport, + this.peakBpm, + this.avgBpm, + }); + + final String id; + final int startTs, endTs; + final String? sport; + final int? peakBpm, avgBpm; + + int get durationMin => ((endTs - startTs) / 60).round(); + + /// The catalogue entry behind `sport`, when this build knows it. Null is + /// carried rather than defaulted so the row can say what it was told. + Activity? get activity => activityByName(sport); + + /// Null when the row is malformed — a suggestion with no window is not a + /// suggestion, and it must not reach a screen that offers to log it. + static Suggestion? from(Map r) { + final id = r['id']; + final s = (r['start_ts'] as num?)?.toInt(); + final e = (r['end_ts'] as num?)?.toInt(); + if (id is! String || s == null || e == null || e <= s) return null; + return Suggestion( + id: id, + startTs: s, + endTs: e, + sport: r['sport'] as String?, + peakBpm: (r['peak_bpm'] as num?)?.toInt(), + avgBpm: (r['avg_bpm'] as num?)?.toInt(), + ); + } +} + +/// [all] narrowed to the one bout the notification named, or [all] unchanged +/// when it named none — or named one that is no longer waiting, because it was +/// logged or dismissed between the buzz and the tap. The remaining bouts are +/// still real, so they are shown rather than an empty screen. +List focusSuggestions(List all, String? focusId) { + if (focusId == null) return all; + final one = [for (final s in all) if (s.id == focusId) s]; + return one.isEmpty ? all : one; +} + +// ══════════════════ THE REVIEW SCREEN ══════════════════ + +/// Where "Did you work out?" lands. Every active bout, each with the two +/// answers that are honest — it happened, or it didn't — and the third that +/// matters more than either: the window is wrong. +class WorkoutSuggestionScreen extends StatefulWidget { + const WorkoutSuggestionScreen({super.key, this.preloaded, this.focusId}); + + /// Injected in tests and goldens. Null means read the table. + final List? preloaded; + + /// The one bout the notification was about (`workout_suggestions.id`), from + /// the deep link's `?id=`. Null when the screen is opened from the Workouts + /// tab, which reviews everything. + /// + /// A notification that says "we spotted ~40 min" and opens a list of four is + /// the same broken promise as landing on the plain tab was. If the id is no + /// longer active — logged or dismissed between the buzz and the tap — the + /// rest of the list is shown rather than an empty screen, because those are + /// still real and still waiting. + final String? focusId; + + @override + State createState() => + _WorkoutSuggestionScreenState(); +} + +class _WorkoutSuggestionScreenState extends State { + List? _items; + + /// Tracked SEPARATELY from [_items]. A failed query rendered as "nothing to + /// review" tells the user a still-active suggestion was already handled, + /// which is the one thing this screen must never say by accident. + bool _failed = false; + bool _busy = false; + + @override + void initState() { + super.initState(); + if (widget.preloaded != null) { + _items = focusSuggestions(widget.preloaded!, widget.focusId); + } else { + _load(); + } + } + + Future _load() async { + setState(() => _failed = false); + // The switch, before the table. This screen is reachable by tapping the + // notification (`kRouteWorkoutSuggestion`), which does not come through + // the Workouts tab's already-gated read — so "auto-detect off" has to be + // answered here too or the one surface the user actually taps is the one + // the switch never reached. + if (!await autoDetectOn()) { + if (mounted) setState(() => _items = const []); + return; + } + try { + final rows = await LocalDb.activeWorkoutSuggestions(); + if (!mounted) return; + final all = [for (final r in rows) ?Suggestion.from(r)]; + setState(() => _items = focusSuggestions(all, widget.focusId)); + } catch (_) { + if (mounted) setState(() => _failed = true); + } + } + + /// Log it, over the window the detector proposed. + Future _confirm(Suggestion s) async { + final repo = repoOf(context); + if (repo == null || _busy) return; + setState(() => _busy = true); + var message = ''; + try { + final r = await repo.logManualWorkout( + startTs: s.startTs, + endTs: s.endTs, + type: s.activity?.typeKey ?? 'other', + ); + // Every write path exports, or the health store quietly disagrees with + // the log (#130). No-op with health sync off; never throws. + await HealthExporter.exportWorkoutId(r['workout_id'] as String?); + // The repo retires every suggestion the saved window covers, this one + // included — nothing to dismiss here. + } on ManualWindowException catch (e) { + // A REFUSAL, not a failure to retry differently. The commonest is an + // overlap: those minutes are already in the log, so the bout is spent. + message = e.error.message; + try { + await LocalDb.dismissWorkoutSuggestion(s.id); + } catch (_) {/* the reason is already on screen */} + } catch (_) { + message = 'Could not log this one — try again.'; + } + if (!mounted) return; + setState(() => _busy = false); + if (message.isNotEmpty) _say(message); + await _afterAction(); + } + + Future _dismiss(Suggestion s) async { + if (_busy) return; + setState(() => _busy = true); + try { + await LocalDb.dismissWorkoutSuggestion(s.id); + } catch (_) { + if (mounted) _say('Could not dismiss this one — try again.'); + } + if (!mounted) return; + setState(() => _busy = false); + await _afterAction(); + } + + /// Open the form on the detected window so the athlete can widen it to the + /// session they actually did, then save that instead. + Future _adjust(Suggestion s) async { + final nav = Navigator.of(context); + final saved = await nav.push(MaterialPageRoute( + builder: (_) => LogWorkout( + start: DateTime.fromMillisecondsSinceEpoch(s.startTs * 1000), + end: DateTime.fromMillisecondsSinceEpoch(s.endTs * 1000), + activity: s.activity, + title: 'Adjust the times', + ), + )); + if (saved == true) await _afterAction(); + } + + void _say(String m) => + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(m))); + + /// Re-read, then close once there is nothing left to review — the tab + /// underneath is where the now-logged session is. + Future _afterAction() async { + await _load(); + if (!mounted) return; + bumpInsights(context); + if (!_failed && (_items?.isEmpty ?? false)) { + await Navigator.maybePop(context); + } + } + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final items = _items; + return Scaffold( + backgroundColor: p.bg, + body: SafeArea( + child: Column(children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: S.x4), + child: NavBar('Detected activity', sub: 'YOURS TO CONFIRM'), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x10), + children: [ + if (_failed) + StatusCard( + 'Could not read your detected activity', + 'The store did not answer. Nothing has been logged or ' + 'dismissed.', + fix: 'Try again', + icon: LucideIcons.refreshCw, + onFix: _load, + ) + else if (items == null) + const NoData(message: 'Reading what the band spotted…') + else if (items.isEmpty) + const StatusCard( + 'Nothing to review', + 'This one may already have been logged or dismissed.', + icon: LucideIcons.circleCheck, + ) + else + for (final s in items) ...[ + _SuggestionCard( + s, + onConfirm: _busy ? null : () => _confirm(s), + onDismiss: _busy ? null : () => _dismiss(s), + onAdjust: _busy ? null : () => _adjust(s), + ), + const SizedBox(height: S.x3), + ], + const SizedBox(height: S.x3), + const StatusCard( + 'These are the hard minutes, not the whole session', + 'Detection reports the sustained effort it could see, so a ' + 'warm-up and the rest between sets fall outside it. ' + 'Adjust the times before logging if the window is short.', + icon: LucideIcons.scissors, + ), + ], + ), + ), + ]), + ), + ); + } +} + +/// One detected bout: what was seen, and the three answers to it. +class _SuggestionCard extends StatelessWidget { + const _SuggestionCard( + this.s, { + this.onConfirm, + this.onDismiss, + this.onAdjust, + }); + + final Suggestion s; + final VoidCallback? onConfirm, onDismiss, onAdjust; + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final a = s.activity; + final colour = a?.color ?? C.purple; + return Surface( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration(color: p.wash(colour), borderRadius: R.rMd), + child: Icon(a?.icon ?? LucideIcons.activity, + size: 19, color: p.on(colour)), + ), + const SizedBox(width: S.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${s.durationMin} min of effort', + style: F.body + .copyWith(color: p.ink, fontWeight: FontWeight.w600)), + Text(windowLabel(s.startTs, s.endTs), + style: F.over.copyWith(color: p.ink3)), + ]), + ), + ]), + const SizedBox(height: S.x3), + // What was actually measured. No strain and no calories: neither has + // been scored yet — the scoring happens on the write, over whatever + // window is finally saved, and printing one here would be a number + // this screen made up. + InlineMetrics([ + if (s.avgBpm != null) ('Avg HR', '${s.avgBpm} bpm', p.on(C.red)), + if (s.peakBpm != null) ('Peak HR', '${s.peakBpm} bpm', p.on(C.orange)), + if (a != null) ('Looks like', a.name, p.on(colour)), + ]), + const SizedBox(height: S.x4), + BigButton('Log it', icon: LucideIcons.check, onTap: onConfirm), + const SizedBox(height: S.x2), + Row(children: [ + Expanded( + child: BigButton('Adjust the times', + icon: LucideIcons.clock, + color: C.blue, + soft: true, + onTap: onAdjust), + ), + const SizedBox(width: S.x2), + Expanded( + child: BigButton('Not a workout', + icon: LucideIcons.x, color: C.red, soft: true, onTap: onDismiss), + ), + ]), + ]), + ); + } +} + +/// "Today · 6:30 PM – 7:31 PM". The WINDOW, never just the start — the whole +/// reason someone opens this screen is to check whether the detector clipped +/// it, and a start time alone cannot show that. +String windowLabel(int startTs, int endTs) { + final s = DateTime.fromMillisecondsSinceEpoch(startTs * 1000); + final e = DateTime.fromMillisecondsSinceEpoch(endTs * 1000); + return '${dayLabel(s)} · ${formatMinuteOfDay(s.hour * 60 + s.minute)} – ' + '${formatMinuteOfDay(e.hour * 60 + e.minute)}'; +} + +/// Today / Yesterday / "Mon 11 Aug", against the real calendar day rather than +/// a 24-hour subtraction — the day after a spring-forward is 23 hours long. +String dayLabel(DateTime at, {DateTime? now}) { + final n = now ?? DateTime.now(); + final today = DateTime(n.year, n.month, n.day); + final d = DateTime(at.year, at.month, at.day); + final diff = today.difference(d).inDays; + if (diff == 0) return 'Today'; + if (diff == 1) return 'Yesterday'; + const wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const mo = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + return '${wd[d.weekday - 1]} ${d.day} ${mo[d.month - 1]}'; +} + +// ══════════════════ THE FORM ══════════════════ + +/// Log a past session, or fix the window on one already in the log. +/// +/// [sessionId] is the whole difference between the two: with it the save is a +/// RETIME (`setWorkoutWindow`, same id, so the row's GPS route and its rating +/// stay attached), without it a new manual entry (`logManualWorkout`). The +/// type is not editable on a retime — it belongs to the row already, and this +/// screen is about the times. +/// +/// Pops `true` when something was written, so the caller can re-read. +class LogWorkout extends StatefulWidget { + const LogWorkout({ + super.key, + this.sessionId, + this.start, + this.end, + this.activity, + this.title = 'Log a past workout', + this.spans, + this.now, + }); + + final String? sessionId; + final DateTime? start, end; + final Activity? activity; + final String title; + + /// The windows already in the log, for the live overlap check. Injected in + /// tests; null means read them from the repo. + final List? spans; + + /// Injected in tests so "that hasn't happened yet" is deterministic. + final DateTime? now; + + @override + State createState() => _LogWorkoutState(); +} + +class _LogWorkoutState extends State { + late DateTime _start; + late DateTime _end; + late Activity _activity; + List _spans = const []; + bool _saving = false; + String? _wrote; + + @override + void initState() { + super.initState(); + final now = widget.now ?? DateTime.now(); + // An hour, ending on the last whole hour. A form that opens on "now to + // now" is a form whose first state is invalid. + final defaultEnd = DateTime(now.year, now.month, now.day, now.hour); + _end = widget.end ?? defaultEnd; + _start = widget.start ?? _end.subtract(Motion.tick * 3600); + _activity = widget.activity ?? quickStart.first; + if (widget.spans != null) { + _spans = widget.spans!; + } else { + _loadSpans(); + } + } + + Future _loadSpans() async { + final repo = repoOf(context); + if (repo == null) return; + try { + final s = await repo.savedSessionSpans(); + if (mounted) setState(() => _spans = s); + } catch (_) {/* the write seam re-checks anyway */} + } + + int get _startSec => _start.millisecondsSinceEpoch ~/ 1000; + int get _endSec => _end.millisecondsSinceEpoch ~/ 1000; + + /// The live verdict, from the SAME pure function the repo refuses on. Null + /// means the window is acceptable. + ManualWindowError? get _invalid => validateManualWindow( + startSec: _startSec, + endSec: _endSec, + nowSec: + (widget.now ?? DateTime.now()).millisecondsSinceEpoch ~/ 1000, + existing: _spans, + // A retime must not collide with itself; a new entry's id is derived + // from its start second, so re-logging the same window updates that + // row rather than colliding with it. + editingId: widget.sessionId ?? manualSessionId(_startSec), + ); + + Future _pickDate() async { + final now = widget.now ?? DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: _start, + firstDate: DateTime(now.year - 5), + lastDate: now, + ); + if (picked == null) return; + final span = _end.difference(_start); + setState(() { + _start = DateTime( + picked.year, picked.month, picked.day, _start.hour, _start.minute); + _end = _start.add(span); + }); + } + + Future _pickTime({required bool isStart}) async { + final at = isStart ? _start : _end; + final picked = await showTimePicker( + context: context, + initialTime: TimeOfDay(hour: at.hour, minute: at.minute), + ); + if (picked == null) return; + setState(() { + if (isStart) { + final span = _end.difference(_start); + _start = DateTime(_start.year, _start.month, _start.day, picked.hour, + picked.minute); + _end = _start.add(span); + } else { + var e = DateTime( + _start.year, _start.month, _start.day, picked.hour, picked.minute); + // Past midnight. A late run that finishes at 00:20 is an ordinary + // session, not an invalid window — the alternative is asking the user + // for a second date to express it. + // + // The NEXT CALENDAR DAY at the picked wall time, built from date + // fields — not +24h of absolute Duration, which lands at 23:20 or + // 01:20 on the two transition nights a year and saves a window an + // hour off the one the user picked. Same trap as `_exportDay`'s + // `dayEnd` in health_export.dart. + if (!e.isAfter(_start)) { + e = DateTime(_start.year, _start.month, _start.day + 1, picked.hour, + picked.minute); + } + _end = e; + } + }); + } + + Future _pickActivity() async { + final picked = await showModalBottomSheet( + context: context, + isScrollControlled: true, + sheetAnimationStyle: sheetMotion(context), + backgroundColor: P.of(context).card, + shape: const RoundedRectangleBorder(borderRadius: R.rXl), + builder: (_) => const _TypeSheet(), + ); + if (picked != null) setState(() => _activity = picked); + } + + Future _save() async { + final repo = repoOf(context); + if (repo == null || _saving || _invalid != null) return; + final nav = Navigator.of(context); + final app = appOf(context); + setState(() { + _saving = true; + _wrote = null; + }); + try { + final r = widget.sessionId == null + ? await repo.logManualWorkout( + startTs: _startSec, endTs: _endSec, type: _activity.typeKey) + : await repo.setWorkoutWindow(widget.sessionId!, + startTs: _startSec, endTs: _endSec); + // Both branches: a new session and a RETIMED one both change what the + // health store should hold for that window (#130). + await HealthExporter.exportWorkoutId( + (r['workout_id'] ?? widget.sessionId) as String?); + // Say what was actually banked. A window with no 1 Hz substrate left + // behind it — anything past the ~3-day retention, or a stretch the band + // was off — is saved UNSCORED, and a screen that pops silently would let + // the athlete believe a strain was computed for it. + app?.insightsRevision.value++; + if (r['unscored'] == true) { + if (!mounted) return; + setState(() { + _saving = false; + _wrote = 'Saved. No heart rate was recorded over that window, so it ' + 'has no strain and no calorie figure — the times are all this ' + 'one carries.'; + }); + return; + } + nav.pop(true); + } on ManualWindowException catch (e) { + if (mounted) setState(() { _saving = false; _wrote = e.error.message; }); + } catch (_) { + if (mounted) { + setState(() { + _saving = false; + _wrote = 'Could not save that — try again.'; + }); + } + } + } + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final bad = _invalid; + final mins = _end.difference(_start).inMinutes; + final retime = widget.sessionId != null; + return Scaffold( + backgroundColor: p.bg, + body: SafeArea( + child: Column(children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: S.x4), + child: NavBar(widget.title, + sub: retime ? 'THE WINDOW, RE-SCORED' : 'YOUR OWN TIMES'), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x10), + children: [ + settingsGroup(c, 'When', [ + if (!retime) + SetRow(_activity.icon, _activity.color, 'Activity', + value: _activity.name, onTap: _pickActivity), + SetRow(LucideIcons.calendar, C.blue, 'Date', + value: dayLabel(_start, now: widget.now), + onTap: _pickDate), + SetRow(LucideIcons.play, C.green, 'Started', + value: + formatMinuteOfDay(_start.hour * 60 + _start.minute), + onTap: () => _pickTime(isStart: true)), + SetRow(LucideIcons.square, C.orange, 'Ended', + value: formatMinuteOfDay(_end.hour * 60 + _end.minute), + sub: _end.day != _start.day ? 'the next morning' : '', + onTap: () => _pickTime(isStart: false)), + SetRow(LucideIcons.timer, C.purple, 'Length', + value: mins > 0 ? '$mins min' : '—', + chevron: false), + ]), + const SizedBox(height: S.x4), + if (bad != null) + StatusCard('That window will not save', bad.message, + icon: LucideIcons.triangleAlert) + else if (_wrote != null) + StatusCard(retime ? 'Times updated' : 'Workout logged', + _wrote!, icon: LucideIcons.circleCheck) + else + StatusCard( + 'Scored from what the band recorded', + 'Strain and calories come from the 1-second heart rate ' + 'inside these times, through the same method the day ' + 'uses. Nothing is estimated from the duration.', + icon: LucideIcons.heartPulse, + ), + const SizedBox(height: S.x4), + BigButton( + _saving + ? 'Saving…' + : retime + ? 'Save the new times' + : 'Log it', + icon: LucideIcons.check, + onTap: bad == null && !_saving ? _save : null, + ), + ], + ), + ), + ]), + ), + ); + } +} + +/// The activity list, searchable. The picker proper (`ActivityPicker`) starts a +/// LIVE session; this one only names a window that has already happened. +class _TypeSheet extends StatefulWidget { + const _TypeSheet(); + @override + State<_TypeSheet> createState() => _TypeSheetState(); +} + +class _TypeSheetState extends State<_TypeSheet> { + String _q = ''; + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final q = _q.trim().toLowerCase(); + final items = q.isEmpty + ? allActivities + : [ + for (final a in allActivities) + if (a.name.toLowerCase().contains(q)) a, + ]; + return SafeArea( + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(c).bottom), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(S.x4, S.x4, S.x4, S.x2), + child: TextField( + autofocus: false, + style: F.body.copyWith(color: p.ink), + onChanged: (v) => setState(() => _q = v), + decoration: InputDecoration( + hintText: 'Search activities', + hintStyle: F.body.copyWith(color: p.ink3), + filled: true, + fillColor: p.card2, + contentPadding: const EdgeInsets.symmetric( + horizontal: S.x4, vertical: S.x3), + border: const OutlineInputBorder( + borderRadius: R.rPill, borderSide: BorderSide.none), + ), + ), + ), + Flexible( + child: items.isEmpty + ? const Padding( + padding: EdgeInsets.all(S.x6), + child: NoData(message: 'No activity by that name'), + ) + : ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x6), + itemCount: items.length, + itemBuilder: (_, i) { + final a = items[i]; + return SetRow(a.icon, a.color, a.name, + chevron: false, + onTap: () => Navigator.of(c).pop(a)); + }, + ), + ), + ]), + ), + ); + } +} + +// ══════════════════ THE WORKOUTS-TAB ENTRY ══════════════════ + +/// Tell the app a session was written, so the Workouts tab re-reads. Null-safe +/// for a golden or a widget test, which have no AppState above them. +void bumpInsights(BuildContext c) => appOf(c)?.insightsRevision.value++; + +/// The AppState, or null when there is none — same shape as [repoOf]. +AppState? appOf(BuildContext c) { + try { + return c.read(); + } catch (_) { + return null; + } +} + +/// The auto-detect switch, read once for every surface that shows a bout. +/// +/// FAILS CLOSED. Unreadable prefs are not permission to render cards the user +/// may have switched off — and hiding them costs nothing, since the rows stay +/// in `workout_suggestions` and reappear the moment the switch can be read. +Future autoDetectOn() async { + try { + return (await NotificationPrefs.load()).autoDetectEnabled; + } catch (_) { + return false; + } +} + +/// Active suggestions for the History tab, or empty when the user has switched +/// auto-detection off. +Future> activeSuggestions() async { + if (!await autoDetectOn()) return const []; + try { + return [ + for (final r in await LocalDb.activeWorkoutSuggestions()) + ?Suggestion.from(r), + ]; + } catch (_) { + return const []; + } +} diff --git a/lib/ui2/screens/nutrition_screen.dart b/lib/ui2/screens/nutrition_screen.dart index dcee426c..990f0a78 100644 --- a/lib/ui2/screens/nutrition_screen.dart +++ b/lib/ui2/screens/nutrition_screen.dart @@ -36,7 +36,7 @@ class NutritionScreen extends StatefulWidget { State createState() => _NutritionScreenState(); } -class _NutritionScreenState extends State { +class _NutritionScreenState extends State with RevisionReload { int _tab = 0; static const _tabs = ['Today', 'Week', 'Goals']; @@ -60,7 +60,15 @@ class _NutritionScreenState extends State { _load(); } + /// Burned calories and water come from the derived store and the journal, + /// both of which are written from elsewhere in the app — an import, a derive + /// after a sync, water logged on Wellness. This tab is never disposed, so + /// without this it showed launch-time figures all day. + @override + void reload() => _load(); + Future _load() async { + final t = beginRead(#nutrition); final app = context.read(); final db = await LocalDb.instance; final week = await NutritionDb.window(db, days: 7); @@ -73,7 +81,7 @@ class _NutritionScreenState extends State { if (daily is Map) burned = Metric.parse(daily['calories_total']); water = (await repo.getJournalMetrics(_date))['water_ml']?.value; } - if (!mounted) return; + if (!stillNewest(#nutrition, t)) return; setState(() { _week = week; _burned = burned; diff --git a/lib/ui2/screens/readiness_detail.dart b/lib/ui2/screens/readiness_detail.dart index e4a1e523..37cb88cf 100644 --- a/lib/ui2/screens/readiness_detail.dart +++ b/lib/ui2/screens/readiness_detail.dart @@ -86,8 +86,9 @@ class ReadinessData { readiness: readiness, // `narrative` and the glass-box `score` are DELIBERATELY not read. Both // belong to the deprecated percentile score, which bands at 70/40 while - // the headline composite bands at 80/60/40 — printing its verdict under - // the ring put "You're ready" directly beneath "45 · Take it easy". The + // the headline composite bands at 61/37/26 (see `readinessBand`) — + // printing its verdict under the ring put "You're ready" directly + // beneath "45 · Take it easy". The // breakdown below IS worth keeping; it is a parallel ranking of the same // four inputs, and the footer now says so. breakdown: [ diff --git a/lib/ui2/screens/rough_night.dart b/lib/ui2/screens/rough_night.dart index 8481ee93..dd56d7d1 100644 --- a/lib/ui2/screens/rough_night.dart +++ b/lib/ui2/screens/rough_night.dart @@ -200,11 +200,23 @@ class RoughNight { /// is not among them: [day] is passed in because the caller already knows which /// night it is looking at. Future loadRoughNight(LocalRepository repo, String day) async { + // MEASURED ONLY. This is a detection, not a chart: the night is called + // rough by comparing it against the spread of the days behind it, and a + // day another vendor's algorithm derived is not the same measurement. + // A picture may splice two algorithms; a statistic may not. + // + // Read ONCE for all four series rather than passing `measuredOnly: true` + // four times: that flag inlines the mask as a subquery whose expensive half + // is a LIKE over whole `day_result` bundles, so four series meant four full + // scans of the user's history for one answer (see LocalDb.importedDates). + final imported = await LocalDb.importedDates(); final series = >{}; for (final key in const [_kRhr, _kRmssd, _kDip, _kTempZ]) { series[key] = { for (final r in await LocalDb.metricSeries(key)) - if (r['date'] is String && r['value'] is num) + if (r['date'] is String && + r['value'] is num && + !imported.contains(r['date'])) r['date'] as String: (r['value'] as num).toDouble(), }; } diff --git a/lib/ui2/screens/wellness_screen.dart b/lib/ui2/screens/wellness_screen.dart index 29bd2179..37d88159 100644 --- a/lib/ui2/screens/wellness_screen.dart +++ b/lib/ui2/screens/wellness_screen.dart @@ -40,16 +40,39 @@ import 'sleep_detail.dart'; class WellnessScreen extends StatefulWidget { const WellnessScreen({super.key}); + /// A deep link asking for one of the sub-tabs — [medsTab] from the dose + /// reminder. -1 for the ordinary case: open where the screen opens. + /// + /// NOT A CONSTRUCTOR ARGUMENT, and it cannot be one. This screen is built by + /// the shell's IndexedStack, which keeps it alive; a tap that lands on a + /// Wellness already on screen rebuilds nothing, so there is no constructor + /// call to carry the index. A request the screen listens for reaches it in + /// BOTH cases — the live state's listener when Wellness is already up, and + /// the fresh state's `initState` when the shell re-keys to switch domain. + /// + /// The shell CLEARS it a frame later rather than the screen consuming it on + /// read: on the re-key path the outgoing state's listener fires first, and a + /// consume-on-read would eat the request before the incoming one existed. + static final ValueNotifier tabRequest = ValueNotifier(-1); + + /// Cycle is LAST on purpose: it is the one tab that can be switched off + /// (Profile → Preferences → Cycle tracking, off by default), and dropping a + /// trailing tab leaves every other tab's index where it was. + /// + /// On the widget rather than the state so [medsTab] can be checked against + /// it — a deep link that lands on the wrong tab because the list was + /// reordered is not a failure anything else would catch. + static const tabs = ['Mind', 'Recovery', 'Habits', 'Medication', 'Cycle']; + + static const int medsTab = 3; + @override State createState() => _WellnessScreenState(); } -class _WellnessScreenState extends State { +class _WellnessScreenState extends State with RevisionReload { int _tab = 0; - /// Cycle is LAST on purpose: it is the one tab that can be switched off - /// (Profile → Preferences → Cycle tracking, off by default), and dropping a - /// trailing tab leaves every other tab's index where it was. - static const _tabs = ['Mind', 'Recovery', 'Habits', 'Medication', 'Cycle']; + static const _tabs = WellnessScreen.tabs; /// Habit consistency is read over a fortnight: long enough that one bad week /// does not read as collapse, short enough to still be about now. @@ -85,10 +108,33 @@ class _WellnessScreenState extends State { @override void initState() { super.initState(); + final t = WellnessScreen.tabRequest.value; + if (t >= 0 && t < _tabs.length) _tab = t; + WellnessScreen.tabRequest.addListener(_onTabRequest); _load(); } + /// A dose reminder tapped while Wellness was already the open domain. + void _onTabRequest() { + final t = WellnessScreen.tabRequest.value; + if (t < 0 || t >= _tabs.length || t == _tab || !mounted) return; + setState(() => _tab = t); + } + + @override + void dispose() { + WellnessScreen.tabRequest.removeListener(_onTabRequest); + super.dispose(); + } + + /// Readiness drivers, insights and journal metrics all move under this tab + /// when a derive or an import runs — and it is one of the three the shell + /// keeps alive forever, so it read them once and stopped. + @override + void reload() => _load(); + Future _load() async { + final t = beginRead(#wellness); final app = context.read(); final repo = app.repo; final db = await LocalDb.instance; @@ -141,7 +187,7 @@ class _WellnessScreenState extends State { ); final history = await LocalDb.journalMetricsByDay(sinceDaysEpoch: since); - if (!mounted) return; + if (!stillNewest(#wellness, t)) return; setState(() { _meds = meds; _slots = slotsForDay(meds, _date, doses, now: DateTime.now()); @@ -204,7 +250,7 @@ class _WellnessScreenState extends State { noun: 'exercises', sub: last == null ? 'Pick one and go' - : 'Last: ${((last['seconds'] as num?) ?? 0) ~/ 60} min', + : 'Last: ${(_reading(last['seconds']) ?? 0) ~/ 60} min', asset: 'mascot_wellness.png', accent: C.domMind, deep: C.teal, @@ -251,8 +297,14 @@ class _WellnessScreenState extends State { // ── MIND ───────────────────────────────────────────────────────────────── Widget _mind(BuildContext c) { - final score = (_stress['stress'] as Map?)?['score'] as num?; - final level = (_stress['stress'] as Map?)?['level'] as String?; + // Same rule as `_recovery`'s coach block, and for the same reason: this + // runs inside `build`, so a leaf of the wrong type here costs the whole + // screen rather than this one card. See [_reading]. + final stress = _stress['stress']; + final score = _reading(stress is Map ? stress['score'] : null); + final level = stress is Map && stress['level'] is String + ? stress['level'] as String + : null; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -348,8 +400,8 @@ class _WellnessScreenState extends State { final needSec = _nested(coachMap, 'need', 'need_sec'); final bedMin = _nested(coachMap, 'bedtime', 'bedtime_min_of_day'); final wakeMin = _nested(coachMap, 'wake', 'wake_min_of_day'); - final napMin = (coachMap?['nap_credit_min'] as num?)?.toDouble(); - final strainMin = (coachMap?['strain_bonus_min'] as num?)?.toDouble(); + final napMin = _reading(coachMap?['nap_credit_min']); + final strainMin = _reading(coachMap?['strain_bonus_min']); final debtH = _nested(_insights, 'sleep_debt', 'debt_hours'); return Column( @@ -397,7 +449,7 @@ class _WellnessScreenState extends State { // nothing, and was printed for every cause the estimator has. ? StatusCard( 'No sleep need yet', - whyFromNote((coachMap?['need'] as Map?)?['note'] as String?) ?? + whyFromNote(_noteOf(coachMap?['need'])) ?? 'Nothing recorded says why there is no need for tonight.', icon: LucideIcons.bedDouble, ) @@ -618,24 +670,48 @@ class _WellnessScreenState extends State { onFix: () => _addMed(c), ) else ...[ - Surface( - pad: const EdgeInsets.symmetric(horizontal: S.x4), - child: Column( - children: [ - for (final s in _slots) - MedRow( - slot: s, - onTap: () => _markDose(s), - // Everything that is not "I took it" lives behind here: - // skipping a dose on purpose, fixing the days it is due, - // and the exit for a course you finished — which used to - // keep coming due every day with nothing but "Delete - // everything" to stop it. - onMore: () => _medActions(c, s), - ), - ], + // A medication with no slot TODAY is not an empty screen. It happens + // for ordinary reasons — the days exclude today, the time had already + // passed when it was added (`slotsForDay` will not invent a slot + // behind you), or the day being viewed is not today — and every one + // of them used to render an empty `Surface`: added a medication, no + // tracker, nothing said. Absence states its reason, and the schedule + // itself is the reason, so it is what gets printed. + if (_slots.isEmpty) ...[ + const StatusCard( + 'Nothing due today', + 'What you take is scheduled for other days or times.', + icon: LucideIcons.pill, + ), + const SizedBox(height: S.x3), + Surface( + pad: const EdgeInsets.symmetric(vertical: S.x2), + child: Column( + children: [ + for (final d in _meds) + for (final sch in d.schedule) _scheduleRow(c, d, sch), + ], + ), + ), + ] else + Surface( + pad: const EdgeInsets.symmetric(horizontal: S.x4), + child: Column( + children: [ + for (final s in _slots) + MedRow( + slot: s, + onTap: () => _markDose(s), + // Everything that is not "I took it" lives behind here: + // skipping a dose on purpose, fixing the days it is due, + // and the exit for a course you finished — which used to + // keep coming due every day with nothing but "Delete + // everything" to stop it. + onMore: () => _medActions(c, s), + ), + ], + ), ), - ), Section( 'Adherence', // An empty denominator is not an adherence of nothing. Consistency @@ -673,6 +749,29 @@ class _WellnessScreenState extends State { ); } + /// One scheduled time that is not due today: what it is, and when it is due. + /// + /// Tapping goes straight to the schedule and NOT to `_medActions` — a slot + /// that is not due cannot be taken or skipped, and offering either would + /// write a dose row for a day the medication was never scheduled on. + /// Changing when it is due is the only honest action here, and it is also + /// the one that brings the tracker back. + Widget _scheduleRow(BuildContext c, MedDef d, MedSchedule sch) { + final slot = MedSlot( + def: d, + date: _date, + slotMin: sch.minuteOfDay, + state: DoseState.upcoming, + ); + return _SheetAction( + LucideIcons.pill, + d.label, + // `timeLabel`, so the two halves of this tab print a time the same way. + sub: '${_daysLabel(sch.days)} · ${slot.timeLabel}', + onTap: () => _editSchedule(c, slot), + ); + } + Future _markDose(MedSlot s) async { final db = await LocalDb.instance; await MedDb.mark( @@ -880,8 +979,41 @@ double? _nested(Map? blk, String key, String field) { final m = blk?[key]; if (m is! Map) return null; final v = m['value']; - if (v is Map) return (v[field] as num?)?.toDouble(); - return (v as num?)?.toDouble(); + return _reading(v is Map ? v[field] : v); +} + +/// A stored leaf as a number this screen can print, or null. +/// +/// TESTED, NEVER CAST, and the finite check is not belt-and-braces. Every +/// caller of this is evaluated inside `_recovery`, which `build` CALLS — so a +/// throw here is not a broken card, it is `WellnessScreen.build` failing, the +/// whole domain replaced by an `ErrorWidget`, and `RenderErrorBox` painting +/// `0xF0C0C0C0` over the page. On a release build that is a flat grey screen +/// with a working nav bar beside it and nothing anywhere that says why. +/// +/// Two ways in, and neither is hypothetical enough to leave open: +/// · `x as num?` tolerates null and NOTHING ELSE, so one leaf stored as a +/// String — an older artifact, a hand-edited backup, an import — throws. +/// · `.round()` throws `UnsupportedError` on NaN and infinity, and every +/// number here is rounded a few lines later (`_hm`, `formatMinuteOfDay`, +/// the strain and nap rows). `1e999` in JSON decodes to `Infinity`. +/// +/// The write seam already learned this: `sanitizeForJson` nulls a non-finite +/// leaf rather than letting `jsonEncode` throw, because "the artifact is a bag +/// of independent metrics, so it must degrade one field at a time". Same rule, +/// read side. A leaf we cannot read is ABSENT — which every branch below +/// already renders honestly — instead of costing the screen. +double? _reading(Object? v) => v is num && v.isFinite ? v.toDouble() : null; + +/// The `note` off a metric envelope, when there is one and it is prose. +/// +/// `(x as Map?)?['note'] as String?` was two unguarded casts on the ABSENCE +/// branch — the one that renders for every account that has no learned sleep +/// need yet, which is the widest audience this screen has. +String? _noteOf(Object? envelope) { + if (envelope is! Map) return null; + final note = envelope['note']; + return note is String ? note : null; } String _hm(double minutes) { diff --git a/lib/ui2/screens/workout_screen.dart b/lib/ui2/screens/workout_screen.dart index 828d8cd6..2dd6cada 100644 --- a/lib/ui2/screens/workout_screen.dart +++ b/lib/ui2/screens/workout_screen.dart @@ -34,7 +34,9 @@ import '../activity/summary.dart'; import '../charts.dart'; import '../profile/profile.dart' show openProfile; import '../grammar.dart'; +import '../revision.dart'; import '../theme.dart'; +import 'log_workout.dart'; import 'start_card.dart'; class WorkoutScreen extends StatefulWidget { @@ -44,56 +46,26 @@ class WorkoutScreen extends StatefulWidget { State createState() => _WorkoutScreenState(); } -class _WorkoutScreenState extends State { +class _WorkoutScreenState extends State with RevisionReload { int tab = 0; static const _tabs = ['For you', 'Activities', 'History']; Future<_WorkoutData>? _load; - /// The revision this screen's data was loaded at. - /// - /// `_load ??=` alone meant the screen read the database exactly once, for the - /// life of the widget — so a session you had just finished was absent from - /// History, "This week", "Tracked" and the weekly load until the app was - /// restarted. `AppState.insightsRevision` already ticks after every derive - /// and now after a session is durably written, so re-reading when it moves - /// covers the manual finish, the gesture path and the Live Activity alike. - int _loadedAt = -1; - - /// Held so the listener can be removed in [dispose], where `context.read` - /// is not safe, and so a second `didChangeDependencies` cannot subscribe - /// twice — `ValueNotifier.addListener` stacks duplicates. - ValueNotifier? _rev; - @override void didChangeDependencies() { super.didChangeDependencies(); - final app = context.read(); - if (!identical(_rev, app.insightsRevision)) { - _rev?.removeListener(_onRevision); - _rev = app.insightsRevision..addListener(_onRevision); - } - if (_load == null) { - _loadedAt = app.insightsRevision.value; - _load = _loadWorkoutData(app); - } - } - - void _onRevision() { - if (!mounted) return; - final app = context.read(); - if (app.insightsRevision.value == _loadedAt) return; - setState(() { - _loadedAt = app.insightsRevision.value; - _load = _loadWorkoutData(app); - }); + // `_load ??=` alone meant the screen read the database exactly once, for + // the life of the widget — so a session you had just finished was absent + // from History, "This week", "Tracked" and the weekly load until the app + // was restarted. `RevisionReload` re-reads when the data moves: the manual + // finish, the gesture path, the Live Activity and an import alike. + _load ??= _loadWorkoutData(context.read()); } @override - void dispose() { - _rev?.removeListener(_onRevision); - super.dispose(); - } + void reload() => + setState(() => _load = _loadWorkoutData(context.read())); @override Widget build(BuildContext c) { @@ -457,26 +429,74 @@ class _WorkoutScreenState extends State { } // ─────────────── HISTORY ─────────────── + + /// Open a screen that can write a session, then re-read. Every write path on + /// this tab goes through here: `RevisionReload` covers the writers that bump + /// `AppState.insightsRevision`, and this covers the ones that do not. + Future _push(BuildContext c, Widget w) async { + await Navigator.of(c).push(MaterialPageRoute(builder: (_) => w)); + if (mounted) reload(); + } + + /// The detector's unreviewed bouts, at the top of History where the sessions + /// they might become are listed. + /// + /// This is the surface that was missing, not a second copy of one: for the + /// whole time the notification was emitted on the `recovery` channel it was + /// dropped by `classOf` and never fired, so these rows accumulated unseen. + /// It fires now (reminders channel, NotifClass.prompt) and lands on the one + /// bout it is about — but only for a bout detected in the last ~2 h, so + /// everything drained later still has to be reviewable here. + List _suggestionCards(BuildContext c, _WorkoutData d) { + if (d.suggestions.isEmpty) return const []; + final n = d.suggestions.length; + return [ + StatusCard( + n == 1 + ? 'One effort we spotted but did not log' + : '$n efforts we spotted but did not log', + 'The band saw sustained work and nothing was started for it. Nothing ' + 'is logged until you say so.', + fix: 'Review ${n == 1 ? 'it' : 'them'}', + icon: LucideIcons.radar, + onFix: () => + _push(c, WorkoutSuggestionScreen(preloaded: d.suggestions)), + ), + const SizedBox(height: S.x5), + ]; + } + + /// Back-log a session the band never saw, or never saw the whole of. + Widget _logPastCard(BuildContext c) => StatusCard( + 'Did something the band missed?', + 'Enter the times yourself and it is scored from the heart rate ' + 'recorded across them, like any other session.', + fix: 'Log a past workout', + icon: LucideIcons.calendarPlus, + onFix: () => _push(c, const LogWorkout()), + ); + List _history(BuildContext c, _WorkoutData d) { final p = P.of(c); if (d.workouts.isEmpty) { return [ + ..._suggestionCards(c, d), StatusCard( 'No sessions recorded yet', - // Auto-detection writes `workout_suggestions` and nothing reads it - // (lib/app.dart:339), so a detected effort never arrives here. The - // string used to tell the user to wait for it. 'Sessions appear here once you start one.', fix: 'Start a workout', onFix: () => _openPicker(c, d), icon: LucideIcons.dumbbell, ), const SizedBox(height: S.x5), + _logPastCard(c), + const SizedBox(height: S.x5), ..._importCard(c, d), ]; } final importedThisWeek = d.weekImported; return [ + ..._suggestionCards(c, d), Row(children: [ Expanded(child: _sum(p, '${d.workoutsTracked ?? d.workouts.length}', 'Tracked')), @@ -506,10 +526,29 @@ class _WorkoutScreenState extends State { ..._morningAfter(p, d), const SizedBox(height: S.x5), for (final w in d.workouts) ...[ - _HistoryRow(w, weightKg: d.weightKg), + _HistoryRow(w, + weightKg: d.weightKg, + // A retime is a re-score over the new window, so it is offered + // only where there is something of ours to re-score: an imported + // row's times belong to the app that recorded it, and this band + // measured nothing across them. + onRetime: w.importedFrom == null && w.id.isNotEmpty + ? () => _push( + c, + LogWorkout( + sessionId: w.id, + start: w.start, + end: w.start.add(w.duration), + activity: w.activity, + title: 'Fix the times', + ), + ) + : null), const SizedBox(height: S.x3), ], const SizedBox(height: S.x3), + _logPastCard(c), + const SizedBox(height: S.x3), ..._importCard(c, d), ]; } @@ -734,7 +773,12 @@ class _QuickTile extends StatelessWidget { class _HistoryRow extends StatelessWidget { final _PastWorkout w; final double? weightKg; - const _HistoryRow(this.w, {this.weightKg}); + + /// Widen or correct this session's window. Null for an imported row, and for + /// a session with no id to retime. + final VoidCallback? onRetime; + + const _HistoryRow(this.w, {this.weightKg, this.onRetime}); Future _open(BuildContext c) async { final nav = Navigator.of(c); @@ -837,6 +881,23 @@ class _HistoryRow extends StatelessWidget { accent: p.on(a.color), ), ], + // The way to correct a window the detector clipped, or one a session + // started late. Nested inside the card's own tap: the inner Pressable + // wins, so the row still opens the summary everywhere else. + if (onRetime != null) ...[ + Divider(color: p.line, height: S.x5), + Pressable( + onTap: onRetime, + semanticLabel: 'Fix the times on this session', + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(LucideIcons.clock, size: 14, color: p.on(C.blue)), + const SizedBox(width: S.x2), + Text('Fix the times', + style: F.cap.copyWith( + color: p.on(C.blue), fontWeight: FontWeight.w600)), + ]), + ), + ], ]), ); } @@ -1521,6 +1582,15 @@ class _WorkoutData { /// which takes months, and that is the honest state until then. final List morningAfter; + /// The detector's active bouts — every "did you work out?" that has neither + /// been logged nor dismissed. Empty when auto-detection is switched off. + /// + /// These rows have been written on every derive since the detector shipped + /// and read by nothing, so a detected effort was invisible unless a + /// notification happened to catch you — and until the emit moved off the + /// dropped `recovery` channel, no notification ever did. + final List suggestions; + /// When the phone's health store last handed us a workout, or null for /// never. It is the whole difference between an Import button and a Refresh /// one — see health_import_state.dart for why the store cannot be asked. @@ -1544,6 +1614,7 @@ class _WorkoutData { this.setHistory = const {}, this.overreach, this.morningAfter = const [], + this.suggestions = const [], this.importedLast, }); @@ -1778,6 +1849,7 @@ Future<_WorkoutData> _loadWorkoutData(AppState app) async { setHistory: history, overreach: overreach, morningAfter: morningAfter, + suggestions: await activeSuggestions(), importedLast: await lastImportAt(HealthImport.workouts), ); } diff --git a/lib/ui2/ui2.dart b/lib/ui2/ui2.dart index 31742fb2..f0ff9ac0 100644 --- a/lib/ui2/ui2.dart +++ b/lib/ui2/ui2.dart @@ -10,5 +10,6 @@ export 'charts.dart'; export 'grammar.dart'; export 'live_hr.dart'; export 'paint_activity.dart'; +export 'revision.dart'; export 'scroll_hint.dart'; export 'theme.dart'; diff --git a/lib/widget/widget_service.dart b/lib/widget/widget_service.dart index ba5c5c36..fcdb5211 100644 --- a/lib/widget/widget_service.dart +++ b/lib/widget/widget_service.dart @@ -11,8 +11,9 @@ import 'package:home_widget/home_widget.dart'; import 'package:flutter/services.dart'; import '../data/local_repository.dart'; +import '../models/metric.dart'; import '../models/payloads.dart'; -import '../ui2/screens/home_screen.dart' show readinessBand; +import '../ui2/screens/home_screen.dart' show hm, readinessBand; class WidgetService { static const _platform = MethodChannel('openstrap/ios_config'); @@ -34,6 +35,22 @@ class WidgetService { /// Android provider class for the Band Battery widget. static const String _batteryAndroidName = 'OpenStrapBatteryWidgetProvider'; + /// The other two faces on the same snapshot: last night's sleep, and the + /// overnight autonomic pair (HRV + resting HR). They read the keys [push] + /// writes, so they reload with it — a widget left holding yesterday because + /// nobody told it to re-read is the failure this list exists to stop. + static const List<(String, String)> _snapshotWidgets = [ + (_iOSName, _androidName), + ('OpenStrapSleepWidget', 'SleepWidgetProvider'), + ('OpenStrapOvernightWidget', 'OvernightWidgetProvider'), + ]; + + static Future _reloadSnapshotWidgets() async { + for (final (ios, android) in _snapshotWidgets) { + await HomeWidget.updateWidget(iOSName: ios, androidName: android); + } + } + static bool _inited = false; static Future init() async { if (_inited) return; @@ -105,11 +122,26 @@ class WidgetService { static Future push(TodayData t) async { try { await init(); - final hrv = t.hrv; + // WHICH NIGHT IS THIS. `getToday` holds the last night that scored over + // until today's settles, so every morning before the first sync the + // overnight block belongs to the night BEFORE last. Home refuses those + // numbers rather than printing them in the today slot (`overnightMetric` + // in lib/ui2/screens/home_screen.dart) — a figure in the today slot is + // read as today's before any caption under it is, and that is even truer + // on a home screen than in the app. So the same refusal happens here, and + // the reason travels in the numbers' place. + final heldWhy = _heldOverWhy(t.status); + Metric ov(Metric m) => heldWhy == null ? m : Metric(note: heldWhy); + + final readiness = ov(t.readiness); + // The DAY's strain, not the night's — Home does not refuse it either + // (home_screen.dart: `strain: metricOf(d('strain'))`). final s = t.strain; - final sleep = t.sleepDuration; + final sleep = ov(t.sleepDuration); final need = t.sleepNeed; - final rhr = t.restingHr; + final eff = ov(t.sleepEfficiency); + final rhr = ov(t.restingHr); + final hrv = heldWhy == null ? t.hrv : null; Future setI(String k, int v) => HomeWidget.saveWidgetData(k, v); @@ -122,8 +154,8 @@ class WidgetService { // answer, and the alternative is a readiness score from last week with // nothing on it to say so. await HomeWidget.saveWidgetData('has_data', !t.isEmpty && !isStale(t)); - // Headline composite Readiness + the three rings (Strain · Sleep · HRV). - final rv = t.readiness.isEmpty ? null : t.readiness.value; + // Headline composite Readiness — the Recovery ring on Home. + final rv = readiness.isEmpty ? null : readiness.value; await setI('readiness', rv == null ? -1 : rv.round()); // The banding, published rather than re-derived. The widget, the Watch // and Siri each carried their own thresholds, so the same 65 read green @@ -159,16 +191,66 @@ class WidgetService { // it empty. await setI('sleep_need_min', need.isEmpty ? -1 : need.value!.round()); await setI('rhr', rhr.isEmpty ? -1 : rhr.value!.round()); + // Sleep efficiency, % — the second number the Sleep widget shows. -1 when + // the night has none, like every other int key here. + await setI('sleep_efficiency', eff.isEmpty ? -1 : eff.value!.round()); + // Why the overnight numbers are missing, for the surfaces that show only + // those (the Overnight widget's HRV and resting HR). '' when they are + // today's own. + await HomeWidget.saveWidgetData('overnight_why', heldWhy ?? ''); await HomeWidget.saveWidgetData( 'coach_line', _coachLine(t.coach), ); + + // THE THREE HOME RINGS, RESOLVED HERE. Recovery · Strain · Sleep, the + // same trio and the same four states as `RingTrio` on Home. + // + // Resolved in Dart rather than three times in Swift, Kotlin and Watch + // Swift for the reason `readiness_tier` already exists: a rule copied + // into four build targets is four rules. Two of these states cannot be + // worked out natively at all — the calibration counts and the pipeline's + // own reason both live in a metric's `note`, which never crossed the App + // Group. Until now a widget drew a blank dimmed circle for BOTH of them, + // so "four more nights and this fills in" and "the band recorded nothing" + // looked identical, forever. + for (final r in [ + rv == null + ? _gapRing('recovery', readiness, 'Not scored') + : _Ring('recovery', + value: '${rv.round()}', + sub: band.label, + frac: rv / 100), + s.isEmpty + // 0–21 is the scale's own ceiling, not a target invented here. + ? _gapRing('strain', s, 'No strain', unit: 'days') + : _Ring('strain', + value: s.value!.toStringAsFixed(1), + sub: 'of 21', + frac: s.value! / 21), + sleep.isEmpty + ? _gapRing('sleep', sleep, 'No sleep', + fallbackWhy: 'No night long enough to score was recorded.') + : _Ring('sleep', + value: hm(sleep.value), + // No computed need means no denominator. The hardcoded 480 in + // the sleep bundle is not this user's need and must never be + // shown as one, so the ring stays open and says so. + sub: need.isEmpty ? 'No target yet' : 'of ${hm(need.value)}', + frac: need.isEmpty || need.value! <= 0 + ? null + : sleep.value! / need.value!), + ]) { + await setI('ring_${r.key}_state', r.state); + await HomeWidget.saveWidgetData('ring_${r.key}_value', r.value); + await HomeWidget.saveWidgetData('ring_${r.key}_sub', r.sub); + await HomeWidget.saveWidgetData('ring_${r.key}_why', r.why); + await HomeWidget.saveWidgetData('ring_${r.key}_frac', r.frac); + } + await setI('updated_at', DateTime.now().millisecondsSinceEpoch ~/ 1000); - await HomeWidget.updateWidget( - iOSName: _iOSName, - androidName: _androidName, - ); + await _reloadSnapshotWidgets(); await _syncWatch(); } catch (_) { /* widgets unavailable / not configured yet — ignore */ @@ -197,14 +279,25 @@ class WidgetService { 'hrv_baseline', 'sleep_min', 'sleep_need_min', + 'sleep_efficiency', 'rhr', 'batt_pct', ]) { await HomeWidget.saveWidgetData(k, -1); } await HomeWidget.saveWidgetData('strain', -1.0); + // The three resolved home rings. `state: 2` with no reason and no arc is + // the honest shape of a wiped database — not a ring reporting zero. + for (final r in const ['recovery', 'strain', 'sleep']) { + await HomeWidget.saveWidgetData('ring_${r}_state', 2); + await HomeWidget.saveWidgetData('ring_${r}_frac', -1.0); + for (final f in const ['value', 'sub', 'why']) { + await HomeWidget.saveWidgetData('ring_${r}_$f', ''); + } + } for (final k in const [ 'readiness_band', + 'overnight_why', 'coach_line', 'batt_name', // A route a Siri intent asked for before the wipe is not a route we @@ -223,10 +316,7 @@ class WidgetService { ]) { await HomeWidget.saveWidgetData(k, false); } - await HomeWidget.updateWidget( - iOSName: _iOSName, - androidName: _androidName, - ); + await _reloadSnapshotWidgets(); await HomeWidget.updateWidget( iOSName: _batteryIOSName, androidName: _batteryAndroidName, @@ -277,10 +367,7 @@ class WidgetService { try { await init(); await HomeWidget.saveWidgetData('theme_dark', dark); - await HomeWidget.updateWidget( - iOSName: _iOSName, - androidName: _androidName, - ); + await _reloadSnapshotWidgets(); // The battery widget shares the Ember/Char surface — retheme it too. await HomeWidget.updateWidget( iOSName: _batteryIOSName, @@ -348,6 +435,45 @@ class WidgetService { return false; } + /// Why the overnight block on offer is not today's, or null when it is. + /// + /// Two absences that are not interchangeable and the same two sentences + /// `staleOvernightNote` uses on Home — one resolves on its own, the other + /// wants a sync. Written out here rather than imported because that helper + /// takes the raw `getToday()` map and this seam is handed the parsed payload. + static String? _heldOverWhy(TodayStatus? s) { + if (s == null || !s.showingPriorOvernight) return null; + return s.overnightBuilding + ? 'Last night is still being worked out.' + : 'Nothing from last night has reached the app yet.'; + } + + /// The absent half of a ring: CALIBRATING when the note says the gate is a + /// baseline still filling — the one absence that is progress and can honestly + /// draw an arc — otherwise the word and the pipeline's own reason. + /// Mirrors `_gap` in lib/ui2/screens/home_screen.dart. + static _Ring _gapRing(String key, Metric m, String word, + {String unit = 'nights', String fallbackWhy = ''}) { + final counts = baselineCountsFromNote(m.note); + if (counts != null) { + return _Ring(key, + state: 1, + value: 'Calibrating', + sub: '${counts.have} of ${counts.need} $unit', + frac: (counts.have / counts.need).clamp(0.0, 1.0)); + } + return _Ring(key, + state: 2, + value: word, + // THE PIPELINE'S REASON FIRST, a sentence written here second, and + // where there is neither the ring says it does not know rather than + // guessing a cause. + why: whyFromNote(m.note, unit: unit) ?? + (fallbackWhy.isNotEmpty + ? fallbackWhy + : 'Nothing recorded says why this is missing.')); + } + static String _coachLine(CoachData? c) { if (c == null) return ''; if (c.plan.isNotEmpty) return c.plan.first.title; @@ -356,3 +482,41 @@ class WidgetService { return c.summary; } } + +/// One home ring as the native surfaces receive it: already-formatted text, a +/// sweep, and which of the four states it is in. Nothing downstream of this +/// decides what a metric means. +class _Ring { + final String key; + + /// 0 measured · 1 calibrating (arc is progress, drawn muted) · 2 absent. + final int state; + + /// The number, or the absence in words. Never a bare dash and never empty: + /// a widget is the surface most likely to be read out of context, and a blank + /// circle says nothing at all. + final String value; + + /// What the number is out of ("of 21", "of 7h 30m", the readiness band), or + /// the calibration count. + final String sub; + + /// The pipeline's own reason, absent rings only. '' otherwise. + final String why; + + /// What to sweep, 0…1 — negative when there is nothing honest to sweep. + final double frac; + + /// CLAMPED HERE, not at the three call sites. Sleeping longer than your need + /// is a fraction above 1, and the native readers take this as a sweep — an + /// arc that laps itself, or a progress bar that draws past its own end, + /// depending on which of the four targets is reading. The number the ring + /// shows is the real one ("8h 10m of 7h 30m"); only the arc is bounded. + _Ring(this.key, + {this.state = 0, + required this.value, + this.sub = '', + this.why = '', + double? frac}) + : frac = frac == null ? -1 : frac.clamp(0.0, 1.0); +} diff --git a/pubspec.lock b/pubspec.lock index f80f52ac..9262bb70 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -652,10 +652,10 @@ packages: dependency: "direct main" description: name: health - sha256: "148ce984c2119f50224b4d187552d751b91aa47f4de8968daf05e6e596ddee50" + sha256: "0432c4e5c5348164adff57e78ca3191c88f0cdf7c2b0d72b6785a6af965177ac" url: "https://pub.dev" source: hosted - version: "11.1.1" + version: "12.2.1" home_widget: dependency: "direct main" description: @@ -924,8 +924,8 @@ packages: dependency: "direct main" description: path: "." - ref: bfea5e56e74f336c3e3d83743123e58da225617d - resolved-ref: bfea5e56e74f336c3e3d83743123e58da225617d + ref: "3174a493472a5e6280b11a0ab11fec82483507e1" + resolved-ref: "3174a493472a5e6280b11a0ab11fec82483507e1" url: "https://github.com/OpenStrap/analytics.git" source: git version: "1.0.0" @@ -933,8 +933,8 @@ packages: dependency: "direct main" description: path: "." - ref: fe3b681a3e9ca76f8a0865339035f949f36f6000 - resolved-ref: fe3b681a3e9ca76f8a0865339035f949f36f6000 + ref: c761f29bcbed73886b1b059dcd9e92e4333574f5 + resolved-ref: c761f29bcbed73886b1b059dcd9e92e4333574f5 url: "https://github.com/OpenStrap/protocol.git" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index ee5192f7..7d76c67b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,7 +70,11 @@ dependencies: # emits R10 beat intervals) — deliberately NOT repinned: edge never reads # `rr_ms` off decodeFrame, so the hop changes no number here and a repin # would drag kAlgoVersion with it for nothing. - ref: fe3b681a3e9ca76f8a0865339035f949f36f6000 + # + # Repinned to the #27 head after its own review pass. NO kAlgoVersion + # bump: the fixes only reject NaN/±inf, which was never a measurement, so + # for any user whose data is valid the output is byte-identical. + ref: c761f29bcbed73886b1b059dcd9e92e4333574f5 openstrap_analytics: git: url: https://github.com/OpenStrap/analytics.git @@ -187,7 +191,14 @@ dependencies: # moved 21664f8 -> bfea5e5 to track it. Diff between the two is two # test-only deprecation-ignore annotations (analytics `lib/` untouched), # so kAlgoVersion needs no bump for this move. - ref: bfea5e56e74f336c3e3d83743123e58da225617d + # + # Repinned again after that branch's own review pass. Still no bump, same + # reason: the change rejects NaN/±inf inputs and nothing else, and a NaN + # was never a reading. `dailyEnergy` returns a nullable record now — it + # abstains rather than billing every waking minute as active when the + # anchors are unusable — which is source-breaking here, not + # number-changing (see `onehz_pipeline` and `_dailyEnergy`). + ref: 3174a493472a5e6280b11a0ab11fec82483507e1 # BLE — flutter_blue_plus is the maintained cross-platform GATT client. flutter_blue_plus: ^1.36.8 @@ -280,7 +291,10 @@ dependencies: # Apple Health (HealthKit, iOS) + Google Health Connect (Android) — export each # day's derived metrics to the platform health store. - health: ^11.1.1 + # >=12.0.0 is not optional: 11.1.1's `_alignValue` lists SLEEP_ASLEEP twice + # and never lists SLEEP_LIGHT, so every light/Core stage write threw on iOS — + # ~70% of a night, every night, and it flipped the day's export to failed too. + health: ^12.2.1 # Open the Health Connect app/settings so the user can grant per-app access # manually (the reliable path when its in-app request dialog is locked out). android_intent_plus: ^5.1.0 diff --git a/test/ai_briefing_test.dart b/test/ai_briefing_test.dart index b2f2e34c..b2fcfb14 100644 --- a/test/ai_briefing_test.dart +++ b/test/ai_briefing_test.dart @@ -224,16 +224,24 @@ void main() { }); test( - 'readinessBand cuts at 40/66 — MUST match the Today ring\'s own ' - 'word-thresholds (score>=66 Push, >=40 Focus, else Recover) or ' - 'the briefing and the ring can disagree again', () { - // Just below/at each ring boundary. - expect(readinessBand(39), 'low'); // ring: "Recover" - expect(readinessBand(40), 'moderate'); // ring: "Focus" - expect(readinessBand(65), 'moderate'); // ring: "Focus" - expect(readinessBand(66), 'good'); // ring: "Push" + 'readinessBand is the ring\'s own band, folded to three words — a ' + 'second set of cuts here is how the briefing and Home came to ' + 'disagree about the same number', () { + // Every ring boundary (26/37/61), from below and at. + expect(readinessBand(0), 'low'); // ring: "Rest today" + expect(readinessBand(25.9), 'low'); // ring: "Rest today" + expect(readinessBand(26), 'low'); // ring: "Take it easy" + expect(readinessBand(36.9), 'low'); // ring: "Take it easy" + expect(readinessBand(37), 'moderate'); // ring: "Steady" + expect(readinessBand(60.9), 'moderate'); // ring: "Steady" + expect(readinessBand(61), 'good'); // ring: "Good to go" expect(readinessBand(100), 'good'); - expect(readinessBand(0), 'low'); + }); + + test('every ring tier has a briefing word', () { + for (var v = 0; v <= 100; v++) { + expect(readinessBand(v), isIn(const ['low', 'moderate', 'good'])); + } }); }); diff --git a/test/band_gestures_test.dart b/test/band_gestures_test.dart new file mode 100644 index 00000000..fe7999ad --- /dev/null +++ b/test/band_gestures_test.dart @@ -0,0 +1,199 @@ +// THE DOUBLE-TAP PICKER — and the one action that made it worth building. +// +// The whole gesture engine shipped without this screen, so the mapping could +// never leave `none`. Two things it may not get wrong: +// * it offers ONLY what this phone reported it can do. An action drawn and +// then silently doing nothing is worse than one never offered; +// * when native answers with nothing, the phone actions are absent AND the +// screen says why, rather than leaving a gap to guess at. +// +// Rendered, not read: this project has paid three times for layout faults that +// inspecting a widget tree does not find. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/gestures/device_action.dart'; +import 'package:openstrap_edge/gestures/gesture_dispatcher.dart'; +import 'package:openstrap_edge/gestures/gesture_settings.dart'; +import 'package:openstrap_edge/ui2/profile/gestures.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +/// What `GestureSettings.bootstrap` builds on a phone whose native side +/// answered: `none`, every in-app action, and the reported native ones. +Set _supported(Set native) => { + DeviceAction.none, + ...DeviceAction.values.where((a) => a.isInApp), + ...native, + }; + +Future _pump( + WidgetTester t, { + required Set supported, + DeviceAction chosen = DeviceAction.none, + ValueChanged? onPick, + double scale = 1, + Brightness brightness = Brightness.light, +}) async { + t.view.physicalSize = Size(390 * 3, 2400 * 3 * scale); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget( + MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: MaterialApp( + theme: buildTheme(brightness), + home: BandGesturesView( + chosen: chosen, + supported: supported, + onPick: onPick, + ), + ), + ), + ); + await t.pumpAndSettle(); +} + +void main() { + group('the picker renders', () { + testWidgets('an iPhone is offered ring and torch, never volume or Tasker', + (t) async { + await _pump(t, + supported: + _supported({DeviceAction.ringPhone, DeviceAction.torch})); + + expect(layoutFaults, isEmpty); + expect(find.text('Ring my phone'), findsOneWidget); + expect(find.text('Flashlight'), findsOneWidget); + expect(find.text('Log water'), findsOneWidget); + expect(find.text('Do nothing'), findsOneWidget); + // Not offerable on iOS, so not drawn. + expect(find.text('Volume up'), findsNothing); + expect(find.text('Broadcast to Tasker'), findsNothing); + expect(find.text('Play / pause music'), findsNothing); + }); + + testWidgets('an Android phone gets the full native list', (t) async { + await _pump(t, + supported: _supported({ + DeviceAction.mediaPlayPause, + DeviceAction.mediaNext, + DeviceAction.mediaPrev, + DeviceAction.volumeUp, + DeviceAction.volumeDown, + DeviceAction.ringPhone, + DeviceAction.torch, + DeviceAction.broadcastToTasker, + })); + + expect(layoutFaults, isEmpty); + for (final label in const [ + 'Play / pause music', + 'Volume up', + 'Ring my phone', + 'Broadcast to Tasker', + 'Log water', + ]) { + expect(find.text(label), findsOneWidget, reason: label); + } + // No "why is this missing" note when nothing is missing. + expect(find.textContaining('could not reach the system'), findsNothing); + }); + + testWidgets('native unreachable: the in-app actions stand, and the ' + 'missing ones state their reason', (t) async { + // capabilities() returned {} — the honest answer is not a bare gap. + await _pump(t, supported: _supported({})); + + expect(layoutFaults, isEmpty); + expect(find.text('Ring my phone'), findsNothing); + expect(find.text('Flashlight'), findsNothing); + // In-app actions act on our own data, so they are unaffected. + expect(find.text('Log water'), findsOneWidget); + expect(find.text('Mark a moment'), findsOneWidget); + expect(find.textContaining('could not reach the system'), findsOneWidget); + // Absence explains itself; it is never a bare dash. + expect(find.text('—'), findsNothing); + }); + + testWidgets('a tap reports the action it is drawn next to', (t) async { + DeviceAction? picked; + await _pump(t, + supported: _supported({DeviceAction.ringPhone}), + onPick: (a) => picked = a); + + await t.tap(find.text('Log water')); + await t.pumpAndSettle(); + expect(picked, DeviceAction.logWater); + + await t.tap(find.text('Ring my phone')); + await t.pumpAndSettle(); + expect(picked, DeviceAction.ringPhone); + }); + + testWidgets('nothing overflows at 3.1x, in either theme', (t) async { + for (final b in Brightness.values) { + await _pump(t, + supported: _supported({DeviceAction.ringPhone, DeviceAction.torch}), + chosen: DeviceAction.logWater, + scale: 3.1, + brightness: b); + expect(layoutFaults, isEmpty, reason: '$b'); + } + }); + }); + + group('log water dispatches', () { + GestureDispatcher build(DeviceAction mapped, {required void Function() water, + void Function()? moment}) { + final s = GestureSettings()..doubleTap = mapped; + return GestureDispatcher( + settings: s, + onLogWater: () async => water(), + onMarkMoment: () async => moment?.call(), + ); + } + + int now() => DateTime.now().millisecondsSinceEpoch ~/ 1000; + + test('a live double-tap mapped to water calls the water handler', () { + var n = 0; + build(DeviceAction.logWater, water: () => n++).onEvent(14, now(), ''); + expect(n, 1); + }); + + test('the 2 s debounce still owns the second tap', () { + var n = 0; + final d = build(DeviceAction.logWater, water: () => n++); + d.onEvent(14, now(), ''); + d.onEvent(14, now(), ''); + expect(n, 1, reason: 'one physical tap can arrive twice from the band'); + }); + + test('a tap drained from flash is too old to pour a glass', () { + var n = 0; + build(DeviceAction.logWater, water: () => n++) + .onEvent(14, now() - 3600, ''); + expect(n, 0); + }); + + test('water is in-app, so it is offerable with no native at all', () { + expect(DeviceAction.logWater.isInApp, isTrue); + expect(DeviceAction.logWater.isNative, isFalse); + // Persisted. Changing it orphans everyone who already picked it. + expect(DeviceAction.logWater.id, 'log_water'); + expect(DeviceActionX.fromId('log_water'), DeviceAction.logWater); + }); + }); +} + +/// Layout faults are reported as caught exceptions, not failed matchers — a +/// negative margin asserting on every build still leaves a findable tree. +List get layoutFaults { + final out = []; + while (true) { + final e = TestWidgetsFlutterBinding.instance.takeException(); + if (e == null) break; + out.add(e as Object); + } + return out; +} diff --git a/test/band_notifications_test.dart b/test/band_notifications_test.dart new file mode 100644 index 00000000..82c60dc3 --- /dev/null +++ b/test/band_notifications_test.dart @@ -0,0 +1,139 @@ +// THE STRAP-BUZZ RELAY, RENDERED — and the label it has to derive. +// +// The point of this screen is that the app ships a notification-listener +// permission (AndroidManifest.xml declares BIND_NOTIFICATION_LISTENER_SERVICE) +// with no way to reach the feature it exists for. So the assertions are about +// reachability and honesty rather than pixels: every state has a control or a +// reason, the permission is explained where it is asked for, and the empty app +// list says why it is empty instead of looking broken. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/notify/notification_relay.dart'; +import 'package:openstrap_edge/ui2/profile/band_notifications.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +Future _pump(WidgetTester t, Widget w, {double scale = 1}) async { + t.view.physicalSize = Size(390 * 3, 2400 * 3 * scale); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: MaterialApp(theme: buildTheme(Brightness.light), home: w), + )); + await t.pumpAndSettle(); +} + +void main() { + group('the relay screen', () { + testWidgets('off is one tap from on, and says what it will do', (t) async { + var toggled; + await _pump( + t, + BandNotificationsView(onEnabled: (v) => toggled = v), + ); + expect(find.text('Buzz on app notifications'), findsOneWidget); + expect(find.text('Off'), findsOneWidget); + await t.tap(find.text('Buzz on app notifications')); + expect(toggled, isTrue); + }); + + testWidgets('on-but-ungranted asks for the permission and says why', ( + t, + ) async { + var asked = false; + await _pump( + t, + BandNotificationsView(enabled: true, onGrant: () => asked = true), + ); + expect(find.text('Grant notification access'), findsOneWidget); + // The claim that has to be on the same card as the request — and it has + // to be the TRUE one. The relay keeps the package names locally, so + // "nothing is stored" was a promise the code did not keep. + expect(find.textContaining('stay on this phone'), findsOneWidget); + expect(find.textContaining('nothing leaves it'), findsOneWidget); + await t.tap(find.text('Grant notification access')); + expect(asked, isTrue); + }); + + testWidgets('an empty app list states its reason, not a bare emptiness', ( + t, + ) async { + await _pump(t, const BandNotificationsView(enabled: true, granted: true)); + expect(find.text('No app has notified you yet'), findsOneWidget); + expect(find.textContaining('the first time each one notifies'), + findsOneWidget); + expect(find.text('—'), findsNothing); + }); + + testWidgets('each seen app is a row you can arm, with its package under it', + (t) async { + final calls = <(String, bool)>[]; + await _pump( + t, + BandNotificationsView( + enabled: true, + granted: true, + apps: const [ + RelayApp('com.whatsapp', on: true), + RelayApp('org.telegram.messenger'), + ], + onApp: (p, v) => calls.add((p, v)), + ), + ); + expect(find.text('Whatsapp'), findsOneWidget); + expect(find.text('com.whatsapp'), findsOneWidget); + expect(find.text('Buzzes'), findsOneWidget); + // The count is the one number that says whether this does anything. + expect(find.text('Apps armed'), findsOneWidget); + expect(find.text('1'), findsOneWidget); + + await t.tap(find.text('Messenger')); + expect(calls, [('org.telegram.messenger', true)]); + }); + + testWidgets('iOS gets a reason, not a dead switch', (t) async { + await _pump(t, const BandNotificationsView(supported: false)); + expect(find.text('This phone cannot do it'), findsOneWidget); + expect(find.text('Buzz on app notifications'), findsNothing); + }); + + testWidgets('nothing overflows at 2x text', (t) async { + await _pump( + t, + const BandNotificationsView( + enabled: true, + granted: true, + apps: [ + RelayApp('com.google.android.apps.messaging', on: true), + RelayApp('com.whatsapp'), + ], + ), + scale: 2, + ); + expect(t.takeException(), isNull); + }); + }); + + group('appLabel', () { + test('takes the last meaningful segment, capitalised', () { + expect(appLabel('com.whatsapp'), 'Whatsapp'); + expect(appLabel('org.telegram.messenger'), 'Messenger'); + expect(appLabel('com.slack'), 'Slack'); + }); + + test('steps over a platform or build segment', () { + // "Android" under every second icon is not a name. + expect(appLabel('com.foo.android'), 'Foo'); + expect(appLabel('com.foo.mobile.lite'), 'Foo'); + }); + + test('never returns empty, whatever the package looks like', () { + expect(appLabel('android'), 'Android'); + expect(appLabel('a'), 'A'); + expect(appLabel('com..bar.'), 'Bar'); + expect(appLabel(''), ''); + }); + }); +} diff --git a/test/baseline_imported_exclusion_test.dart b/test/baseline_imported_exclusion_test.dart new file mode 100644 index 00000000..c8631008 --- /dev/null +++ b/test/baseline_imported_exclusion_test.dart @@ -0,0 +1,143 @@ +// A baseline is a picture of THIS person as measured by THIS device. Another +// vendor's export is a different algorithm's output over a different (or no) +// substrate, so a day that came from one must never set the median a personal +// z-score is taken against. +// +// The law was enforced on the WRITE path only (`LocalDb.isMeasuredDay` stops an +// import overwriting a measured day) and not on the READ path, so imported days +// were feeding the readiness/illness baselines in shipped code. +// +// The trap this test pins down is the OTHER direction. `metric_series_version. +// source` only exists from schema v43 and is never retro-filled, so every day +// written before it reads NULL — a naive `source = 'band'` filter would delete +// the user's whole genuine early history from their own baselines. NULL means +// "the column did not exist yet", and the day bundle behind it still says who +// wrote it, so it is decidable rather than ambiguous. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// A day this device derived from 1 Hz records. +Future _measured(String date, double rhr, {String? source = 'band'}) => + LocalDb.putDayResult( + dayId: date, + algoVersion: 1, + payloadJson: '{"date":"$date"}', + windowJson: '{}', + finalized: true, + source: source, + rhr: rhr, + series: {'rhr': rhr}, + ); + +/// A day an importer wrote — the `imported` flag is the marker both importers +/// have always put in the bundle. +Future _imported( + String date, + double rhr, { + String? source = 'whoop_export', +}) => + LocalDb.putDayResult( + dayId: date, + algoVersion: 1, + payloadJson: '{"date":"$date","imported":true,"source":"whoop_export"}', + windowJson: '{}', + finalized: true, + source: source, + rhr: rhr, + series: {'rhr': rhr}, + ); + +/// Age the stamps back to before the `source` column existed. +Future _forgetSources() async { + final db = await LocalDb.instance; + await db.rawUpdate('UPDATE metric_series_version SET source = NULL'); +} + +Future _clear() async { + final db = await LocalDb.instance; + await db.delete('day_result'); + await db.delete('metric_series'); + await db.delete('metric_series_version'); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_baseline_imported_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + setUp(_clear); + + test('a mixed history baselines on the measured days only', () async { + await _measured('2026-01-01', 50); + await _imported('2026-01-02', 70); + await _measured('2026-01-03', 51); + await _imported('2026-01-04', 71); + + expect(await debugBaselineWindow('rhr'), [50, 51]); + }); + + test('pre-v43 days have NULL source and must NOT be dropped', () async { + await _measured('2026-02-01', 50); + await _measured('2026-02-02', 52); + await _forgetSources(); + + final window = await debugBaselineWindow('rhr'); + expect(window, isNotEmpty, + reason: 'a NULL source is "written before the column existed", not ' + '"foreign" — filtering on source alone deletes real history'); + expect(window, [50, 52]); + }); + + test('a pre-v43 IMPORTED day is still excluded — the bundle says so', + () async { + await _measured('2026-03-01', 50); + await _imported('2026-03-02', 70); + await _forgetSources(); + + expect(await debugBaselineWindow('rhr'), [50]); + }); + + test('importedDates names both eras and nothing else', () async { + await _measured('2026-04-01', 50); // source = 'band' + await _imported('2026-04-02', 70); // source = 'whoop_export' + await _imported('2026-04-03', 71, source: null); // pre-v43 import + await _measured('2026-04-04', 51, source: null); // pre-v43 band day + + expect(await LocalDb.importedDates(), {'2026-04-02', '2026-04-03'}); + }); + + test('trailingSeriesValues excludes imported days by default', () async { + await _measured('2026-05-01', 50); + await _imported('2026-05-02', 70); + await _measured('2026-05-03', 51); + + expect(await LocalDb.trailingSeriesValues('rhr', 28), [50, 51]); + expect(await LocalDb.trailingSeriesValues('rhr', 28, measuredOnly: false), + [50, 70, 51]); + }); + + test('metricSeries keeps imported days for trends, drops them when asked', + () async { + await _measured('2026-06-01', 50); + await _imported('2026-06-02', 70); + + expect((await LocalDb.metricSeries('rhr')).length, 2); + expect((await LocalDb.metricSeries('rhr', measuredOnly: true)).length, 1); + }); +} diff --git a/test/coach_config_key_test.dart b/test/coach_config_key_test.dart index 11933ae8..708de47f 100644 --- a/test/coach_config_key_test.dart +++ b/test/coach_config_key_test.dart @@ -24,13 +24,20 @@ class _FakeKeychain { bool throwOnRead = false; bool throwOnWrite = false; bool hangReads = false; - final List> _hung = []; - - void releaseHung() { - for (final c in _hung) { - if (!c.isCompleted) c.complete(); + bool hangWrites = false; + final List> _hungReads = []; + final List> _hungWrites = []; + + /// Reads and writes release SEPARATELY, so a test can land a save while a + /// read that started before it is still parked inside the plugin — the one + /// ordering the generation counter has to survive. + void releaseHung({bool reads = true, bool writes = true}) { + for (final l in [if (reads) _hungReads, if (writes) _hungWrites]) { + for (final c in l) { + if (!c.isCompleted) c.complete(); + } + l.clear(); } - _hung.clear(); } Future handle(MethodCall call) async { @@ -40,7 +47,7 @@ class _FakeKeychain { if (throwOnRead) throw PlatformException(code: 'keychain'); if (hangReads) { final c = Completer(); - _hung.add(c); + _hungReads.add(c); await c.future; } // A locked keychain does not error — it simply returns nothing, which @@ -49,6 +56,11 @@ class _FakeKeychain { return items[args['key'] as String]; case 'write': if (throwOnWrite) throw PlatformException(code: 'keychain'); + if (hangWrites) { + final c = Completer(); + _hungWrites.add(c); + await c.future; + } items[args['key'] as String] = args['value'] as String; writeOptions.add((args['options'] as Map?) ?? const {}); return null; @@ -259,6 +271,92 @@ void main() { reason: 'a read that predates the save must not apply its result'); }); + // #241 reported `PlatformException(-25299)` and blamed the plugin for adding + // without checking. It does check (check → update → delete + add). What was + // ours is this: `load` writes the key back to upgrade its accessibility, and + // an unawaited startup `load` could have that write in flight while the user + // saved a new one. + test('an in-flight upgrade write never puts the old key back', () async { + // A legacy item: a key in the keychain with no marker beside it, so the + // next load takes the accessibility-upgrade branch — the WRITE inside + // `load` that this is about. + keychain.items['coach_api_key'] = 'sk-old'; + SharedPreferences.setMockInitialValues({'coach_model': 'gpt-4o'}); + + final cfg = CoachConfig(); + // The read returns, the generation check passes, and the upgrade write is + // then in flight — which is the window the generation counter cannot close. + keychain.hangWrites = true; + unawaited(cfg.load()); + await Future.delayed(const Duration(milliseconds: 10)); + + // The user pastes a new key right there. + keychain.hangWrites = false; + final saving = cfg.save(apiKey: 'sk-new', model: 'gpt-4o'); + await Future.delayed(const Duration(milliseconds: 10)); + keychain.releaseHung(); + await saving; + await Future.delayed(const Duration(milliseconds: 20)); + + expect(keychain.items['coach_api_key'], 'sk-new', + reason: 'the upgrade write must not resurrect the superseded key'); + expect(cfg.apiKey, 'sk-new'); + }); + + // The other half of the same race, and the one the single generation bump + // could not see: a load that starts DURING a save captures the already- + // incremented generation, so its check passes — and its read, taken while + // the write was still inside the plugin, comes back empty. Trusted, that + // empty is treated as proof there is no key. + test('a trusted load straddling a save does not erase the key', () async { + final cfg = CoachConfig(); + + // The save's write parks inside the plugin. + keychain.hangWrites = true; + final saving = cfg.save(apiKey: 'sk-new', model: 'gpt-4o'); + await Future.delayed(const Duration(milliseconds: 10)); + + // Resume brings the app forward and re-reads the key. It starts here — + // after the save began — and its read parks too. `locked` so it comes back + // empty rather than seeing the key the save is about to land. + keychain.hangReads = true; + keychain.locked = true; + final loading = cfg.load(trusted: true); + await Future.delayed(const Duration(milliseconds: 10)); + + // The save lands FIRST, completely: key in the keychain, marker true. + keychain.releaseHung(reads: false); + await saving; + expect(cfg.apiKey, 'sk-new'); + + // Now the straddling read finally answers, and it answers "nothing". + keychain.releaseHung(); + await loading; + + expect(cfg.apiKey, 'sk-new', + reason: 'a read taken before the write landed proves nothing about it'); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('coach_api_key_present'), isTrue, + reason: 'a false marker files a stored key as ABSENT, not unreadable, ' + 'and puts the resume retry to sleep with it'); + }); + + test('a hung keychain read does not block a save', () async { + final cfg = CoachConfig(); + keychain.hangReads = true; + unawaited(cfg.load()); + await Future.delayed(const Duration(milliseconds: 10)); + + // A keystore read can hang outright. Save has to get through anyway — this + // is why only the writes are serialized and not the whole of `load`. + await cfg.save(apiKey: 'sk-new', model: 'gpt-4o').timeout( + const Duration(seconds: 2), + onTimeout: () => fail('save blocked behind a hung read'), + ); + expect(cfg.apiKey, 'sk-new'); + keychain.releaseHung(); + }); + test('a keychain that refuses the write does not report success', () async { final cfg = CoachConfig(); keychain.throwOnWrite = true; diff --git a/test/daily_energy_consistency_test.dart b/test/daily_energy_consistency_test.dart index ab748931..8f95bc4f 100644 --- a/test/daily_energy_consistency_test.dart +++ b/test/daily_energy_consistency_test.dart @@ -51,7 +51,7 @@ final _dayHr = [ void main() { group('DerivationEngine.wakeDayEnergy', () { test('active calories net out the basal minute, not double-count it', () { - final e = DerivationEngine.wakeDayEnergy(_dayHr, profile: _profile, deviceFamily: 'gen4'); + final e = DerivationEngine.wakeDayEnergy(_dayHr, profile: _profile, restingHr: 55, deviceFamily: 'gen4'); expect(e, isNotNull); @@ -72,7 +72,7 @@ void main() { }); test('total is the full-day basal floor plus the active surplus', () { - final e = DerivationEngine.wakeDayEnergy(_dayHr, profile: _profile, deviceFamily: 'gen4')!; + final e = DerivationEngine.wakeDayEnergy(_dayHr, profile: _profile, restingHr: 55, deviceFamily: 'gen4')!; expect(e.basal, closeTo(_bmrDay, 0.5)); expect(e.total, closeTo(e.basal + e.active, 0.001)); @@ -82,7 +82,7 @@ void main() { // health_export writes BASAL_ENERGY_BURNED as calories_total - calories. // When the two came from different implementations that subtraction // silently produced a basal figure that was too low. - final e = DerivationEngine.wakeDayEnergy(_dayHr, profile: _profile, deviceFamily: 'gen4')!; + final e = DerivationEngine.wakeDayEnergy(_dayHr, profile: _profile, restingHr: 55, deviceFamily: 'gen4')!; expect(e.total - e.active, closeTo(e.basal, 0.001)); }); @@ -90,7 +90,7 @@ void main() { test('a day spent entirely below the flex point reads as pure basal', () { final quiet = [for (var i = 0; i < 1440; i++) 55.0]; - final e = DerivationEngine.wakeDayEnergy(quiet, profile: _profile, deviceFamily: 'gen4')!; + final e = DerivationEngine.wakeDayEnergy(quiet, profile: _profile, restingHr: 55, deviceFamily: 'gen4')!; expect(e.active, 0.0); expect(e.total, closeTo(_bmrDay, 0.5)); @@ -103,7 +103,7 @@ void main() { DerivationEngine.wakeDayEnergy( _dayHr, profile: const Profile(weightKg: 72, sex: 'm'), - deviceFamily: 'gen4', + restingHr: 55, deviceFamily: 'gen4', ), isNull, reason: 'age is a Keytel term', @@ -112,7 +112,7 @@ void main() { DerivationEngine.wakeDayEnergy( _dayHr, profile: const Profile(ageYears: 34, sex: 'm'), - deviceFamily: 'gen4', + restingHr: 55, deviceFamily: 'gen4', ), isNull, reason: 'body mass is a Keytel term', @@ -121,7 +121,7 @@ void main() { DerivationEngine.wakeDayEnergy( _dayHr, profile: const Profile(ageYears: 34, weightKg: 72), - deviceFamily: 'gen4', + restingHr: 55, deviceFamily: 'gen4', ), isNull, reason: 'the formula has a different constant per sex', @@ -136,7 +136,7 @@ void main() { // in moves a scalar that is persisted to `day_result` and exported to // Apple Health. const noHeight = Profile(ageYears: 34, weightKg: 72, sex: 'm'); - expect(DerivationEngine.wakeDayEnergy(_dayHr, profile: noHeight, deviceFamily: 'gen4'), isNull); + expect(DerivationEngine.wakeDayEnergy(_dayHr, profile: noHeight, restingHr: 55, deviceFamily: 'gen4'), isNull); }); test('a stand-in height would move ACTIVE, not just the basal floor', () { @@ -147,9 +147,9 @@ void main() { final hr = [for (var i = 0; i < 600; i++) 130.0]; final s = - DerivationEngine.wakeDayEnergy(hr, profile: short, deviceFamily: 'gen4')!; + DerivationEngine.wakeDayEnergy(hr, profile: short, restingHr: 55, deviceFamily: 'gen4')!; final t = - DerivationEngine.wakeDayEnergy(hr, profile: tall, deviceFamily: 'gen4')!; + DerivationEngine.wakeDayEnergy(hr, profile: tall, restingHr: 55, deviceFamily: 'gen4')!; expect((s.active - t.active).abs(), greaterThan(100.0)); expect((s.total - t.total).abs(), greaterThan(100.0)); @@ -160,7 +160,7 @@ void main() { // the same claim as "this day burned exactly your BMR". expect( DerivationEngine.wakeDayEnergy(const [], - profile: _profile, deviceFamily: 'gen4'), + profile: _profile, restingHr: 55, deviceFamily: 'gen4'), isNull, ); }); @@ -177,7 +177,11 @@ void main() { // A 70-year-old is the sharpest case for the wake-vs-whole-day question: // `dailyEnergy`'s flex gate is 0.50 x Tanaka HRmax = 104 - 0.35*age, so at // 70 it sits at 79.5 bpm — under a perfectly ordinary sleeping heart rate. - const older = Profile(ageYears: 70, weightKg: 80, heightCm: 175, sex: 'm'); + const older = Profile( + ageYears: 70, weightKg: 80, heightCm: 175, sex: 'm', + // The active gate is a %HRR flex point now, so the lower reserve anchor + // is a term in it — no resting HR, no gate, no figure. + restingHrManual: 55); // Mifflin (male): 10*80 + 6.25*175 - 5*70 + 5 = 1548.75 kcal/day const olderBasalPerMin = 1548.75 / 1440.0; @@ -309,7 +313,11 @@ void main() { bundle: bundle, scalars: scalars, daySub: daySub, - profile: const Profile(ageYears: 70, weightKg: 80, heightCm: 175), + profile: const Profile( + ageYears: 70, + weightKg: 80, + heightCm: 175, + restingHrManual: 55), sleepOnsetSec: sleepOnset, sleepOffsetSec: sleepOffset, dayStartSec: daySub.tsSec.first, @@ -374,6 +382,10 @@ void main() { 'sex': 'm', 'weight_kg': 72, 'height_cm': 178, + // The active gate is a %HRR flex point now, so the lower reserve + // anchor is a term in it. This day has no sleep, so the manual one + // is the only resting HR there is. + 'resting_hr': 55, }), isNotNull, ); diff --git a/test/derive_result_protection_test.dart b/test/derive_result_protection_test.dart index 33fed1ff..de32bdd8 100644 --- a/test/derive_result_protection_test.dart +++ b/test/derive_result_protection_test.dart @@ -346,4 +346,39 @@ void main() { expect(outcome.partial, isTrue); expect(outcome.finalized, isFalse); }); + + // 3. A re-stage over LESS substrate than the last one had (#242). A day + // re-stages on every pass for its first 48 h, and pruning can take the + // substrate away between passes — so the same night comes back shorter and + // replaced the good one. "It got fixed, then a few syncs later it went + // back." + group('a night never re-stages shorter', () { + SleepSessionCandidate night(num? tstSec) => SleepSessionCandidate( + dayId: '2026-08-19', + confidence: 0.8, + flags: const [], + sleepJson: {'tst_sec': ?tstSec}, + hypnoStages: const [], + sleepOnsetSec: 1000, + sleepOffsetSec: 2000, + ); + + test('a shorter re-stage loses to the banked night', () { + expect(DerivationEngine.isRicherSleep(night(27000), night(9000)), isTrue); + }); + + test('a longer re-stage wins — the band handed over more of it', () { + expect(DerivationEngine.isRicherSleep(night(9000), night(27000)), isFalse); + }); + + test('an identical re-stage writes, so equal is not richer', () { + expect(DerivationEngine.isRicherSleep(night(27000), night(27000)), isFalse); + }); + + test('a night beats no night, and no night never beats one', () { + expect(DerivationEngine.isRicherSleep(night(27000), night(null)), isTrue); + expect(DerivationEngine.isRicherSleep(night(null), night(27000)), isFalse); + expect(DerivationEngine.isRicherSleep(night(null), night(null)), isFalse); + }); + }); } diff --git a/test/health_sleep_export_test.dart b/test/health_sleep_export_test.dart index 045f0125..56e8a184 100644 --- a/test/health_sleep_export_test.dart +++ b/test/health_sleep_export_test.dart @@ -248,6 +248,51 @@ void main() { ); }); + test('Apple delete scope never names the Health Connect envelope', () { + final types = healthDeleteTypes(isApplePlatform: true); + + // SLEEP_SESSION is Health-Connect-only. On iOS the plugin resolves an + // unknown key to bodyMass, queries a type we never asked for, and its + // error path never calls back — `delete()` hangs and the day's export + // stalls behind it. Same failure #239/#225 fixed on the write side. + expect(types, isNot(contains(HealthDataType.SLEEP_SESSION))); + expect(types, contains(HealthDataType.SLEEP_IN_BED)); + expect( + types, + containsAll([ + HealthDataType.SLEEP_DEEP, + HealthDataType.SLEEP_REM, + HealthDataType.SLEEP_LIGHT, + HealthDataType.SLEEP_AWAKE, + ]), + ); + }); + + test('the sleep delete covers the pre-midnight half of the night', () { + final dayStart = DateTime(2026, 8, 5); + final dayEnd = DateTime(2026, 8, 6); + final night = normalizeHealthSleepSession(_overnightBundle())!; + + // Onset is 2026-08-04 23:55 — OUTSIDE the day that owns this night. A + // day-scoped delete leaves it behind and every retry appends another + // copy, which is the truncation and the duplicate bars both. + expect(night.start.isBefore(dayStart), isTrue); + + final window = sleepCleanupWindow( + dayStart: dayStart, + dayEnd: dayEnd, + night: night, + ); + expect(window.start, night.start); + expect(window.end, dayEnd, reason: 'the night ends well inside the day'); + + // No night to write — nothing to widen for, and the day window still has + // to be swept so stale samples from an earlier export go. + final none = sleepCleanupWindow(dayStart: dayStart, dayEnd: dayEnd); + expect(none.start, dayStart); + expect(none.end, dayEnd); + }); + test('Apple and Android share one hypnogram stage vocabulary', () { expect(healthSleepStageOf('wake'), HealthSleepStage.awake); expect(healthSleepStageOf('awake'), HealthSleepStage.awake); diff --git a/test/import_container_test.dart b/test/import_container_test.dart index eb3e78d5..f8915ed9 100644 --- a/test/import_container_test.dart +++ b/test/import_container_test.dart @@ -490,4 +490,128 @@ void main() { } }); }); + + // The other half of #160/#199: the file was classified correctly here and + // then handed to the wrong importer anyway, because the router read the + // extension. What a file HOLDS decides now. + group('isNoopExport ignores the extension', () { + test('a NOOP raw-sensor CSV is claimed whatever it is called', () async { + final path = await write( + 'export (1).csv', + utf8.encode('unix_s,iso_utc,stream,hr_bpm\n1754000000,x,hr,61\n'), + ); + expect(await isNoopExport(path), isTrue); + }); + + test('a WHOOP My Data ZIP is NOT a NOOP export', () async { + final path = await write( + 'my_whoop_data.zip', + _zipOf({ + 'physiological_cycles.csv': 'Cycle start time,Recovery score %\n', + 'sleeps.csv': 'Cycle start time,Sleep performance %\n', + 'workouts.csv': 'Workout start time,Activity name\n', + }), + ); + expect(await isNoopExport(path), isFalse); + }); + + test('a WHOOP CSV on its own is NOT a NOOP export', () async { + final path = await write('sleeps.csv', + utf8.encode('Cycle start time,Sleep performance %\n2026-08-01,88\n')); + expect(await isNoopExport(path), isFalse); + }); + + test('a .noopbak is claimed by its database member', () async { + final path = await write( + 'backup.noopbak', + _zipOf({'noop-backup.sqlite': 'SQLite format 3\x00 rows'}), + ); + expect(await isNoopExport(path), isTrue); + }); + + test('a loose database is claimed by its magic', () async { + final path = + await write('unnamed', utf8.encode('SQLite format 3\x00 rows')); + expect(await isNoopExport(path), isTrue); + }); + + test('a single CSV zipped by hand is still a NOOP export', () async { + final path = await write('archive.zip', + _zipOf({'raw_sensor.csv': 'unix_s,iso_utc,stream\n1,x,hr\n'})); + expect(await isNoopExport(path), isTrue); + }); + + test('junk is claimed by nobody here', () async { + final path = await write('junk.bin', [0x00, 0x01, 0x02, 0x03]); + expect(await isNoopExport(path), isFalse); + }); + }); + + // The router judged byte ZERO; the reader skips blank and `#` lines first and + // falls back to the documented positional layout when there is no header at + // all. Anything the reader would take, the router has to route — otherwise a + // valid export goes to the vendor importer and is refused with a confident + // wrong message, which is #160/#199 all over again. + group('the router uses the reader\'s own first-record rule', () { + test('a leading comment does not lose the file', () async { + final path = await write( + 'export.csv', + utf8.encode('# noop raw sensor export\n# v3\n' + 'unix_s,iso_utc,stream,hr_bpm\n1754000000,x,hr,61\n'), + ); + expect(await isNoopExport(path), isTrue); + }); + + test('leading blank lines do not lose the file', () async { + final path = await write( + 'export.csv', + utf8.encode('\n\r\n\nunix_s,iso_utc,stream,hr_bpm\n1754000000,x,hr,61\n'), + ); + expect(await isNoopExport(path), isTrue); + }); + + test('a headerless export is claimed, as the reader claims it', () async { + // The positional layout in `NoopImporter._defaultCols`, no header row. + final path = await write( + 'raw.csv', + utf8.encode('1754000000,2026-08-01T00:00:00Z,hr,61,,,,,,,,,,,,,\n' + '1754000001,2026-08-01T00:00:01Z,hr,62,,,,,,,,,,,,,\n'), + ); + expect(await isNoopExport(path), isTrue); + }); + + test('a vendor CSV behind a comment is still not ours', () async { + final path = await write( + 'sleeps.csv', + utf8.encode('# exported 2026-08-01\n' + 'Cycle start time,Sleep performance %\n2026-08-01,88\n'), + ); + expect(await isNoopExport(path), isFalse); + }); + + test('a comma-heavy row that is not an epoch is not ours', () async { + // The headerless signature is structural on purpose: 17 columns is not + // enough, column zero has to be unix seconds. + final path = await write( + 'other.csv', + utf8.encode('${List.filled(17, 'x').join(',')}\n'), + ); + expect(await isNoopExport(path), isFalse); + }); + + test('an all-comment file claims nothing', () async { + final path = await write('notes.csv', utf8.encode('# nothing\n# here\n')); + expect(await isNoopExport(path), isFalse); + }); + + test('a header past the read ceiling is not guessed at', () async { + // 4 KB of comments, then the header. Bounded read means bounded answer: + // it declines rather than materialising the file to be sure. + final path = await write( + 'export.csv', + utf8.encode('${'# pad\n' * 1200}unix_s,iso_utc,stream\n1754000000,x,hr\n'), + ); + expect(await isNoopExport(path), isFalse); + }); + }); } diff --git a/test/import_routing_test.dart b/test/import_routing_test.dart new file mode 100644 index 00000000..69a4ba73 --- /dev/null +++ b/test/import_routing_test.dart @@ -0,0 +1,152 @@ +// Issues #160 / #199: the onboarding router picked an importer by FILE +// EXTENSION, and got it wrong in both directions at once. +// +// • NOOP's Android "raw sensor CSV" export is a plain `.csv`, so it went to +// the vendor importer, which told the user to re-download it with WHOOP +// set to English. (That is the exact file attached to #160.) +// • A WHOOP "My Data" export is a `.zip` of CSVs — the shape WHOOP actually +// gives you — so it went to the NOOP importer, which refused it for +// holding too many CSVs. +// +// Both files were fine. Both were refused, each with advice meant for the +// other one. These tests drive the real `runImport` and assert WHICH importer +// each shape reaches, so neither direction can come back. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/onboarding/welcome.dart'; + +/// Records where `runImport` sent each path instead of importing it. Every +/// override replaces work that needs a database and a derivation engine; the +/// routing decision above them is what is under test. +class _RoutingSpy extends AppState { + _RoutingSpy() : super.forTesting(); + + final noop = []; + final vendor = []; + + @override + Future importNoopCsv(String path, + {void Function(int days)? onProgress}) async { + noop.add(path); + return 1; + } + + @override + Future importWhoopCsvs(List paths, + {void Function(int days)? onProgress}) async { + vendor.addAll(paths); + return 1; + } +} + +List _zipOf(Map members) { + final a = Archive(); + members.forEach((name, body) { + final bytes = utf8.encode(body); + a.addFile(ArchiveFile(name, bytes.length, bytes)); + }); + return ZipEncoder().encode(a); +} + +/// The header row a real NOOP raw-sensor export starts with (NOOP 9.1/9.2, as +/// observed on the #160 attachment). +const _noopCsv = 'unix_s,iso_utc,stream,hr_bpm,rr_ms,grav_x,grav_y,grav_z\n' + '1754000000,2026-08-01T00:00:00Z,hr,61,,,,\n'; + +/// A WHOOP "My Data" export, which is several named CSVs in one archive. +const _whoopZipMembers = { + 'physiological_cycles.csv': 'Cycle start time,Recovery score %\n', + 'sleeps.csv': 'Cycle start time,Sleep performance %\n', + 'workouts.csv': 'Workout start time,Activity name\n', + 'journal_entries.csv': 'Cycle start time,Question text\n', +}; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tmp; + setUp(() async { + tmp = await Directory.systemTemp.createTemp('import_routing_test_'); + }); + tearDown(() async { + if (tmp.existsSync()) await tmp.delete(recursive: true); + }); + + Future write(String name, List bytes) async { + final f = File('${tmp.path}/$name'); + await f.writeAsBytes(bytes); + return f.path; + } + + test('a NOOP raw-sensor CSV goes to the NOOP importer, not the vendor one', + () async { + final app = _RoutingSpy(); + final path = await write('noop-export.csv', utf8.encode(_noopCsv)); + + final out = await runImport(app, [path]); + + expect(app.noop, [path]); + expect(app.vendor, isEmpty, + reason: 'this is the #160 file — the vendor importer answers it with ' + '"re-download it with WHOOP set to English"'); + expect(out.source, contains('Raw sensor export')); + }); + + test('a WHOOP My Data ZIP goes to the vendor importer, not the NOOP one', + () async { + final app = _RoutingSpy(); + final path = await write('my_whoop_data.zip', _zipOf(_whoopZipMembers)); + + final out = await runImport(app, [path]); + + expect(app.vendor, [path]); + expect(app.noop, isEmpty, + reason: 'the NOOP importer refuses this for holding too many CSVs'); + expect(out.source, contains('Vendor CSV export')); + }); + + test('a .noopbak still routes to NOOP once the name stops deciding', + () async { + final app = _RoutingSpy(); + // The real shape: a ZIP whose member is NOOP's own SQLite database. The + // magic is what identifies it, so the bytes have to be real. + final path = await write( + 'backup.noopbak', + _zipOf({'noop-backup.sqlite': 'SQLite format 3\x00 and then some rows'}), + ); + + await runImport(app, [path]); + + expect(app.noop, [path]); + expect(app.vendor, isEmpty); + }); + + test('a NOOP CSV keeps routing to NOOP when someone zips it first', () async { + final app = _RoutingSpy(); + final path = + await write('noop.zip', _zipOf({'raw_sensor.csv': _noopCsv})); + + await runImport(app, [path]); + + expect(app.noop, [path]); + expect(app.vendor, isEmpty); + }); + + test('a mixed selection reaches both importers', () async { + final app = _RoutingSpy(); + final noopPath = await write('noop-export.csv', utf8.encode(_noopCsv)); + final whoopPath = await write('whoop.zip', _zipOf(_whoopZipMembers)); + + final out = await runImport(app, [noopPath, whoopPath]); + + expect(app.noop, [noopPath]); + expect(app.vendor, [whoopPath]); + expect(out.source, contains('Raw sensor export')); + expect(out.source, contains('Vendor CSV export')); + }); +} diff --git a/test/live_rescore_calorie_parity_test.dart b/test/live_rescore_calorie_parity_test.dart index 0dd760a5..2e4b1beb 100644 --- a/test/live_rescore_calorie_parity_test.dart +++ b/test/live_rescore_calorie_parity_test.dart @@ -31,6 +31,7 @@ // The last four cases in this file are each one of those. import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_analytics/onehz.dart' as ana; import 'package:openstrap_edge/compute/manual_session.dart'; import 'package:openstrap_edge/compute/profile.dart'; import 'package:openstrap_edge/state/app_state.dart'; @@ -213,28 +214,37 @@ void main() { test('a sawtooth hovering at the gate does not collapse to one side', () { // The worst case for minute-mean gating, and an entirely ordinary heart - // rate: 30 s at 94 and 30 s at 93 against a 93.76 gate. Every minute mean - // is 93.5, just under, so the whole session reads as rest. + // rate: half a minute a beat above the gate, half a minute a beat below. + // Every minute mean lands under it, so the whole session reads as rest. + // + // The gate is ASKED FOR, not written down. It used to be a fraction of + // HRmax and is now a fraction of heart-rate reserve, and this test failed + // the day that changed — it was still straddling a boundary that had moved + // 13 bpm away, which is a test pinning arithmetic instead of behaviour. + // `!` — the fixture's anchors are a real pair, so a null here would mean + // the gate stopped being definable for ordinary numbers. + final gate = ana.Calories.activeGateHr(_hrMax, _restingHr)!; + final above = gate.ceil() + 1; + final below = gate.floor() - 1; final sawtooth = [ for (var block = 0; block < 10; block++) ...[ - for (var i = 0; i < 30; i++) 94, - for (var i = 0; i < 30; i++) 93, + for (var i = 0; i < 30; i++) above, + for (var i = 0; i < 30; i++) below, ], ]; final live = _run(sawtooth); expect(live.calories, closeTo(_rescore(sawtooth), 0.25)); - // 300 s * activeKcalPerS(94) + 300 s * restingRate - // = 300 * 0.1010958 + 300 * 0.0198397 = 36.28 kcal - expect(live.calories, closeTo(36.28, 0.25)); - // Minute-mean gating bills all 600 s at the resting rate: 11.90 kcal, i.e. - // 1.19 kcal/min where the re-score says 3.63 — about 146 kcal adrift over a - // zone-2 hour, off a stream that never looks unusual. + + // What minute-mean gating would have billed: all 600 s at the resting rate, + // because no minute's mean ever clears the gate. Derived from the same + // estimator rather than stated, so it tracks the gate too. + final allRest = _rescore([for (var i = 0; i < 600; i++) below]); expect( live.calories, - greaterThan(20.0), - reason: 'billing a 94 bpm half-minute as rest is the bug this pins', + greaterThan(allRest * 1.5), + reason: 'billing an above-gate half-minute as rest is the bug this pins', ); }); diff --git a/test/log_prompts_test.dart b/test/log_prompts_test.dart new file mode 100644 index 00000000..fd8b129f --- /dev/null +++ b/test/log_prompts_test.dart @@ -0,0 +1,334 @@ +// The two prompts that ASK you to log something — medication and the daily +// check-in — as pure policy. No plugins: nothing here schedules, it only +// decides what would be scheduled and when. +// +// Three properties are pinned, because each one is a bug this app has already +// shipped: +// +// · every new id is on NotificationService.schedulableIds. A slot absent +// from that list is dropped silently at the gate and never fires once — +// which is what happened to the movement nudge for its whole life. +// · a prompt does not fire for something already logged. A reminder to take +// a pill already taken is how people turn every notification off. +// · the tap route resolves to a real destination. The audit found one +// notification saying "tap to log it" that landed on a screen which did +// not exist, and another whose route mapped to null. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/app.dart'; +import 'package:openstrap_edge/ui2/app_shell.dart' show ShellDomain; +import 'package:openstrap_edge/ui2/screens/wellness_screen.dart' + show WellnessScreen; +import 'package:openstrap_edge/data/day_label.dart'; +import 'package:openstrap_edge/data/journal_fields.dart'; +import 'package:openstrap_edge/data/med_store.dart'; +import 'package:openstrap_edge/notify/notification_center.dart'; +import 'package:openstrap_edge/notify/notification_prefs.dart'; +import 'package:openstrap_edge/notify/notification_service.dart'; +import 'package:openstrap_edge/notify/tap_router.dart'; + +/// A definition that existed long before any day under test, so `slotsForDay`'s +/// created-at bound never trims a slot out from under a case. +MedDef _def(String key, List schedule) => MedDef( + key: key, + label: key, + doseValue: 1000, + doseUnit: 'IU', + schedule: schedule, + createdAt: DateTime(2020, 1, 1).millisecondsSinceEpoch, + ); + +/// One `med_dose` row in the shape `MedDb.dosesForDay` returns. +Map>> _doses( + String medKey, + int slotMin, { + bool taken = false, + bool skipped = false, +}) => + { + medKey: { + slotMin: { + 'taken_ts': taken ? 1 : null, + 'skipped': skipped ? 1 : 0, + } + } + }; + +void main() { + group('the scheduler allow-list', () { + test('takes the check-in and the whole medication band', () { + expect(NotificationService.maySchedule(NotificationService.idCheckIn), + isTrue); + for (var i = 0; i < NotificationService.maxMedSlots; i++) { + expect( + NotificationService.maySchedule(NotificationService.idMedsBase + i), + isTrue, + reason: 'med slot $i'); + } + }); + + test('and still refuses the ids either side of the band', () { + expect(NotificationService.maySchedule(NotificationService.idMedsBase - 1), + isFalse); + expect( + NotificationService.maySchedule( + NotificationService.idMedsBase + NotificationService.maxMedSlots), + isFalse); + }); + + test('the bands are disjoint from the hydration one', () { + for (var i = 0; i < NotificationService.maxMedSlots; i++) { + expect(NotificationService.isWaterSlot(NotificationService.idMedsBase + i), + isFalse); + } + expect(NotificationService.isMedSlot(NotificationService.idCheckIn), isFalse); + expect(NotificationService.isMedSlot(NotificationService.idStillness), isFalse); + }); + }); + + group('the check-in knows when it has already been answered', () { + test('any rating counts, and one is enough', () { + for (final f in kJournalFields.where((f) => f.isRating)) { + expect( + NotificationCenter.checkInDone({f.key: const JournalMetricValue(3)}), + isTrue, + reason: f.key); + } + }); + + test('a dose logged as it happened is not a self-report', () { + // Water at lunchtime says nothing about whether the day has been + // reflected on — this is the case that would otherwise silence the + // prompt for anyone who uses the water reminder. + expect( + NotificationCenter.checkInDone( + const {'water_ml': JournalMetricValue(500)}), + isFalse); + expect( + NotificationCenter.checkInDone( + const {'caffeine_mg': JournalMetricValue(200)}), + isFalse); + expect(NotificationCenter.checkInDone(const {}), isFalse); + }); + }); + + group('the check-in follows the person', () { + const off = NotificationPrefs(); + const on = NotificationPrefs(checkInEnabled: true); + + test('off by default — nothing is armed for anyone who did not ask', () { + expect(NotificationCenter.checkInMinute(off, 23 * 60), isNull); + }); + + test('an hour before the bedtime the coach learned', () { + expect(NotificationCenter.checkInMinute(on, 21 * 60), 20 * 60); + // A late chronotype is asked later, not at everyone else's 20:30. + expect(NotificationCenter.checkInMinute(on, 22 * 60 + 30), 21 * 60 + 30); + }); + + test('no bedtime yet → the stated fixed fallback', () { + expect(NotificationCenter.checkInMinute(on, null), + NotificationCenter.checkInFallbackMin); + }); + + test('never inside the quiet window, whatever the bedtime says', () { + // 01:00 bedtime. Minus an hour is midnight, which is the middle of the + // window the user asked not to be interrupted in. + final t = NotificationCenter.checkInMinute(on, 25 * 60)!; + expect(t, 21 * 60 + 30); // quietStart 22:00, minus the half-hour margin + expect(on.inQuietHours(t), isFalse); + }); + + test('never before the day has happened', () { + // A 17:00 bedtime would put the prompt at 16:00. + expect(NotificationCenter.checkInMinute(on, 17 * 60), + NotificationCenter.checkInEarliestMin); + }); + + test('a quiet window that swallows the evening arms nothing', () { + const all = NotificationPrefs( + checkInEnabled: true, quietStartMin: 12 * 60, quietEndMin: 11 * 60); + expect(NotificationCenter.checkInMinute(all, null), isNull); + }); + }); + + group('the check-in does not ask twice', () { + const on = NotificationPrefs(checkInEnabled: true); + + test('a day already written is not asked about again', () { + expect( + NotificationCenter.checkInSlot(on, null, + doneToday: true, nowMin: 12 * 60), + isNull); + }); + + test('but tomorrow is still armed once tonight has passed', () { + // 21:00, journal written, slot was 20:30 — that instance is behind us, so + // the one being armed is tomorrow's and the day it asks about is not + // written yet. + expect( + NotificationCenter.checkInSlot(on, null, + doneToday: true, nowMin: 21 * 60), + NotificationCenter.checkInFallbackMin); + }); + + test('an unwritten day arms normally', () { + expect( + NotificationCenter.checkInSlot(on, null, + doneToday: false, nowMin: 12 * 60), + NotificationCenter.checkInFallbackMin); + }); + }); + + group('medication prompts come off the schedule the user typed', () { + // A Thursday, mid-morning: the 08:00 dose is behind us, the 20:00 one is not. + final now = DateTime(2026, 8, 20, 10, 0); + final defs = [ + _def('d3', const [MedSchedule(8 * 60, []), MedSchedule(20 * 60, [])]), + ]; + const on = NotificationPrefs(medsEnabled: true); + + test('off by default', () { + expect( + NotificationCenter.medPromptSlots( + const NotificationPrefs(), defs, const {}, + now: now), + isEmpty); + }); + + test('every dose still due across the horizon, soonest first', () { + final s = + NotificationCenter.medPromptSlots(on, defs, const {}, now: now); + // today 20:00, then both slots on each of the next two days. + expect(s.length, 5); + expect(s.first.date, todayLabel(now)); + expect(s.first.slotMin, 20 * 60); + for (var i = 1; i < s.length; i++) { + expect(NotificationCenter.medSlotInstant(s[i])! + .isAfter(NotificationCenter.medSlotInstant(s[i - 1])!), isTrue); + } + }); + + test('a dose already taken is never asked for', () { + final s = NotificationCenter.medPromptSlots( + on, defs, _doses('d3', 20 * 60, taken: true), + now: now); + expect(s.length, 4); + expect(s.where((x) => x.date == todayLabel(now)), isEmpty); + }); + + test('a dose deliberately skipped is not asked for either', () { + final s = NotificationCenter.medPromptSlots( + on, defs, _doses('d3', 20 * 60, skipped: true), + now: now); + expect(s.where((x) => x.date == todayLabel(now)), isEmpty); + }); + + test('a dose that already came due today is not chased', () { + // The 08:00 slot is a miss, not an upcoming dose. Arming it would be a + // notification about a thing that is over — the same "yesterday's news" + // rule emitOncePerDay carries. + final s = + NotificationCenter.medPromptSlots(on, defs, const {}, now: now); + expect( + s.where((x) => x.date == todayLabel(now) && x.slotMin == 8 * 60), + isEmpty); + }); + + test('a weekday-restricted course only fires on its days', () { + final mondays = [ + _def('m', const [MedSchedule(9 * 60, [DateTime.monday])]) + ]; + // Thu 20 Aug + Fri + Sat — no Monday in the horizon. + expect(NotificationCenter.medPromptSlots(on, mondays, const {}, now: now), + isEmpty); + // From the Sunday, Monday is in it. + final s = NotificationCenter.medPromptSlots(on, mondays, const {}, + now: DateTime(2026, 8, 23, 10, 0)); + expect(s.length, 1); + expect( + DateTime.parse(s.first.date).weekday, DateTime.monday); + }); + + test('two pills at the same minute are one interruption', () { + final pair = [ + _def('a', const [MedSchedule(20 * 60, [])]), + _def('b', const [MedSchedule(20 * 60, [])]), + ]; + final s = NotificationCenter.medPromptSlots(on, pair, const {}, now: now); + // One per day across the horizon, not two. + expect(s.length, 3); + expect(s.map((x) => x.date).toSet().length, 3); + }); + + test('an inactive definition is not armed', () { + final stopped = [ + MedDef( + key: 'x', + label: 'x', + active: false, + schedule: const [MedSchedule(20 * 60, [])], + createdAt: DateTime(2020).millisecondsSinceEpoch, + ) + ]; + expect(NotificationCenter.medPromptSlots(on, stopped, const {}, now: now), + isEmpty); + }); + + test('never more slots than the id band has room for', () { + final many = [ + for (var i = 0; i < 8; i++) + _def('m$i', [MedSchedule(11 * 60 + i, const [])]), + ]; + final s = NotificationCenter.medPromptSlots(on, many, const {}, now: now); + expect(s.length, NotificationService.maxMedSlots); + // And every one of them lands on an id inside the band. + for (var i = 0; i < s.length; i++) { + expect( + NotificationService.maySchedule(NotificationService.idMedsBase + i), + isTrue); + } + }); + + test('the instant is the day plus the minute the user entered', () { + final s = + NotificationCenter.medPromptSlots(on, defs, const {}, now: now).first; + final at = NotificationCenter.medSlotInstant(s)!; + expect(at.hour, 20); + expect(at.minute, 0); + expect(todayLabel(at), todayLabel(now)); + expect(at.isAfter(now), isTrue); + }); + }); + + group('both prompts have somewhere to land', () { + test('the check-in opens the journal it is asking you to write', () { + final t = resolveTapRoute(kRouteJournalCompose); + expect(t.screen, kRouteJournalCompose); + expect(screenForRoute(kRouteJournalCompose), isNotNull); + }); + + test('the medication reminder lands on Wellness, which owns the checklist', + () { + final t = resolveTapRoute(kRouteMeds); + // Not the Home fallback an unknown payload gets — the route is KNOWN, + // which is the half `/profile` and `/recap` were missing. + expect(t.screen, kRouteMeds); + expect(domainForRoute(kRouteMeds), ShellDomain.wellness); + // Still pushes nothing, and that is now the WORKING answer rather than + // the ceiling it used to be: the checklist is a sub-tab of a shell tab, + // so anything pushed would be a second copy of Wellness over Wellness. + // The shell asks the screen for the tab instead. + expect(screenForRoute(kRouteMeds), isNull); + // The number that deep link hands over. It is an index into a private + // list, so a reorder would silently land the tap on Habits. + expect(WellnessScreen.tabs[WellnessScreen.medsTab], 'Medication'); + }); + + test('an unknown route still falls back to Home rather than crashing', () { + expect(resolveTapRoute('/nope').tab, 0); + expect(resolveTapRoute('/nope').screen, isNull); + }); + }); +} diff --git a/test/log_workout_test.dart b/test/log_workout_test.dart new file mode 100644 index 00000000..24bd5ae1 --- /dev/null +++ b/test/log_workout_test.dart @@ -0,0 +1,210 @@ +// THE TWO SCREENS THE UI REBUILD LEFT OUT, RENDERED. +// +// Reading a widget tree does not find layout bugs — this project has paid for +// that three times over (a negative margin asserts, an OverflowBox blanks a +// whole tab, Expanded and Flexible in one Row split it 50/50). Both of these +// are pumped at a real phone width, and both are driven: the form's validation +// is exercised through the controls a thumb would use, not by calling the pure +// function underneath it. +// +// Neither screen gets an AppState here on purpose. `repoOf`/`appOf` return +// null without one, which is exactly the golden case — a screen that cannot +// reach the repository must still render its own absence rather than throw. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/compute/manual_session.dart'; +import 'package:openstrap_edge/ui2/activity/catalogue.dart'; +import 'package:openstrap_edge/ui2/screens/log_workout.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +/// A real phone, and tall enough that nothing under test is below the fold — +/// the default 800x600 harness hides the very controls these tests are about. +Future _pump(WidgetTester t, Widget w) async { + t.view.physicalSize = const Size(390 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: w, + )); + await t.pumpAndSettle(); +} + +/// 18:30–19:31 on a fixed day, as the detector would have reported it. +final _now = DateTime(2026, 8, 19, 21); +final _start = DateTime(2026, 8, 19, 18, 30); +final _end = DateTime(2026, 8, 19, 19, 31); + +Suggestion _sug({String id = 'a', int? avg = 148, int? peak = 171}) => + Suggestion( + id: id, + startTs: _start.millisecondsSinceEpoch ~/ 1000, + endTs: _end.millisecondsSinceEpoch ~/ 1000, + sport: 'running', + avgBpm: avg, + peakBpm: peak, + ); + +void main() { + group('the detected-activity review', () { + testWidgets('draws the bout, its window and all three answers', (t) async { + await _pump(t, WorkoutSuggestionScreen(preloaded: [_sug()])); + + expect(find.text('Detected activity'), findsOneWidget); + // The WINDOW, not just a start time — the whole reason to open this + // screen is to see whether the detector clipped it. + expect(find.textContaining('6:30 PM – 7:31 PM'), findsOneWidget); + expect(find.text('61 min of effort'), findsOneWidget); + // Every answer is reachable, including the one that matters most. + expect(find.text('Log it'), findsOneWidget); + expect(find.text('Adjust the times'), findsOneWidget); + expect(find.text('Not a workout'), findsOneWidget); + // and it never prints a strain or a calorie figure it has not scored + expect(find.textContaining('strain'), findsNothing); + }); + + testWidgets('an empty review says so, and never as a bare dash', (t) async { + await _pump(t, const WorkoutSuggestionScreen(preloaded: [])); + expect(find.text('Nothing to review'), findsOneWidget); + expect(find.text('—'), findsNothing); + }); + + test('the deep link narrows to its own bout, and falls back honestly', () { + final all = [_sug(), _sug(id: 'b')]; + // No id (opened from the Workouts tab) — review everything. + expect(focusSuggestions(all, null), all); + // The notification named 'b': that is the one it promised. + expect(focusSuggestions(all, 'b').single.id, 'b'); + // Logged or dismissed between the buzz and the tap. The others are still + // waiting, so they are shown — an empty screen would claim this one was + // handled when what happened is that a DIFFERENT one was. + expect(focusSuggestions(all, 'gone'), all); + }); + + testWidgets('opened from the notification, only that bout is on screen', + (t) async { + await _pump( + t, + WorkoutSuggestionScreen( + preloaded: [_sug(), _sug(id: 'b', avg: 96, peak: 110)], + focusId: 'b', + ), + ); + expect(find.text('Log it'), findsOneWidget); // one card, not two + expect(find.textContaining('110'), findsOneWidget); + expect(find.textContaining('171'), findsNothing); + }); + + testWidgets('nothing overflows at 2x text', (t) async { + t.view.physicalSize = const Size(390 * 3, 3000 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2)), + child: MaterialApp( + theme: buildTheme(Brightness.dark), + home: WorkoutSuggestionScreen(preloaded: [_sug(), _sug(id: 'b')]), + ), + )); + await t.pumpAndSettle(); + // An overflow paints its stripe and reports through the harness rather + // than failing the pump, so it has to be taken to be seen. + expect(t.takeException(), isNull); + }); + }); + + group('the manual-entry form', () { + testWidgets('opens on a valid window and offers to save it', (t) async { + await _pump(t, LogWorkout(now: _now)); + expect(find.text('Log a past workout'), findsOneWidget); + // Defaults to the last whole hour, which is a WINDOW — a form that opens + // on "now to now" opens invalid. + expect(find.text('60 min'), findsOneWidget); + expect(find.text('That window will not save'), findsNothing); + expect(find.text('Log it'), findsOneWidget); + }); + + testWidgets('a window that overlaps one already logged is refused', ( + t, + ) async { + await _pump( + t, + LogWorkout( + now: _now, + start: _start, + end: _end, + spans: [ + SessionSpan( + 'manual:1', + _start.millisecondsSinceEpoch ~/ 1000 + 600, + _end.millisecondsSinceEpoch ~/ 1000 + 600, + ), + ], + ), + ); + expect(find.text('That window will not save'), findsOneWidget); + expect( + find.text('That overlaps a workout already in your log.'), + findsOneWidget, + ); + }); + + testWidgets('a retime keeps the type and does not offer to change it', ( + t, + ) async { + await _pump( + t, + LogWorkout( + sessionId: 'manual:123', + now: _now, + start: _start, + end: _end, + activity: activityByName('running'), + title: 'Fix the times', + ), + ); + expect(find.text('Fix the times'), findsWidgets); + expect(find.text('Save the new times'), findsOneWidget); + // The row that would change the activity is absent: a retime is about + // the window, and the type belongs to the row already. + expect(find.text('Activity'), findsNothing); + }); + + testWidgets('picking an end time before the start rolls to the next day', ( + t, + ) async { + // 23:40 → 00:20 is an ordinary late run, not an invalid window. + final late = DateTime(2026, 8, 19, 23, 40); + await _pump( + t, + LogWorkout( + now: DateTime(2026, 8, 20, 8), + start: late, + end: late.add(Motion.tick * 2400), + activity: activityByName('running'), + ), + ); + expect(find.text('40 min'), findsOneWidget); + expect(find.text('the next morning'), findsOneWidget); + expect(find.text('That window will not save'), findsNothing); + }); + }); + + group('the day label', () { + final now = DateTime(2026, 8, 19, 12); + test('names today and yesterday, then the date', () { + expect(dayLabel(DateTime(2026, 8, 19, 6), now: now), 'Today'); + expect(dayLabel(DateTime(2026, 8, 18, 23), now: now), 'Yesterday'); + expect(dayLabel(DateTime(2026, 8, 11, 9), now: now), 'Tue 11 Aug'); + }); + + test('counts calendar days, not 24-hour blocks', () { + // 23:59 yesterday to 00:01 today is two minutes and one day. An + // `inDays` on the difference calls it "Today". + expect(dayLabel(DateTime(2026, 8, 18, 23, 59), + now: DateTime(2026, 8, 19, 0, 1)), 'Yesterday'); + }); + }); +} diff --git a/test/notification_center_test.dart b/test/notification_center_test.dart index 6e0e110b..18038ef6 100644 --- a/test/notification_center_test.dart +++ b/test/notification_center_test.dart @@ -10,6 +10,7 @@ import 'package:openstrap_edge/notify/notification_event.dart'; import 'package:openstrap_edge/notify/notification_ids.dart'; import 'package:openstrap_edge/notify/notification_prefs.dart'; import 'package:openstrap_edge/notify/notification_service.dart'; +import 'package:openstrap_edge/notify/tap_router.dart'; import 'package:openstrap_edge/ui2/profile/settings.dart'; NotificationEvent _ev(NotifCategory c, NotifPriority p) => NotificationEvent( @@ -21,6 +22,19 @@ NotificationEvent _ev(NotifCategory c, NotifPriority p) => NotificationEvent( date: '2026-06-27', ); +/// The auto-detected-workout prompt exactly as `derivation_engine` emits it — +/// reminders channel, normal priority, and a route carrying the bout's id. +NotificationEvent _autoWorkout({String? route, NotifPriority? priority}) => + NotificationEvent( + dedupeKey: '2026-06-27:1750000000:auto_workout', + category: NotifCategory.reminders, + priority: priority ?? NotifPriority.normal, + title: 'Did you work out?', + body: 'We spotted ~42 min of elevated activity. Tap to log it.', + date: '2026-06-27', + route: route ?? workoutSuggestionRoute('2026-06-27:1750000000'), + ); + void main() { group('quiet hours window', () { const p = NotificationPrefs(quietStartMin: 22 * 60, quietEndMin: 7 * 60); @@ -44,8 +58,8 @@ void main() { }); }); - group('the three classes', () { - test('classOf recognises exactly three, and nothing else', () { + group('the four classes', () { + test('classOf recognises exactly four, and nothing else', () { // The exception: health findings and the band's own failures. expect(classOf(_ev(NotifCategory.health, NotifPriority.critical)), NotifClass.exception); @@ -54,11 +68,36 @@ void main() { // The alarm, and only the alarm, claims reminders+critical. expect(classOf(_ev(NotifCategory.reminders, NotifPriority.critical)), NotifClass.alarm); + // The detected workout, and it alone, claims reminders+normal — by + // ROUTE, so the pair keeps meaning "no" for anything else that lands on + // it. It was emitted on `recovery` and dropped: written, never told. + expect(classOf(_autoWorkout()), NotifClass.prompt); // Everything that used to make up the other nineteen kinds. expect(classOf(_ev(NotifCategory.recovery, NotifPriority.normal)), isNull); expect(classOf(_ev(NotifCategory.reminders, NotifPriority.low)), isNull); expect( classOf(_ev(NotifCategory.reminders, NotifPriority.normal)), isNull); + // The route is what opens the gate, not the channel: a new nudge on the + // same channel is still refused. + expect(classOf(_autoWorkout(route: '/workouts')), isNull); + // BOTH halves, not either. The route alone was enough until now, so a + // low-priority event carrying it walked through a gate documented as + // reminders + NORMAL only. + expect(classOf(_autoWorkout(priority: NotifPriority.low)), isNull); + }); + + test('the id-carrying payload is what actually gets classified', () { + // The real emit carries `?id=…`. Every route check in the system is an + // equality test, so a path-blind one silently classifies the live + // notification as null — the same "written, never told" failure in a new + // place. Both halves of the gate are checked on the real payload. + final e = _autoWorkout(); + expect(e.route, contains('?id=')); + expect(classOf(e), NotifClass.prompt); + expect( + const NotificationPrefs(autoDetectEnabled: false) + .shouldFireOs(e, 12 * 60), + isFalse); }); }); @@ -66,9 +105,15 @@ void main() { // The OS fires a zonedSchedule with no Dart running, so shouldFireOs never // sees one. What may be SCHEDULED is a separate, narrower list: a slot the // user asked for by name, at a time or interval they picked. - test('allows the lookback, the hydration band and the nightly sweep', () { + test('allows the lookback, the hydration band, the sweep and the nudge', () { expect(NotificationService.maySchedule(NotificationService.idWeeklyRecap), isTrue); + // The movement nudge earned its place by growing an off switch + // (NotificationPrefs.movementEnabled). Refused here for as long as it had + // none, which is why issue #123 never fired for anyone — the cancel on + // every foreground resume was the visible half of it. + expect(NotificationService.maySchedule(NotificationService.idStillness), + isTrue); expect(NotificationService.maySchedule(NotificationService.idEveningBrief), isTrue); for (var i = 0; i < NotificationService.maxWaterSlots; i++) { @@ -85,7 +130,6 @@ void main() { NotificationService.idWindDown, NotificationService.idJournalLog, NotificationService.idMorningBrief, - NotificationService.idStillness, NotificationService.idLowBattery, NotificationService.idWaterBase - 1, NotificationService.idWaterBase + NotificationService.maxWaterSlots, @@ -107,7 +151,20 @@ void main() { expect(p.shouldFireOs(_ev(NotifCategory.device, NotifPriority.normal), 2 * 60), isFalse); }); - test('a kind that is not one of the three never fires, quiet or not', () { + test('the detected-workout prompt reaches the OS, and is not shouty', () { + final e = _autoWorkout(); + // The whole point: it fires. A test that only asserts the suggestion row + // was written passes on the build where this never reached anyone. + expect(p.shouldFireOs(e, 12 * 60), isTrue); + // A prompt, not an alarm: 02:00 is not the time to ask about a workout. + expect(p.shouldFireOs(e, 2 * 60), isFalse); + // And the Reminders channel switch turns it off like everything on it. + expect( + const NotificationPrefs(remindersEnabled: false) + .shouldFireOs(e, 12 * 60), + isFalse); + }); + test('a kind that is not one of the four never fires, quiet or not', () { for (final minute in [2 * 60, 12 * 60]) { expect( p.shouldFireOs( @@ -119,6 +176,31 @@ void main() { isFalse); } }); + // The auto-detect off switch (issues #102, #149). The detector has never + // had one — the row is written, the notification is emitted, and nothing + // anywhere could stop either. + test('the detected-workout prompt is silenced by its own switch', () { + const on = NotificationPrefs(); + const off = NotificationPrefs(autoDetectEnabled: false); + const e = NotificationEvent( + dedupeKey: '2026-06-27:auto_workout:1', + // health, so the three-class rule is not what is being measured here: + // the point is that the switch outranks a category that WOULD fire. + category: NotifCategory.health, + priority: NotifPriority.normal, + title: 'Did you work out?', + body: 'b', + date: '2026-06-27', + route: kRouteWorkoutSuggestion, + ); + expect(on.shouldFireOs(e, 12 * 60), isTrue); + expect(off.shouldFireOs(e, 12 * 60), isFalse); + // and it silences nothing else + expect( + off.shouldFireOs(_ev(NotifCategory.health, NotifPriority.normal), + 12 * 60), + isTrue); + }); test('critical overrides quiet hours when allowed', () { expect(p.shouldFireOs(_ev(NotifCategory.health, NotifPriority.critical), 2 * 60), isTrue); diff --git a/test/notification_dedupe_test.dart b/test/notification_dedupe_test.dart index 3a2df4ed..eb56b9ad 100644 --- a/test/notification_dedupe_test.dart +++ b/test/notification_dedupe_test.dart @@ -22,6 +22,7 @@ import 'package:openstrap_edge/data/day_label.dart'; import 'package:openstrap_edge/notify/fired_keys.dart'; import 'package:openstrap_edge/notify/notification_center.dart'; import 'package:openstrap_edge/notify/notification_event.dart'; +import 'package:openstrap_edge/notify/tap_router.dart'; /// Records every event handed to the OS layer so tests can assert call counts. class _FakeSink { @@ -324,6 +325,66 @@ void main() { }); }); + group('the auto-detected workout actually reaches the shade', () { + // The exact event derivation_engine builds for a detected bout. It was + // emitted on NotifCategory.recovery, which `classOf` maps to null, so + // `shouldFireOs` dropped it: the suggestion row was written on every derive + // and the user was never told, in any build. A test that only asserts the + // row exists passes on that broken code — this one asserts the OS saw it. + const sugId = '2026-08-19:1755625800'; + NotificationEvent detected() => NotificationEvent( + dedupeKey: '$_today:$sugId:auto_workout', + category: NotifCategory.reminders, + title: 'Did you work out?', + body: 'We spotted ~42 min of elevated activity. Tap to log it.', + date: _today, + route: workoutSuggestionRoute(sugId), + ); + + test('it fires, and it carries the bout it is about', () async { + final sink = _FakeSink(); + center.presentSink = sink.call; + expect(await center.emit(detected()), isTrue); + // The payload is what the OS hands back on the tap; the id has to survive + // it (the colon in the row id is percent-encoded in the query). + expect(routeId(sink.shown.single.route!), sugId); + }); + + test('one detected workout, one notification — never per derive pass', + () async { + final sink = _FakeSink(); + center.presentSink = sink.call; + // Derivation re-detects the same bout on every drain and every 15-min + // background pass. The key is the suggestion id, so they all collapse. + for (var i = 0; i < 5; i++) { + await center.emit(detected()); + } + expect(sink.shown.length, 1); + }); + + test('the auto-detect switch silences it', () async { + SharedPreferences.setMockInitialValues({ + 'notif_quiet_enabled': false, + 'notif_auto_detect': false, + }); + final sink = _FakeSink(); + center.presentSink = sink.call; + expect(await center.emit(detected()), isFalse); + expect(sink.shown, isEmpty); + }); + + test('quiet hours silence it — it is a prompt, not the alarm', () async { + SharedPreferences.setMockInitialValues({ + 'notif_quiet_enabled': true, + 'notif_quiet_start': 0, + 'notif_quiet_end': 1440, + }); + final sink = _FakeSink(); + center.presentSink = sink.call; + expect(await center.emit(detected()), isFalse); + }); + }); + group('FiredKeyStore per-key + retention (degraded mode)', () { // A local YYYY-MM-DD offset from today, for retention-window assertions. // dayLabelOf, not raw toIso8601String: day labels are LOCAL everywhere, and diff --git a/test/off_lookup_test.dart b/test/off_lookup_test.dart index 6afc8783..37f5df28 100644 --- a/test/off_lookup_test.dart +++ b/test/off_lookup_test.dart @@ -17,6 +17,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/data/off_lookup.dart'; +import 'package:openstrap_edge/state/prefs.dart'; +import 'package:shared_preferences/shared_preferences.dart'; /// An api/v2 body, in the shape the endpoint actually returns. Map body({ @@ -392,10 +394,36 @@ void main() { }); }); - group('nothing leaves without consent', () { - test('a lookup with the pref off refuses before any request', () async { - // Prefs is unloaded in a headless test, so every read is its default — - // and the default is off. This is the state a fresh install is in. + group('the consent gate', () { + // Order matters: Prefs caches its SharedPreferences instance on first load + // and never reloads, so the unloaded case has to be read before anything + // mocks a store in. + test('storage we cannot read is a refusal, not the default', () async { + // Nothing has loaded Prefs. The default is ON, but an unreadable store + // is not evidence of a fresh install — it is equally the phone of + // somebody who turned this OFF, and their barcode must not go out on a + // guess. + expect(Prefs.loaded, isFalse); + expect(offLookupAllowed, isFalse); + final r = await fetchOffProduct('8901719101090'); + expect(r.outcome, OffOutcome.refused); + }); + + test('a fresh install may look up', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues(const {}); + await Prefs.ensureLoaded(); + // Loaded, and the key has never been written: THIS is the fresh install, + // and it is on. What leaves is the barcode, never anything about the + // person holding it. + expect(Prefs.loaded, isTrue); + expect(offLookupAllowed, isTrue); + }); + + test('a lookup refuses before any request once it is turned off', () async { + // Written through the instance the test above loaded — Prefs caches it + // for the process, so a second `setMockInitialValues` would not be seen. + setOffLookupAllowed(false); expect(offLookupAllowed, isFalse); final r = await fetchOffProduct('8901719101090'); expect(r.outcome, OffOutcome.refused); diff --git a/test/session_score_reconcile_test.dart b/test/session_score_reconcile_test.dart index e8398542..c8b55479 100644 --- a/test/session_score_reconcile_test.dart +++ b/test/session_score_reconcile_test.dart @@ -81,9 +81,49 @@ void main() { ); expect(r.strain, 9.0); expect(r.calories, 400); - expect(r.maxHr, 171); expect(r.zoneMinutes, const [1, 5, 10, 4, 0]); - expect(r.changed, isFalse); + // ... EXCEPT the peak, which is not that kind of quantity — see below. + expect(r.maxHr, 140); + expect(r.changed, isTrue); + }); + + // #127. Strain and calories accumulate, so over a subset of the window each + // is a floor and the larger of two floors is the better estimate. A MAXIMUM + // moves the other way: an artefact only ever makes it bigger, so max() is a + // ratchet a single PPG transient wins forever. It did — a session saved + // before the peak was smoothed carries the spike, the substrate re-scores it + // to the real figure, and the ratchet put the spike straight back on every + // pass under 90 % coverage. Meanwhile `_sessionTrace` recomputes the peak + // from the same substrate and deliberately does NOT floor against the stored + // column, so the list and the detail screen printed different peaks for one + // session. + test('a spiked stored peak loses to the substrate, at any coverage', () { + final r = reconcileSessionScore( + liveStrain: 5.0, + liveCalories: 200, + liveMaxHr: 160, // the PPG transient RR reported + liveZoneMinutes: const [], + substrate: _substrate( + strain: 4.0, + calories: 180, + maxHr: 143, // the real peak, spike-suppressed + samples: 600, + ), + ); + expect(r.maxHr, 143); + expect(r.strain, 5.0, reason: 'the additive rule is unchanged'); + }); + + test('an absent substrate peak still keeps the live one', () { + final r = reconcileSessionScore( + liveStrain: 5.0, + liveCalories: 200, + liveMaxHr: 160, + liveZoneMinutes: const [], + substrate: _substrate(strain: 4.0, calories: 180, samples: 600), + ); + expect(r.maxHr, 160, + reason: 'no worn samples survived is not "the answer is nothing"'); }); test('absent stays absent — an unscored session never becomes 0.0', () { diff --git a/test/tap_router_test.dart b/test/tap_router_test.dart index 4edfbe6a..766f4c1f 100644 --- a/test/tap_router_test.dart +++ b/test/tap_router_test.dart @@ -32,6 +32,23 @@ void main() { expect(t.screen, kRouteWorkoutSuggestion); // focused log/adjust review }); + test('the id-carrying suggestion payload resolves, id intact', () { + // The real notification names the bout. Matching the tables on the whole + // string would drop it into the unknown-route fallback — Today, from a + // notification about a workout. + const id = '2026-08-19:1755625800'; + final t = resolveTapRoute(workoutSuggestionRoute(id)); + expect(t.tab, 4); + expect(routePath(t.screen!), kRouteWorkoutSuggestion); + expect(routeId(t.screen!), id, reason: 'the shell needs it to open on it'); + }); + + test('routePath/routeId survive a route with no query, and a junk one', () { + expect(routePath(kRouteWater), kRouteWater); + expect(routeId(kRouteWater), isNull); + expect(routeId('/nope'), isNull); + }); + test('plain /workouts still resolves to the tab with no sub-screen', () { // The auto-detect notification now uses kRouteWorkoutSuggestion, but the // bare tab route must keep working for any other caller. diff --git a/test/ui2_charts_test.dart b/test/ui2_charts_test.dart index 9dee407d..5879bbed 100644 --- a/test/ui2_charts_test.dart +++ b/test/ui2_charts_test.dart @@ -367,7 +367,7 @@ void main() { }); // ── THE AXIS-LESS PATH ────────────────────────────────────────────────── - // TrendCard and the MetricRow spark go through the painters' own fallback + // TrendCard and the MetricRow series go through the painters' own fallback // and never see AxisSpec, so every fix that landed there has to land here // too or half the app keeps the bug. group('the auto-scale fallback', () { diff --git a/test/ui2_health_subtab_race_test.dart b/test/ui2_health_subtab_race_test.dart new file mode 100644 index 00000000..7837a457 --- /dev/null +++ b/test/ui2_health_subtab_race_test.dart @@ -0,0 +1,129 @@ +// A revision landed WHILE a sub-tab was loading for the first time. +// +// The token in RevisionReload only rejects an old read once a NEWER token has +// been issued for that key — and nothing issues one unless `reload` re-reads +// the key. Health's `reload` used to re-read a sub-tab on its cached value +// being non-null, which is false for exactly the read that is still in flight. +// So the pre-revision read passed `stillNewest` and committed pre-import data +// AFTER the import, on the first load after the import, which is the one +// moment the data is guaranteed to be changing. +// +// The order below is the whole test: start the read, bump the revision while +// it is parked, and let the OLD read finish LAST. Bumping after the first read +// settles passes on the broken code too — that is how this hole got here. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +const _day = '2026-08-16'; + +/// The Vitals read, with a hand on its clock. +/// +/// Only the four calls `VitalsData.load` makes are answered; the Overview +/// load's own queries throw their `re-layer` default, which that loader +/// catches, and nothing in this test looks at Overview. +class _Repo extends LocalRepository { + /// The respiratory rate the database holds. Changing it is "an import + /// landed". + double resp = 11; + + /// Parks the NEXT lungs read until completed. One-shot. + Completer? hold; + + @override + Future> getToday() async => const { + 'status': {'today_day': _day} + }; + + @override + Future> availableDays() async => const [_day]; + + @override + Future> getDayTimeline(String date) async => + {'date': date}; + + @override + Future> getDayLungs(String date) async { + // Read the value BEFORE parking: a read that started before the import + // saw the pre-import database, whenever it happens to be resumed. + final v = resp; + final h = hold; + if (h != null) { + hold = null; + await h.future; + } + return { + 'resp': {'value': v} + }; + } + + @override + Future> getDayWear(String date) async => const {}; + + @override + Future> getDayHrv(String date) async => const {}; +} + +Future _settle(WidgetTester t) async { + for (var i = 0; i < 20; i++) { + await t.pump(); + } +} + +void main() { + testWidgets('a read in flight when the revision lands does not win', + (t) async { + // Wide enough that all five sub-tab chips are on screen to be tapped. + t.view.physicalSize = const Size(800 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + + final app = AppState.forTesting(); + addTearDown(app.dispose); + final repo = _Repo(); + app.repo = repo; + + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: const Scaffold(body: HealthScreen()), + ), + )); + await _settle(t); + + // The user opens Vitals for the first time. Its read parks mid-flight. + final parked = Completer(); + repo.hold = parked; + await t.tap(find.text('Vitals')); + await _settle(t); + expect(find.byType(CircularProgressIndicator), findsOneWidget, + reason: 'the sub-tab should still be loading — nothing to race yet'); + + final before = t.state(find.byType(HealthScreen)); + + // An import lands while that read is parked. Its cache is still null. + repo.resp = 17; + app.bumpInsights(); + await _settle(t); + + // …and only NOW does the pre-import read come back. + parked.complete(); + await _settle(t); + + expect(find.text('17.0'), findsOneWidget, + reason: 'the post-revision read must be what is on screen'); + expect(find.text('11.0'), findsNothing, + reason: 'a read that started before the revision committed after it'); + expect(identical(t.state(find.byType(HealthScreen)), before), isTrue, + reason: 'the screen was remounted — that is the workaround, not the fix'); + }); +} diff --git a/test/ui2_labs_delete_test.dart b/test/ui2_labs_delete_test.dart new file mode 100644 index 00000000..87098c57 --- /dev/null +++ b/test/ui2_labs_delete_test.dart @@ -0,0 +1,265 @@ +// LABS, REMOVABLE — the surface for a store that already had the deletes. +// +// `LocalDb.deleteLabResult` and `deleteLabMarkerDef` were both written and +// both tested, and neither had a caller anywhere in the app: blood work typed +// by hand was permanent. This is that path end to end, RENDERED rather than +// read, because a Row that overflows or a control under the 44 pt floor is not +// something the widget tree tells you about. +// +// Two acts, deliberately not one. Removing a RESULT destroys a reading. +// Removing a MARKER destroys a label — and because a result is labelled +// THROUGH its marker, one that still holds results is refused rather than left +// rendering under its raw storage key. +// +// The screen is handed its first LabsData (it is loaded on the sub-tab TAP in +// production, and a fake clock never lets sqflite answer), but every delete +// below goes to the real database and the row that comes back is a real read — +// which is the point: gone from the store AND gone from the screen, with no +// tab switch in between. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/ui2/screens/health_screen.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +/// Frames until [f] appears, or give up. Real time, because the write behind +/// it is real: sqflite answers from another isolate and the fake clock never +/// lets that reply land. Same idiom as ui2_revision_reload_test. +Future _until(WidgetTester t, Finder f, {int n = 60}) async { + for (var i = 0; i < n && f.evaluate().isEmpty; i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 20))); + await t.pump(); + } +} + +/// The same, waiting for something to GO. +Future _untilGone(WidgetTester t, Finder f, {int n = 60}) async { + for (var i = 0; i < n && f.evaluate().isNotEmpty; i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 20))); + await t.pump(); + } +} + +/// The row-wide control whose label STARTS with [label] — a Pressable wrapping +/// a row merges its children's text into its own semantics node, so the whole +/// row reads as "Remove Ferritin from 2026-03-04, Ferritin, Typical 30–400 …". +/// The action is asserted to come first, which is the part that matters when +/// the control destroys something. +Finder _control(String label) => + find.bySemanticsLabel(RegExp('^${RegExp.escape(label)}')); + +/// Write the rows, then read back what the screen would have loaded. +Future _seed( + WidgetTester t, + List> results, { + List> defs = const [], +}) async => + (await t.runAsync(() async { + for (final d in defs) { + await LocalDb.putLabMarkerDef(d); + } + for (final r in results) { + await LocalDb.putLabResult( + marker: r[0] as String, + takenOn: r[1] as String, + value: (r[2] as num).toDouble(), + unit: r[3] as String, + ); + } + return LabsData.load(); + }))!; + +/// The Labs sub-tab at a real phone width. +Future _pumpLabs(WidgetTester t, LabsData labs, {double scale = 1}) async { + t.view.physicalSize = Size(390 * 3, 2400 * 3 * scale); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: MaterialApp( + theme: buildTheme(Brightness.light), + home: Scaffold( + body: HealthScreen(data: const HealthData(), labs: labs, tab: 4), + ), + ), + )); + await t.pumpAndSettle(); +} + +const _ferritinAndHba1c = [ + ['ferritin', '2026-03-04', 42, 'ng/mL'], + ['hba1c', '2026-03-04', 5.2, '%'], +]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_labs_delete_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + }); + + tearDownAll(() async => LocalDb.close()); + + setUp(() async { + final db = await LocalDb.instance; + await db.delete('lab_result'); + await db.delete('lab_marker_def'); + }); + + testWidgets('every result carries its own way out, at 390 and at large text', + (t) async { + final labs = await _seed(t, _ferritinAndHba1c); + for (final scale in const [1.0, 2.0, 3.0]) { + await _pumpLabs(t, labs, scale: scale); + expect(find.text('Ferritin'), findsOneWidget); + expect(find.text('42'), findsOneWidget); + expect( + _control('Remove Ferritin from 2026-03-04'), + findsOneWidget, + reason: 'a control at ${scale}x text, not only at 1x', + ); + expect(_control('Remove HbA1c from 2026-03-04'), + findsOneWidget); + expect(t.takeException(), isNull, + reason: 'nothing overflowed at ${scale}x'); + } + }); + + testWidgets('the confirm names the reading, and Keep it keeps it', (t) async { + final labs = await _seed(t, _ferritinAndHba1c); + await _pumpLabs(t, labs); + await t.tap(_control('Remove Ferritin from 2026-03-04')); + await t.pumpAndSettle(); + + // The marker, the number, the unit and the date — all four, because this + // sheet is the last thing between a column of blood results and the wrong + // one going. + expect(find.text('Remove Ferritin from 2026-03-04?'), findsOneWidget); + expect(find.textContaining('42 ng/mL'), findsOneWidget); + expect(find.textContaining('no undo'), findsOneWidget); + + await t.tap(find.text('Keep it')); + await t.pumpAndSettle(); + expect(find.text('Ferritin'), findsOneWidget); + expect( + (await t.runAsync(() => LocalDb.labResults(marker: 'ferritin')))!, + hasLength(1), + ); + }); + + testWidgets('a removed result leaves the store AND the screen, in place', + (t) async { + final labs = await _seed(t, _ferritinAndHba1c); + await _pumpLabs(t, labs); + final before = t.state(find.byType(HealthScreen)); + + await t.tap(_control('Remove Ferritin from 2026-03-04')); + await t.pumpAndSettle(); + await t.tap(find.text('Remove')); + await _until(t, find.textContaining('No Ferritin results left')); + + expect((await t.runAsync(() => LocalDb.labResults(marker: 'ferritin')))!, + isEmpty); + // No tab switch, no relaunch: the row is gone from the tab it was tapped + // on, the screen says what happened, and it is the SAME State — it + // re-read, it was not thrown away and rebuilt. + expect(find.text('Ferritin'), findsNothing); + expect(find.text('HbA1c'), findsOneWidget); + expect(identical(t.state(find.byType(HealthScreen)), before), isTrue); + }); + + testWidgets('an earlier draw takes its place, and is said to', (t) async { + final labs = await _seed(t, const [ + ['ferritin', '2025-11-02', 61, 'ng/mL'], + ['ferritin', '2026-03-04', 42, 'ng/mL'], + ]); + await _pumpLabs(t, labs); + + await t.tap(_control('Remove Ferritin from 2026-03-04')); + await t.pumpAndSettle(); + // Warned BEFORE the tap, so "I deleted it and it is still there" never + // happens: only the newest draw of a marker is on screen, so the one + // underneath surfaces in its place. + expect(find.textContaining('2025-11-02 draw stays'), findsOneWidget); + await t.tap(find.text('Remove')); + await _until(t, find.text('61')); + + expect(find.text('Ferritin'), findsOneWidget); + expect(find.textContaining('Showing your 2025-11-02 draw now'), + findsOneWidget); + }); + + testWidgets('the last result gone leaves a stated absence, not a blank', + (t) async { + final labs = await _seed(t, const [ + ['ferritin', '2026-03-04', 42, 'ng/mL'], + ]); + await _pumpLabs(t, labs); + await t.tap(_control('Remove Ferritin from 2026-03-04')); + await t.pumpAndSettle(); + await t.tap(find.text('Remove')); + await _until(t, find.text('No lab results')); + + expect(find.text('—'), findsNothing); + expect(find.text('Add a result'), findsOneWidget); + }); + + group('a marker you named yourself', () { + const lpa = >[ + { + 'key': 'custom_lp_a', + 'label': 'Lp(a)', + 'unit': 'nmol/L', + 'category': 'lipids', + 'decimals': 0, + } + ]; + + testWidgets('is refused while it still labels a reading', (t) async { + final labs = await _seed(t, const [ + ['custom_lp_a', '2026-03-04', 90, 'nmol/L'], + ], defs: lpa); + await _pumpLabs(t, labs); + + expect(find.text('Markers you named'), findsOneWidget); + expect(find.text('1 result · nmol/L'), findsOneWidget); + + await t.tap(_control('Remove the Lp(a) marker')); + await t.pumpAndSettle(); + // Not a confirm — a refusal, with the way forward. The store KEEPS the + // readings when a definition goes, and this screen has nothing left to + // label them with, so neither destroying nor degrading them is offered. + expect(find.text('Remove Lp(a)?'), findsNothing); + expect(find.textContaining('still holds 1 result'), findsOneWidget); + expect((await t.runAsync(LocalDb.labMarkerDefs))!, hasLength(1)); + }); + + testWidgets('goes once nothing is logged under it', (t) async { + final labs = await _seed(t, const [], defs: lpa); + await _pumpLabs(t, labs); + + expect(find.text('Nothing logged under it'), findsOneWidget); + await t.tap(_control('Remove the Lp(a) marker')); + await t.pumpAndSettle(); + expect(find.text('Remove Lp(a)?'), findsOneWidget); + expect(find.textContaining('Nothing measured goes with it'), + findsOneWidget); + + await t.tap(find.text('Remove')); + await _untilGone(t, find.text('Markers you named')); + expect(find.text('Markers you named'), findsNothing); + expect((await t.runAsync(LocalDb.labMarkerDefs))!, isEmpty); + }); + }); +} diff --git a/test/ui2_metric_row_trend_test.dart b/test/ui2_metric_row_trend_test.dart new file mode 100644 index 00000000..46dee83c --- /dev/null +++ b/test/ui2_metric_row_trend_test.dart @@ -0,0 +1,155 @@ +// The trailing slot on an overview row: an arrow, not a sparkline. +// +// The arrow makes two claims the sparkline did not, so both are pinned here: +// +// · a DIRECTION — which only exists if the move clears half a standard +// deviation of the metric's own recent baseline, and only if there are +// enough recorded days to have a baseline at all; +// · a VERDICT — green or orange, which depends on the metric and not on the +// direction: resting heart rate falling is good news, HRV falling is not. +// +// And the direction has to be readable without the hue, because roughly one +// man in twelve cannot read the hue. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import 'package:openstrap_edge/ui2/ui2.dart'; + +List _flat(int n, double v) => List.filled(n, v); + +void main() { + group('trendOf', () { + test('a move clear of its own spread is a direction', () { + expect(trendOf(const [50, 51, 50, 52, 51, 53, 58, 59, 60]), + Trend.rising); + expect(trendOf(const [60, 59, 58, 61, 59, 60, 51, 50, 50]), + Trend.falling); + }); + + test('a move inside its own spread is steady, not a coin flip', () { + expect(trendOf(const [50, 53, 49, 52, 48, 51, 50, 52, 49]), Trend.steady); + }); + + test('too few recorded days is no answer at all, not a flat arrow', () { + expect(trendOf(const [50, 51, 50]), isNull); + expect(trendOf(const []), isNull); + // Six recorded values behind seven slots: the holes do not count. + expect(trendOf(const [50, null, 51, null, 50, null, 52, null, 51]), + isNull); + }); + + test('a baseline that truly sat still still reports a real move', () { + // MAD/SD of zero is not a reason to abstain — that is the readiness bug + // this project has already paid for once. + expect(trendOf([..._flat(6, 50), 55, 55, 55]), Trend.rising); + expect(trendOf(_flat(12, 50)), Trend.steady); + }); + }); + + group('the row', () { + Future

    pump(WidgetTester t, List rows, + {double textScale = 1.0}) async { + t.view.physicalSize = const Size(390 * 3, 1600 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(textScale)), + child: Scaffold(body: ListView(children: rows)), + ), + )); + await t.pumpAndSettle(); + return P.of(t.element(find.byType(MetricRow).first)); + } + + const rising = [50, 51, 50, 52, 51, 53, 58, 59, 60]; + const falling = [60, 59, 58, 61, 59, 60, 51, 50, 50]; + const steady = [50, 53, 49, 52, 48, 51, 50, 52, 49]; + + testWidgets('no sparkline is drawn beside the number', (t) async { + await pump(t, const [ + MetricRow(LucideIcons.activity, C.green, 'HRV', '64', + unit: 'ms', series: rising, rising: Rising.good), + ]); + expect(find.byType(CustomPaint).evaluate().where((e) { + final w = e.widget as CustomPaint; + return w.painter is LineChart; + }), isEmpty); + }); + + testWidgets('the glyph carries the direction, the hue carries the verdict', + (t) async { + final p = await pump(t, const [ + // Up, and up is good here. + MetricRow(LucideIcons.activity, C.green, 'HRV', '64', + unit: 'ms', series: rising, rising: Rising.good), + // Up, and up is bad here — same glyph, different hue. + MetricRow(LucideIcons.heart, C.red, 'Resting heart rate', '58', + unit: 'bpm', series: rising, rising: Rising.bad), + // Down, and down is good here. + MetricRow(LucideIcons.brain, C.purple, 'Stress', '31', + unit: '/100', series: falling, rising: Rising.bad), + ]); + final up = t + .widgetList(find.byIcon(LucideIcons.arrowUpRight)) + .toList(); + expect(up.length, 2, reason: 'both rose, so both point up'); + expect(up[0].color, p.on(C.green)); + expect(up[1].color, p.on(C.orange)); + final down = + t.widgetList(find.byIcon(LucideIcons.arrowDownRight)).single; + expect(down.color, p.on(C.green)); + }); + + testWidgets('a metric with no settled better direction gets no hue', + (t) async { + final p = await pump(t, const [ + MetricRow(LucideIcons.thermometer, C.orange, 'Skin temperature', '+0.3', + unit: '°', series: rising), + ]); + expect(t.widgetList(find.byIcon(LucideIcons.arrowUpRight)).single.color, + p.ink3); + }); + + testWidgets('a move inside the noise is flat and unjudged', (t) async { + final p = await pump(t, const [ + MetricRow(LucideIcons.activity, C.green, 'HRV', '50', + unit: 'ms', series: steady, rising: Rising.good), + ]); + expect(t.widgetList(find.byIcon(LucideIcons.arrowRight)).single.color, + p.ink3); + }); + + testWidgets('too little history draws nothing and says why', (t) async { + await pump(t, const [ + MetricRow(LucideIcons.wind, C.teal, 'Respiratory rate', '14.2', + unit: 'br/min', series: [50, 51, 50], rising: Rising.bad), + ]); + for (final i in const [ + LucideIcons.arrowUpRight, + LucideIcons.arrowDownRight, + LucideIcons.arrowRight, + ]) { + expect(find.byIcon(i), findsNothing); + } + // The absence carries its reason where an empty box cannot: a horizontal + // arrow here would claim a measured "no change". + expect( + find.bySemanticsLabel( + RegExp('no trend yet', caseSensitive: false)), + findsOneWidget); + }); + + testWidgets('the arrow survives large text at 390 pt', (t) async { + await pump(t, const [ + MetricRow(LucideIcons.heart, C.red, 'Resting heart rate', '58', + sub: 'OVERNIGHT', unit: 'bpm', series: rising, rising: Rising.bad), + ], textScale: 2.0); + expect(find.byIcon(LucideIcons.arrowUpRight), findsOneWidget); + expect(find.text('58'), findsOneWidget); + }); + }); +} diff --git a/test/ui2_revision_reload_test.dart b/test/ui2_revision_reload_test.dart new file mode 100644 index 00000000..7f02e2a3 --- /dev/null +++ b/test/ui2_revision_reload_test.dart @@ -0,0 +1,186 @@ +// A write landed underneath a screen that is already on screen. Does it show? +// +// The bug this pins: "I imported something, and to see those workouts in +// Workouts I have to switch to another tab and come back." Every screen here +// loads in `initState`, and three of the five tabs are kept alive by the +// shell's IndexedStack for the life of the process — so "loads once" means +// "until the app is relaunched", and leaving the tab was the workaround. +// +// The test below writes UNDERNEATH a live screen and asserts the screen +// updates while keeping the SAME State object. Asserting only that the text +// appears would pass for the same wrong reason switching tabs does — a fresh +// widget reading the database for the first time. + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/journal_fields.dart'; +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +/// The rows an import would have written, behind the repo the screen reads. +/// +/// Water is a JOURNAL metric: written by the journal screen, by an imported +/// journal CSV, and by this tab itself — exactly the "somebody else wrote it" +/// case the report is about. +class _Repo extends LocalRepository { + Map journal = const {}; + int reads = 0; + + @override + Future> getToday() async => const {}; + @override + Future> getJournalMetrics(String date) async { + reads++; + return journal; + } +} + +/// Frames until [f] appears, or give up. +/// +/// `runAsync` and not `pumpAndSettle`: the screen's load goes to sqflite, which +/// answers from another isolate in REAL time, and a widget test's fake clock +/// never lets that reply land. `pumpAndSettle` would not help either — the +/// loading state draws an indeterminate spinner, which never settles. +Future _until(WidgetTester t, Finder f, {int n = 60}) async { + for (var i = 0; i < n && f.evaluate().isEmpty; i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 20))); + await t.pump(); + } +} + +Widget _app(AppState app) => MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: const Scaffold(body: NutritionScreen()), + ), + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_revision_reload_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('water logged underneath the live tab reaches it', (t) async { + t.view.physicalSize = const Size(390 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + + final app = AppState.forTesting(); + addTearDown(app.dispose); + final repo = _Repo(); + app.repo = repo; + + await t.pumpWidget(_app(app)); + await _until(t, find.text('None yet')); + expect(find.text('None yet'), findsOneWidget); + + // The screen that is about to be asked to notice. + final before = t.state(find.byType(NutritionScreen)); + + // Something else writes — an import, the journal screen, a headless + // derive. All of them raise the one signal. + repo.journal = const {'water_ml': JournalMetricValue(1500)}; + app.bumpInsights(); + await _until(t, find.text('1.5 L')); + + expect(find.text('1.5 L'), findsOneWidget); + // THE POINT: same State, so the number arrived by re-reading and not by + // the screen being thrown away and built again — which is what leaving the + // tab and coming back used to do. + expect(identical(t.state(find.byType(NutritionScreen)), before), isTrue, + reason: + 'the screen was remounted — that is the workaround, not the fix'); + }); + + testWidgets('a rebuild is not a write', (t) async { + t.view.physicalSize = const Size(390 * 3, 2400 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + + final app = AppState.forTesting(); + addTearDown(app.dispose); + final repo = _Repo(); + app.repo = repo; + + await t.pumpWidget(_app(app)); + await _until(t, find.text('None yet')); + final first = repo.reads; + expect(first, greaterThan(0)); + + // AppState ticks at ~1 Hz with live HR and a log line. A refresh signal + // riding on that would re-read the database every second — the rebuild + // storm this must not become, and the harder bug of the two to diagnose. + for (var i = 0; i < 5; i++) { + app.notifyListeners(); + await t.pump(); + } + expect(repo.reads, first, + reason: 'the screen re-read with nothing having landed'); + }); + + // ── the signal has to be raised where the writes are ────────────────────── + // + // A behavioural test of the importers needs a vendor export, a database and + // the derivation engine; what it would actually be checking is one line, so + // this checks that line. Every import path lands rows into a database the + // live tabs have already finished reading. + test('every AppState importer raises the signal', () { + final src = File('lib/state/app_state.dart').readAsStringSync(); + for (final m in const [ + 'Future importNoopCsv(', + 'Future importWhoopCsvs(', + 'Future importEdgeBackup(', + ]) { + final at = src.indexOf(m); + expect(at, greaterThan(0), reason: '$m has moved or been renamed'); + // The method body, to its `return` — long enough to hold the whole of + // any of the three, short enough not to reach the next one. + final body = src.substring(at, at + 2600); + expect(body, contains('bumpInsights()'), + reason: '$m writes durable rows and no screen is told'); + } + }); + + test('the screens the shell keeps alive re-read', () { + // Home, Health, Nutrition, Workout, Wellness are the five tabs; Cycle + // lives inside Wellness and shares its lifetime. + for (final f in const [ + 'home_screen', + 'health_screen', + 'nutrition_screen', + 'workout_screen', + 'wellness_screen', + 'cycle_screen', + ]) { + expect(File('lib/ui2/screens/$f.dart').readAsStringSync(), + contains('with RevisionReload'), + reason: '$f loads once and never reads again'); + } + }); +} diff --git a/test/ui2_router_test.dart b/test/ui2_router_test.dart index 01759882..88a53ffc 100644 --- a/test/ui2_router_test.dart +++ b/test/ui2_router_test.dart @@ -17,16 +17,21 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; import 'package:openstrap_edge/app.dart'; import 'package:openstrap_edge/ble/ble_state.dart'; +import 'package:openstrap_edge/data/models.dart' show DeviceState; import 'package:openstrap_edge/notify/tap_router.dart'; import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/sync/paired_device.dart' show PairedDevice; import 'package:openstrap_edge/import/backup_crypto.dart'; import 'package:openstrap_edge/ui2/onboarding/pairing.dart'; import 'package:openstrap_edge/ui2/onboarding/profile_setup.dart'; import 'package:openstrap_edge/ui2/onboarding/welcome.dart' show isEncryptedBackup; +import 'package:openstrap_edge/ui2/screens/log_workout.dart' + show WorkoutSuggestionScreen; import 'package:openstrap_edge/ui2/screens/nutrition_screen.dart'; import 'package:openstrap_edge/ui2/profile/devices.dart'; import 'package:openstrap_edge/ui2/profile/profile.dart'; @@ -166,6 +171,23 @@ void main() { expect(screenForRoute('/nope'), isNull); }); + test('the detected-workout deep link opens on THAT bout', () { + // "We spotted ~42 min. Tap to log it." has to land on those 42 minutes. + // Landing on the Workouts tab was issue #113; landing on a list of every + // unreviewed bout is the same broken promise one screen further in. + const id = '2026-08-19:1755625800'; + final route = workoutSuggestionRoute(id); + expect(domainForRoute(route), ShellDomain.workout); + final screen = screenForRoute(route); + expect(screen, isA()); + expect((screen! as WorkoutSuggestionScreen).focusId, id); + // A payload from a build that carried no id still reviews everything. + expect( + (screenForRoute(kRouteWorkoutSuggestion)! as WorkoutSuggestionScreen) + .focusId, + isNull); + }); + test('the tab index the shell persists round-trips through the enum', () { for (final d in ShellDomain.values) { expect(ShellDomain.values[d.index], d); @@ -442,6 +464,108 @@ void main() { }); }); + // The dead end this group exists for: forget the band and the only route + // back to pairing disappeared. The pair affordance lived inside + // `sources.isEmpty`, and a phone reporting steps is a source — so one + // steps-only row was enough to hide it, and the app became unusable as a + // band app with no way to say so. + // + // Driven through the real screen over a real AppState, because reading the + // widget tree is exactly what missed it: both halves look right on their + // own. + group('the way back to pairing survives a forget', () { + testWidgets('the band goes, the phone stays, the pair affordance appears', + (tester) async { + _tallView(tester); + final app = AppState() + ..paired = PairedDevice('AA:BB:CC:DD:EE:FF', 'SER1') + ..phoneStepsEnabled = true + ..phoneStepsLastSyncedDays = 1 + ..phoneStepsLastTotal = 4200; + addTearDown(app.dispose); + + await tester.pumpWidget(ChangeNotifierProvider.value( + value: app, + child: MaterialApp( + theme: buildTheme(Brightness.light), home: const MyDevices()), + )); + expect(find.text('WHOOP band'), findsOneWidget); + expect(find.text('Pair a band'), findsNothing); + + // Forget. `unpair()` itself is platform-bound (ASK, the engine, the + // foreground service); what it leaves behind for this screen is this. + app.paired = null; + app.notifyListeners(); + await tester.pump(); + + expect(find.text('WHOOP band'), findsNothing); + expect(find.text('This phone'), findsOneWidget, + reason: 'the phone row is what used to swallow the empty state'); + expect(find.text('Pair a band'), findsOneWidget); + }); + + testWidgets('and for someone who only ever had the phone', (tester) async { + _tallView(tester); + var pairs = 0; + await tester.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: MyDevicesView( + sources: [ + const HealthSource( + name: 'This phone', + kind: 'Motion coprocessor', + tier: SourceTier.phone, + icon: LucideIcons.smartphone, + connected: true), + ], + onPair: () => pairs++, + ), + )); + await tester.tap(find.text('Pair a band')); + expect(pairs, 1); + }); + + // The other half of the same dead end: getting back to pairing is no good + // if the band you pair next inherits the forgotten one's identity. The + // engine holds one DeviceState for the life of the process. + test('forgetting drops what the old band said about itself', () { + final d = DeviceState(connection: 'connected') + ..serial = 'SER1' + ..strapName = 'Old band' + ..generation = 'gen4' + ..batteryPct = 71 + ..autoReconnectPaused = true + ..bondRefusals = 5; + d.reset(); + expect(d.serial, isNull); + expect(d.strapName, isNull); + expect(d.generation, isNull, + reason: 'a gen5 band must not be calibrated as the gen4 it replaced'); + expect(d.batteryPct, isNull); + expect(d.connection, 'disconnected'); + expect(d.autoReconnectPaused, isFalse, + reason: 'the next band starts with a clean reconnect loop'); + expect(d.bondRefusals, 0); + }); + + testWidgets('a paired band is not asked to pair again', (tester) async { + _tallView(tester); + await tester.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: MyDevicesView(sources: [ + const HealthSource( + name: 'WHOOP 4.0', + kind: '', + tier: SourceTier.wristOptical, + icon: LucideIcons.watch, + connected: true, + isBand: true), + ], onPair: () {}), + )); + expect(find.text('Pair a band'), findsNothing); + }); + }); + test('byte sizes read like sizes', () { expect(formatBytes(512), '512 B'); expect(formatBytes(1536), '1.5 KB'); diff --git a/test/ui2_tokens_test.dart b/test/ui2_tokens_test.dart index 465025ee..bc14ebfd 100644 --- a/test/ui2_tokens_test.dart +++ b/test/ui2_tokens_test.dart @@ -198,10 +198,21 @@ const _notComponents = { 'NotificationSettings', 'NotificationSettingsView', 'EditProfile', 'EditProfileView', 'DataScreen', 'AlarmScreen', 'AlarmScreenView', 'MyDevices', 'MyDevicesView', 'DeviceDetail', 'DeviceDetailView', 'RePair', + // The strap-buzz relay picker: a Scaffold route over a live + // NotificationRelay, whose list is whatever the OS notification stream has + // handed us this session. `BandNotificationsView` is the pure half and is + // what `band_notifications_test.dart` pumps. + 'BandNotifications', 'BandNotificationsView', // Both are Scaffold routes that read the database and ask the OS for a // permission on tap — a gallery case would either mock all of that or // trigger a real health-store prompt from a screenshot sweep. 'PhoneImport', 'AutomationSettings', + // The double-tap picker. A Scaffold route whose whole content is decided by + // what the OS answered to a method channel, so a gallery case would be a + // photograph of a fixture rather than of the screen. Rendered instead by + // band_gestures_test.dart, at a real phone width, in both the has-native and + // the native-unreachable state. + 'BandGestures', 'BandGesturesView', // FULL-BLEED, so it is a screen element rather than a component: it takes // the whole window width back off its parent's padding via OverflowBox. The // gallery lays every case out in a ~179 logical-px cell, which is narrower @@ -245,4 +256,10 @@ const _notComponents = { 'LiveFlow', 'LiveMatch', 'LiveInterval', // the activity flow: pick → set up → do → summarise → share 'ActivityPicker', 'ActivitySetup', 'ActivitySummary', 'ShareSheet', + // The two write routes for a session the band did not capture as it + // happened. Both are Scaffolds that read `sessions` / `workout_suggestions` + // and write through the repo; the second also asks the OS for a date and a + // time picker on tap. Covered by `log_workout_test.dart`, which pumps each + // at a real phone width against injected rows. + 'WorkoutSuggestionScreen', 'LogWorkout', }; diff --git a/test/ui2_wellness_meds_not_due_test.dart b/test/ui2_wellness_meds_not_due_test.dart new file mode 100644 index 00000000..b6269c54 --- /dev/null +++ b/test/ui2_wellness_meds_not_due_test.dart @@ -0,0 +1,103 @@ +// "in wellness medication, after I added medication, the medicine component — +// the actual tracking component — isn't visible." +// +// `_medication` branched on `_meds`, then rendered `_slots`. They are not the +// same list: a medication due on days that are not today — or added after its +// own time had already passed, which `slotsForDay` deliberately does not +// backfill — leaves `_meds` non-empty and `_slots` empty, and the `Surface` +// drew an empty Column. Added a medication, no tracker, no reason given. +// +// One case, at one size: a medication present, nothing due today. The tab has +// to say why and print the schedule instead of an empty box. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/med_store.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('a medication with nothing due today says so', (t) async { + t.view.physicalSize = const Size(390 * 3, 844 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + + // `runAsync`, not a bare await: sqflite answers on the real event loop and + // the test zone's fake clock never gets there on its own. + await t.runAsync(() async { + final db = await LocalDb.instance; + await db.delete('med_def'); + await db.delete('med_dose'); + // Due only on the day AFTER today, so this never resolves a slot for the + // day the screen is on, whatever day the suite runs. + final tomorrow = DateTime.now().add(const Duration(days: 1)).weekday; + await MedDb.putDef( + db, + MedDef( + key: 'vitamin_d', + label: 'Vitamin D', + schedule: [MedSchedule(8 * 60, [tomorrow])], + ), + ); + }); + + final app = AppState.forTesting(); + addTearDown(app.dispose); + + // The deep link the dose reminder uses, which is what makes an invisible + // tracker reachable from the lock screen. + WellnessScreen.tabRequest.value = WellnessScreen.medsTab; + addTearDown(() => WellnessScreen.tabRequest.value = -1); + + await t.pumpWidget( + MaterialApp( + theme: buildTheme(Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: Builder( + builder: (c) => Scaffold( + backgroundColor: P.of(c).bg, + body: const WellnessScreen(), + ), + ), + ), + ), + ); + // sqflite answers on a real event loop; pumping alone leaves the spinner up + // and every finder below passing against an empty tab. + for (var i = 0; i < 40; i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await t.pump(const Duration(milliseconds: 16)); + if (find.byType(CircularProgressIndicator).evaluate().isEmpty) break; + } + for (var i = 0; i < 6; i++) { + await t.pump(const Duration(milliseconds: 32)); + } + + expect(find.byType(CircularProgressIndicator), findsNothing, + reason: 'the tab never loaded, so nothing after this is a test'); + // Not "Nothing scheduled" — something IS scheduled, just not today. + expect(find.text('Nothing due today'), findsOneWidget); + expect(find.text('Nothing scheduled'), findsNothing); + // What you take and when, which is the reason itself. + expect(find.text('Vitamin D'), findsOneWidget); + expect(find.textContaining('08:00'), findsWidgets); + expect(find.byType(MedRow), findsNothing); + }, timeout: const Timeout(Duration(seconds: 60))); +} diff --git a/test/ui2_wellness_recovery_paint_test.dart b/test/ui2_wellness_recovery_paint_test.dart new file mode 100644 index 00000000..79c807c5 --- /dev/null +++ b/test/ui2_wellness_recovery_paint_test.dart @@ -0,0 +1,403 @@ +// "in wellness screen when we enter recovery tab everything becomes grey, +// except bottom navbar." +// +// That sentence is a precise description of ONE failure and no other, and it +// is worth writing down because reading the widget tree will never find it. +// +// `AppShell` puts the domain in `Scaffold.body` and the tab row in +// `Scaffold.bottomNavigationBar` — SIBLINGS. So anything that throws while the +// domain BUILDS is caught by the framework, that whole subtree is replaced by +// an `ErrorWidget`, and the bar beside it is untouched. `RenderErrorBox` paints +// `0xF0C0C0C0` — "red in debug mode, a light gray otherwise" — so on a release +// build a single exception inside `_recovery` is, pixel for pixel, a grey page +// under a normal nav bar. Nothing about it looks like a crash. +// +// The consequence for testing: a green `flutter test` proves nothing here, +// because the framework SWALLOWS the throw. `FlutterError.onError` has to be +// captured and asserted on, and the render tree has to be walked, or this +// exact bug reports as a pass. +// +// So this renders the real screen — the real `_load`, the real repository +// seam, the real ListView — walks to Recovery the way a thumb does, and then +// asserts three things, in the order they would fail: +// +// 1. nothing was reported to `FlutterError.onError` while the tab came up, +// 2. no `ErrorWidget` is in the tree and no `RenderErrorBox` is in the RENDER +// tree — the substitution happens at paint, so the second is the one that +// cannot be satisfied by a page that has stopped drawing, +// 3. the tab's first card is laid out with a real height. +// +// Light and dark, 1.0x and 2.0x text, at 390 pt — the narrow phone and the +// accessibility size are where this screen's cards have failed before. + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/data/journal_fields.dart'; +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +const _day = '2026-08-16'; + +/// A day with everything Recovery can show: a debt worth a recommendation, a +/// learned need, and four readiness inputs — one that helped, one that moved +/// inside its own spread, one that could not be used, and the raw skin-temp +/// ADC whose numbers are suppressed. Every field is the WIRE name; a fixture +/// built on `contribution` instead of `weighted_contribution` passes its own +/// fiction and reports every driver as "neither". +class _Repo extends LocalRepository { + _Repo({this.insights, this.stress}); + + /// Overrides the healthy fixture, for the hostile-leaf case. + final Map? insights; + final Map? stress; + + @override + Future> getToday() async => const { + 'status': {'today_day': _day} + }; + + @override + Future> getDayStress(String date) async => + stress ?? + const { + 'readiness': {'value': 62.0, 'confidence': 0.8, 'tier': 'HIGH'}, + 'stress': {'score': 34}, + }; + + @override + Future> getInsights() async => + insights ?? + const { + 'readiness_glassbox': { + 'value': { + 'breakdown': [ + { + 'label': 'hrv', + 'used': true, + 'weight': 0.40, + 'weighted_contribution': -6.2, + 'past_mdc': true, + }, + { + 'label': 'rhr', + 'used': true, + 'weight': 0.30, + 'weighted_contribution': 3.1, + 'past_mdc': false, + }, + { + 'label': 'resp', + 'used': false, + 'weight': 0.15, + 'note': 'need_baseline:have=2,need=7', + }, + { + 'label': 'temp', + 'used': true, + 'weight': 0.15, + 'weighted_contribution': -1.4, + 'past_mdc': true, + }, + ], + }, + }, + 'sleep_debt': { + 'value': {'debt_hours': 1.4} + }, + 'sleep_coach': { + 'need': { + 'value': {'need_sec': 28800.0} + }, + 'bedtime': { + 'value': {'bedtime_min_of_day': 1380} + }, + 'wake': { + 'value': {'wake_min_of_day': 420} + }, + 'nap_credit_min': 20, + 'strain_bonus_min': 15, + }, + }; + + @override + Future> getDayHeart(String date) async => const { + 'baselines': { + 'hrv': { + 'value': 42.0, + 'baseline': 51.0, + 'spread': 4.0, + 'delta': -9.0, + 'mdc_multiples': -1.6, + }, + 'resting_hr': { + 'value': 54.0, + 'baseline': 56.0, + 'spread': 2.0, + 'delta': -2.0, + 'mdc_multiples': -0.4, + }, + 'skin_temp': { + 'value': 32411.0, + 'baseline': 32380.0, + 'spread': 20.0, + 'delta': 31.0, + 'mdc_multiples': 1.1, + }, + }, + }; + + /// `{t, v}` and dated off NOW, which is what `pointsOf` and `denseDays` + /// actually read. A `{day, value}` fixture parses to an EMPTY series, every + /// driver comes back `chartable: false`, and the expandable half of this + /// card silently stops being covered. + @override + Future> getChart(String metric, + {int? from, int? to}) async { + final midnight = DateTime.now().copyWith( + hour: 12, minute: 0, second: 0, millisecond: 0, microsecond: 0); + return { + 'points': [ + for (var back = 29; back >= 0; back--) + { + 't': midnight + .subtract(Duration(days: back)) + .millisecondsSinceEpoch ~/ + 1000, + 'v': 40.0 + back % 7, + } + ] + }; + } + + @override + Future> getJournalMetrics( + String date) async => + {}; + + @override + Future> getJournalFields() async => const []; +} + +/// `_load` goes to sqflite for medication, breathing and the habit history, and +/// sqflite answers on a REAL event loop. Pumping alone never lets those futures +/// complete, so the screen sits on its spinner and every assertion below passes +/// against an empty tab — which is how a test like this quietly stops testing +/// anything, which is why there is a spinner check after it. +/// +/// So: real time to let the queries answer, a pump to commit the `setState` +/// they land in, and POLL rather than guess a duration — a fixed delay that is +/// long enough on this machine is a flake on a slower one. Returns whether the +/// spinner ever went away; the caller asserts on it, but only once it has put +/// `FlutterError.onError` back. +Future _settleLoad(WidgetTester t) async { + for (var i = 0; i < 40; i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 20))); + await t.pump(const Duration(milliseconds: 16)); + if (find.byType(CircularProgressIndicator).evaluate().isEmpty) { + await _frames(t); + return true; + } + } + return false; +} + +/// Fake time only — enough for a `setState` and the chip's transition. +Future _frames(WidgetTester t) async { + for (var i = 0; i < 12; i++) { + await t.pump(const Duration(milliseconds: 32)); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + setUp(() => SharedPreferences.setMockInitialValues({})); + + for (final dark in [false, true]) { + for (final scale in [1.0, 2.0]) { + final where = '${dark ? 'dark' : 'light'}, ${scale}x text'; + + testWidgets('recovery paints its cards — $where', (t) async { + await _openRecovery(t, dark: dark, scale: scale, repo: _Repo()); + + expect(find.text('What charged and drained you'), findsOneWidget); + expect(find.text('Sleep need tonight'), findsOneWidget); + expect(find.byType(DriverBreakdown), findsOneWidget); + _expectCardPainted(t); + }, timeout: const Timeout(Duration(seconds: 60))); + + // ONE UNREADABLE LEAF MUST COST THAT LEAF, NOT THE SCREEN. + // + // `_recovery` is called from `WellnessScreen.build`, so every cast and + // every `.round()` in it is load-bearing for the whole domain: `x as + // num?` tolerates null and nothing else, and `.round()` throws on NaN + // and infinity (`jsonDecode('1e999')` is `Infinity`). Each of the six + // leaves below used to be one of those, and any one of them turned the + // page into a flat `0xF0C0C0C0` rectangle with the nav bar beside it. + // + // The write seam already refuses to let one bad leaf cost the artifact + // (`sanitizeForJson`); this is the same rule on the read side, and this + // is the test that says so. Both themes, because a screen that has + // stopped painting looks different in each. + testWidgets('a leaf of the wrong type costs its row, not the page — ' + '$where', (t) async { + await _openRecovery( + t, + dark: dark, + scale: scale, + repo: _Repo( + insights: const { + 'sleep_coach': { + // Not a Map: the `need` envelope flattened to a scalar. + 'need': 12345, + 'bedtime': { + // Not finite: `1e999` off the wire. + 'value': {'bedtime_min_of_day': double.infinity} + }, + 'wake': { + // Not a num. + 'value': {'wake_min_of_day': '07:00'} + }, + 'nap_credit_min': 'twenty', + 'strain_bonus_min': double.nan, + }, + // The envelope's own `value` is a String, not a map or a number. + 'sleep_debt': {'value': 'a lot'}, + }, + stress: const { + // The Mind tab's two casts, same method, same blast radius. + 'stress': {'score': 'high', 'level': 7}, + }, + ), + ); + + // Every one of those is now ABSENT, which is a state this screen + // already renders honestly — so the section is still here and still + // says why, rather than the page being gone. + expect(find.text('Sleep need tonight'), findsOneWidget); + expect(find.text('No sleep need yet'), findsOneWidget); + _expectCardPainted(t); + }, timeout: const Timeout(Duration(seconds: 60))); + } + } +} + +/// TWO framework-internal assertions this harness causes and the screen does +/// not, dropped BY NAME rather than by widening the filter. +/// +/// `_load` awaits sqflite, whose continuations only run when fake time is +/// advanced, so real delays have to be interleaved with pumped frames to get +/// the screen loaded at all. That interleaving lets the semantics tree be +/// compiled while layout is still dirty, and `flushSemantics` says so. Both are +/// debug-only asserts inside the framework's own semantics pass; neither can +/// produce an `ErrorWidget`, which is what this file is about. +/// +/// Everything else fails — every `TypeError`, `UnsupportedError`, `RangeError` +/// and `FlutterError`, which is the entire class of bug that greys the page. +bool _harnessArtifact(String message) => + message.contains('parentDataDirty') || + message.contains('!childSemantics.renderObject._needsLayout'); + +/// Pump the real screen, let its real load finish, and tap through to Recovery +/// the way a thumb does — then assert that the tab switch itself reported +/// nothing. That assertion is the point: the framework SWALLOWS a build throw +/// into an `ErrorWidget`, so without capturing `FlutterError.onError` the grey +/// page reports as a passing test. +Future _openRecovery( + WidgetTester t, { + required bool dark, + required double scale, + required LocalRepository repo, +}) async { + t.view.physicalSize = const Size(390 * 3, 844 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + + final errors = []; + final previous = FlutterError.onError; + FlutterError.onError = errors.add; + addTearDown(() => FlutterError.onError = previous); + + final app = AppState.forTesting(); + addTearDown(app.dispose); + app.repo = repo; + + await t.pumpWidget(MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: MaterialApp( + theme: buildTheme(dark ? Brightness.dark : Brightness.light), + home: ChangeNotifierProvider.value( + value: app, + child: Builder( + builder: (c) => Scaffold( + backgroundColor: P.of(c).bg, + body: const WellnessScreen(), + ), + ), + ), + ), + )); + final loaded = await _settleLoad(t); + + // Cleared, NOT asserted on. The Mind tab this opens on has a pre-existing + // layout complaint of its own (`StartCard`'s `Spacer` sits in a `Column` + // that a `ListView` hands unbounded height), and this test is about the tab + // that comes next. Only what the switch to Recovery reports is in scope. + errors.clear(); + await t.tap(find.text('Recovery'), warnIfMissed: false); + await _frames(t); + + // RESTORED BEFORE THE FIRST expect. A failing expectation while the test + // still holds `FlutterError.onError` trips an assert inside the binding's + // own error handler, and the test then hangs until its timeout instead of + // reporting what actually went wrong. + FlutterError.onError = previous; + expect(loaded, isTrue, + reason: 'the fixture never loaded, so nothing after this is a test'); + expect( + errors + .map((e) => e.exception.toString()) + .where((m) => !_harnessArtifact(m)) + .toList(), + isEmpty, + reason: 'a throw inside the tab body is swallowed into an ErrorWidget — ' + 'grey on a release build, and green here unless this is asserted', + ); + expect(find.byType(ErrorWidget), findsNothing); +} + +/// The tab is REALLY THERE — not replaced by the thing that paints it grey. +/// +/// The grey page is a PAINT-time substitution: `RenderErrorBox` is spliced in +/// where the failing subtree was, so every finder above can be satisfied by a +/// tree that draws one flat rectangle over the whole page. Walking the RENDER +/// tree for that box is the assertion that cannot be. +void _expectCardPainted(WidgetTester t) { + final boxes = []; + void walk(RenderObject r) { + if (r is RenderErrorBox) boxes.add(r); + r.visitChildren(walk); + } + + walk(t.binding.rootElement!.renderObject!); + expect(boxes, isEmpty, + reason: 'a RenderErrorBox is in the tree — that is the grey page, and it ' + 'paints 0xF0C0C0C0 over everything the failing subtree covered'); + + // …and the tab's first card is laid out with a real height. An ErrorWidget + // has no `Surface` under it at all, so this fails before the walk above even + // gets a chance to — which is the belt to that braces. + expect(t.getSize(find.byType(Surface).first).height, greaterThan(0)); +} diff --git a/test/v25_refusal_test.dart b/test/v25_refusal_test.dart index 19ac30e9..80cd23da 100644 --- a/test/v25_refusal_test.dart +++ b/test/v25_refusal_test.dart @@ -44,18 +44,21 @@ void main() { await LocalDb.close(); }); - test('protocol still hands us the vector — this is the thing we refuse', () { - // Not a change request against protocol (SEALED): asserted so that if the - // decoder ever DOES change, this test tells whoever changed it that edge - // is deliberately dropping the record. + test('protocol hands us no vector at all now — and we still drop the record', + () { + // This used to assert the opposite: protocol handed over a "gravity" + // vector from inner[69/71/73] and edge dropped the record anyway. Those + // offsets were refuted on real data and protocol 60676cf stopped emitting + // them, so `accelG` is empty — absent, not (0,0,0), the same idiom gen5's + // `gravityG` uses. Asserted so that if the decoder changes again, whoever + // changes it learns edge is deliberately dropping the record either way. final r = proto.FirmwareAwareR24Decoder().decode(proto.hexToBytes(_v25a)); expect(r, isNotNull); expect(r!.histVersion, 25); expect(r.hr, 0, reason: 'v25 carries no heart rate'); - // The tell: the same "y" value on both records, and a "z" of zero. + expect(r.accelG, isEmpty, reason: 'absent, never a still wrist'); final s = proto.FirmwareAwareR24Decoder().decode(proto.hexToBytes(_v25b))!; - expect(r.accelG[1], s.accelG[1], reason: 'a wrist axis that never moves'); - expect(r.accelG[2], 0.0); + expect(s.accelG, isEmpty); }); test('decodeSubstrate drops v25 rather than banking a still wrist', () { diff --git a/test/widget_service_sentinels_test.dart b/test/widget_service_sentinels_test.dart index f94cbfb7..03a9ae67 100644 --- a/test/widget_service_sentinels_test.dart +++ b/test/widget_service_sentinels_test.dart @@ -147,16 +147,23 @@ void main() { group('readiness banding', () { test('tiers at the boundaries', () { expect(readinessBand(100).tier, 3); - expect(readinessBand(80).tier, 3); - expect(readinessBand(79.9).tier, 2); - expect(readinessBand(65).tier, 2); - expect(readinessBand(60).tier, 2); - expect(readinessBand(59.9).tier, 1); - expect(readinessBand(40).tier, 1); - expect(readinessBand(38).tier, 0); + expect(readinessBand(61).tier, 3); + expect(readinessBand(60.9).tier, 2); + expect(readinessBand(50).tier, 2); + expect(readinessBand(37).tier, 2); + expect(readinessBand(36.9).tier, 1); + expect(readinessBand(26).tier, 1); + expect(readinessBand(25.9).tier, 0); expect(readinessBand(0).tier, 0); }); + // The bug the cut-offs above exist to fix (#250): the score's centre is 50 + // by construction, so whatever band contains 50 is the one a typical night + // gets. It must not be a warning. + test('a night at personal median is the neutral band, not a warning', () { + expect(readinessBand(50).label, 'Steady'); + }); + test('an unscored day is tier -1, which every native reader paints grey', () { expect(readinessBand(null).tier, -1); @@ -171,9 +178,9 @@ void main() { test('the tier and its label are published for the native surfaces', () async { await WidgetService.push(TodayData.fromJson({ - 'daily': {'readiness': 65}, + 'daily': {'readiness': 50}, })); - expect(written['readiness'], 65); + expect(written['readiness'], 50); expect(written['readiness_tier'], 2); expect(written['readiness_band'], 'Steady'); }); @@ -193,6 +200,125 @@ void main() { }); }); + // The three home rings, resolved in Dart because two of their four states + // CANNOT be worked out natively: the calibration counts and the pipeline's + // reason both live in a metric's `note`, which never used to cross the App + // Group. The widget drew one dimmed empty circle for both, so "four more + // nights and this fills in" and "the band recorded nothing" were the same + // picture, forever. + group('the home rings', () { + test('a measured ring publishes the number, what it is out of, and a sweep', + () async { + await WidgetService.push(TodayData.fromJson({ + 'daily': { + 'readiness': 74, + 'strain': 12.4, + }, + 'sleep': {'duration_min': 437, 'need_min': 465}, + })); + expect(written['ring_recovery_state'], 0); + expect(written['ring_recovery_value'], '74'); + expect(written['ring_recovery_sub'], 'Good to go'); + expect(written['ring_strain_value'], '12.4'); + expect(written['ring_strain_sub'], 'of 21'); + expect(written['ring_sleep_value'], '7h 17m'); + expect(written['ring_sleep_sub'], 'of 7h 45m'); + expect(written['ring_sleep_frac'], closeTo(437 / 465, 1e-9)); + // Nothing is missing, so nothing has a reason. + expect(written['ring_recovery_why'], ''); + }); + + // The one absence that is PROGRESS rather than a gap, and the only one a + // ring may honestly draw an arc for. + test('a baseline still filling is calibration progress, not a low score', + () async { + await WidgetService.push(TodayData.fromJson({ + 'daily': { + 'readiness': {'value': null, 'note': 'need_baseline:have=2,need=5'}, + }, + })); + expect(written['readiness'], -1); + expect(written['ring_recovery_state'], 1); + expect(written['ring_recovery_value'], 'Calibrating'); + expect(written['ring_recovery_sub'], '2 of 5 nights'); + expect(written['ring_recovery_frac'], closeTo(0.4, 1e-9)); + }); + + test('an absence is a word and a reason — never a dash, never an arc', + () async { + await WidgetService.push(TodayData.fromJson({ + 'daily': {'readiness': null}, + 'sleep': const {}, + })); + expect(written['ring_sleep_state'], 2); + expect(written['ring_sleep_value'], 'No sleep'); + expect(written['ring_sleep_frac'], -1.0); + // Every absent ring says something. A blank circle says nothing at all, + // which on a home screen is worse than a number. + for (final r in const ['recovery', 'strain', 'sleep']) { + expect(written['ring_${r}_value'], isNotEmpty, reason: r); + expect(written['ring_${r}_value'], isNot(contains('—')), reason: r); + expect(written['ring_${r}_why'], isNotEmpty, reason: r); + } + }); + + test('sleep with no learned need is measured but unscaled, not filled to 8h', + () async { + await WidgetService.push(TodayData.fromJson({ + 'sleep': {'duration_min': 437}, + })); + expect(written['ring_sleep_state'], 0); + expect(written['ring_sleep_value'], '7h 17m'); + expect(written['ring_sleep_sub'], 'No target yet'); + expect(written['ring_sleep_frac'], -1.0); + }); + }); + + // `getToday` holds the last night that scored over until today's settles, so + // every morning before the first sync the overnight block belongs to the + // night BEFORE last. Home refuses those numbers rather than printing them in + // the today slot (`overnightMetric`), and a home screen is the surface where + // a number is read as today's hardest of all. + group('a night that is not today\'s', () { + Map heldOver(String state) => { + 'daily': {'readiness': 74, 'resting_hr': 52, 'strain': 9.1}, + 'sleep': {'duration_min': 437}, + 'hrv': {'rmssd': 62.0, 'baseline': 58.0}, + 'status': { + 'showing_prior_overnight': true, + 'overnight_state': state, + 'overnight_day': todayLabel(), + }, + }; + + test('its numbers are refused and the reason travels in their place', + () async { + await WidgetService.push(TodayData.fromJson(heldOver('missing'))); + expect(written['readiness'], -1); + expect(written['readiness_tier'], -1); + expect(written['sleep_min'], -1); + expect(written['hrv'], -1); + expect(written['rhr'], -1); + expect(written['ring_recovery_value'], 'Not scored'); + expect(written['ring_recovery_why'], + 'Nothing from last night has reached the app yet.'); + expect(written['overnight_why'], + 'Nothing from last night has reached the app yet.'); + }); + + test('a night still being worked out is a different sentence — it resolves ' + 'on its own and asks nothing of anyone', () async { + await WidgetService.push(TodayData.fromJson(heldOver('building'))); + expect(written['ring_sleep_why'], 'Last night is still being worked out.'); + }); + + test('the DAY\'s strain is not an overnight figure and survives', () async { + await WidgetService.push(TodayData.fromJson(heldOver('missing'))); + expect(written['strain'], 9.1); + expect(written['ring_strain_value'], '9.1'); + }); + }); + // The widget, the Watch mirror and the Siri intents all render whatever was // last written here, with no way to notice how old it is — the native readers // gate on `has_data` and nothing else. So a snapshot the app KNOWS is old has diff --git a/test/workout_calorie_anchors_test.dart b/test/workout_calorie_anchors_test.dart index 2afb1cbf..ba26c29a 100644 --- a/test/workout_calorie_anchors_test.dart +++ b/test/workout_calorie_anchors_test.dart @@ -73,6 +73,7 @@ void main() { DerivationEngine.wakeDayEnergy( [for (var i = 0; i < 60; i++) 140.0], profile: _anchored, + restingHr: 55, deviceFamily: 'gen4', ), isNull, @@ -87,6 +88,7 @@ void main() { heightCm: 178, sex: 'm', ), + restingHr: 55, deviceFamily: 'gen4', ), isNotNull, @@ -105,10 +107,29 @@ void main() { heightCm: 178, sex: 'm', ), + restingHr: 55, ), isNotNull, reason: 'Tanaka is an age formula, not a calibration constant', ); + + // The active gate is a %HRR flex point, so it needs the LOWER reserve + // anchor too. Without one there is no gate and every wake minute bills as + // active, which is a bigger lie than an absent figure. + expect( + DerivationEngine.wakeDayEnergy( + [for (var i = 0; i < 60; i++) 140.0], + profile: const Profile( + ageYears: 34, + weightKg: 72, + heightCm: 178, + sex: 'm', + ), + restingHr: null, + ), + isNull, + reason: 'no resting HR, no active gate', + ); }); });