Privacy Policy — Edge / OpenStrap
- Last updated: July 27, 2026
+ Last updated: August 18, 2026
Edge ("the App") is an independent, open-source project. It is not affiliated
with, sponsored by, or endorsed by WHOOP, Inc.
@@ -66,6 +66,38 @@ Anonymous diagnostics
Google's Firebase privacy & security documentation:
firebase.google.com/support/privacy.
+ 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.
+
+ - 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 goes with it. Like any network
+ request it discloses your IP address to them.
+ - Only when you scan. There is no background lookup, no
+ batch and no pre-fetch.
+ - A barcode you have scanned before is answered from your own
+ phone. The App keeps a local copy of what it has fetched, so
+ re-scanning the same packet asks nobody anything.
+ - The camera reads digits and nothing else. No photo is
+ taken, stored or sent. Declining camera access leaves the rest of the
+ food log working.
+ - Everything still works with it off. Typing the
+ numbers off the pack was always the way in and still is.
+
+ You can turn it off at any time in Settings › Privacy ›
+ “Look barcodes up online”, and the App then makes no
+ food-related network request at all.
+ Their data is contributed by the public, is licensed under the
+ Open Database
+ License, and their own terms say it must not be used for medical
+ purposes. So a scanned figure is treated as something you typed rather
+ than something the App measured: anything that fails a basic plausibility
+ check is left blank instead of filled in, and every filled box is yours to
+ edit before you save.
+
Location and workout routes
If you record a run, ride or walk, the App uses your device's location to
draw that workout's route. This is the most sensitive permission the App
@@ -112,7 +144,13 @@
Optional, user-initiated integrations
your own API key.
Health app integration — if you enable it, the App can
write derived daily metrics to Apple Health or Google Health Connect, which
- are controlled by your device's own OS-level health app, not by us.
+ are controlled by your device's own OS-level health app, not by us. The App
+ can also read from that same health app when you tap an import —
+ your height, weight, date of birth and sex, your resting heart rate, blood
+ pressure, blood glucose and body temperature readings, and your workouts and
+ their routes. Every import is something you start by hand, never a background
+ sync, and what it reads stays on your device: reading from your health app
+ sends nothing anywhere.
What we don't do
@@ -126,7 +164,9 @@ 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.
+ settings. Barcode lookup for the food log is off by default and can be
+ turned off again at any time in Settings › Privacy ›
+ “Look barcodes up online”.
Uninstalling the App deletes all of your locally stored data immediately.
That's the whole picture unless you had separately enabled one of
diff --git a/guides/TASKER_INTEGRATION.md b/guides/TASKER_INTEGRATION.md
index 71db8336..53e5dfa2 100644
--- a/guides/TASKER_INTEGRATION.md
+++ b/guides/TASKER_INTEGRATION.md
@@ -1,4 +1,4 @@
-# Tasker integration — buzz your strap
+# Tasker integration — buzz your strap, and hear back
OpenStrap Edge can vibrate your WHOOP strap in response to a Broadcast intent
from Tasker (or any automation app). Use it to buzz the strap for phone calls,
@@ -60,6 +60,43 @@ omitted. `pattern:1` buzzes until acked by tapping the strap, which makes it
ideal for phone-call notifications. See [BUZZ_MEANINGS.md](BUZZ_MEANINGS.md)
for the full list.
+## The other direction: OpenStrap tells Tasker
+
+**Android only.** iOS does not get this and is not going to: a Shortcuts
+personal automation can only trigger on a fixed system list of events, and
+there is no public mechanism for an app to add one. Donating an intent buys
+Siri suggestions and discoverability, not a trigger. So on iOS you get more
+things *you* can invoke; you do not get events that invoke *your* shortcut.
+
+One event ships today. When an offload from the strap finishes, the app sends
+a broadcast:
+
+| Field | Value |
+|---|---|
+| Action | `wtf.openstrap.openstrap_edge.SYNC_COMPLETE` |
+| Extra `records` | int — how many records that sync stored |
+| Extra `at` | int — unix seconds when it finished |
+
+Catch it with a Tasker Profile → **Event → System → Intent Received**, with
+Action set to the string above. Leave Package empty.
+
+Rate-limited to one outbound event a minute, so a reconnect storm cannot spam
+your profile.
+
+### What the extras deliberately do not contain
+
+No token. This is an implicit broadcast, so anything on your phone can read
+the extras — putting your buzz token in there would hand every installed app
+the ability to buzz your strap, which is the one thing that token exists to
+stop. There is nothing to protect in this direction: the worst a forged
+`SYNC_COMPLETE` can do is run your own profile early.
+
+No readiness, no recovery, no strain, no sleep. Those numbers are sometimes
+*absent* — not zero, absent, with a reason attached — and the app shows you
+which. Once a number leaves the app that context is gone, and a Shortcut that
+received `readiness=0` would have no way to tell "you scored zero" from "we
+did not measure it". Only facts about the sync itself go out.
+
## Troubleshooting
- Rapid repeat broadcasts are rate-limited (about 1.5 s between accepted
diff --git a/ios/OpenStrapIntents.swift b/ios/OpenStrapIntents.swift
index d4915e81..f742cfc7 100644
--- a/ios/OpenStrapIntents.swift
+++ b/ios/OpenStrapIntents.swift
@@ -14,40 +14,72 @@ import Foundation
enum OpenStrapShared {
static var appGroup: String {
Bundle.main.object(forInfoDictionaryKey: "OpenStrapAppGroupIdentifier") as? String
- ?? "group.wtf.openstrap"
+ // Same fallback as AppGroup.swift and WidgetService.fallbackAppGroupId:
+ // the build-configured default (ios/Config/Signing.defaults.xcconfig).
+ // Three different fallbacks for one group meant that if Info.plist ever
+ // went missing, Siri and the widget would read different suites.
+ ?? "group.com.example.openstrap"
}
static func defaults() -> UserDefaults? { UserDefaults(suiteName: appGroup) }
- static var hasData: Bool { defaults()?.bool(forKey: "has_data") ?? false }
+ /// `has_data` is the phone saying the snapshot is non-empty and describes a
+ /// recent day — but it is a bool frozen when the phone last pushed, so on a
+ /// phone that stopped syncing it stays true forever. Siri answers in the
+ /// present tense, so it ages `updated_at` here, at answer time.
+ ///
+ /// Same 26 h and same reasoning as `kStaleAfter` in
+ /// ios/OpenStrapWidget/OpenStrapWidget.swift and WatchMetrics.swift —
+ /// separate build targets, so it cannot be one declaration.
+ static let staleAfter: TimeInterval = 26 * 3600
+
+ static var hasData: Bool {
+ guard defaults()?.bool(forKey: "has_data") ?? false else { return false }
+ let at = defaults()?.object(forKey: "updated_at") as? Int ?? 0
+ // An unknown timestamp is not a claim of staleness.
+ return at <= 0 || Date().timeIntervalSince1970 - Double(at) <= staleAfter
+ }
static var readiness: Int { defaults()?.object(forKey: "readiness") as? Int ?? -1 }
+ /// The phone's own band label, published as `readiness_band` (thresholds:
+ /// `readinessBand` in lib/ui2/screens/home_screen.dart). Siri used to carry a
+ /// fourth private copy of the cut-offs, so it called 65 "moderate" while the
+ /// phone said "Steady" and the widget drew orange.
+ static var readinessBand: String { defaults()?.string(forKey: "readiness_band") ?? "" }
static var strain: Double { defaults()?.object(forKey: "strain") as? Double ?? -1 }
static var hrv: Int { defaults()?.object(forKey: "hrv") as? Int ?? -1 }
static var rhr: Int { defaults()?.object(forKey: "rhr") as? Int ?? -1 }
static var sleepMin: Int { defaults()?.object(forKey: "sleep_min") as? Int ?? -1 }
+ /// Spoken, so it is words rather than the phone's "7h 05m" — but a 45-minute
+ /// nap is not "0 hours 45 minutes".
static var sleepText: String {
guard sleepMin >= 0 else { return "no sleep data yet" }
- return "\(sleepMin / 60) hours \(sleepMin % 60) minutes"
+ let h = sleepMin / 60, m = sleepMin % 60
+ if h == 0 { return "\(m) minutes" }
+ if m == 0 { return h == 1 ? "1 hour" : "\(h) hours" }
+ return "\(h) \(h == 1 ? "hour" : "hours") \(m) minutes"
}
- static var noData: String { "I don't have today's numbers yet. Open OpenStrap and sync your strap." }
+ static var noData: String { "I don't have today's numbers yet. Open OpenStrap and sync your band." }
}
// MARK: - Intents
@available(iOS 16.0, *)
struct RecoveryIntent: AppIntent {
- static var title: LocalizedStringResource = "Check Recovery"
- static var description = IntentDescription("Ask OpenStrap for today's recovery.")
+ static var title: LocalizedStringResource = "Check Readiness"
+ static var description = IntentDescription("Ask OpenStrap for today's readiness.")
static var openAppWhenRun = false
func perform() async throws -> some IntentResult & ProvidesDialog {
guard OpenStrapShared.hasData, OpenStrapShared.readiness >= 0 else {
return .result(dialog: IntentDialog(stringLiteral: OpenStrapShared.noData))
}
- let r = OpenStrapShared.readiness
- let tier = r < 34 ? "Take it easy today." : (r < 67 ? "A moderate day looks good." : "You're primed to push.")
- return .result(dialog: "Your recovery is \(r) percent. \(tier)")
+ // "Readiness, out of 100" — the app's own name and unit. It is not a
+ // percentage and it is not called Recovery anywhere else in the product.
+ let band = OpenStrapShared.readinessBand
+ let line = "Your readiness is \(OpenStrapShared.readiness) out of 100."
+ return .result(dialog: IntentDialog(
+ stringLiteral: band.isEmpty ? line : "\(line) \(band)."))
}
}
@@ -111,11 +143,12 @@ struct OpenStrapShortcuts: AppShortcutsProvider {
AppShortcut(
intent: RecoveryIntent(),
phrases: [
+ "\(.applicationName) readiness",
+ "What's my readiness in \(.applicationName)",
"\(.applicationName) recovery",
- "What's my recovery in \(.applicationName)",
"How recovered am I in \(.applicationName)",
],
- shortTitle: "Recovery",
+ shortTitle: "Readiness",
systemImageName: "bolt.heart")
AppShortcut(
diff --git a/ios/OpenStrapWatch Watch App/OpenStrapWatchApp.swift b/ios/OpenStrapWatch Watch App/OpenStrapWatchApp.swift
index f34456e9..be7444b9 100644
--- a/ios/OpenStrapWatch Watch App/OpenStrapWatchApp.swift
+++ b/ios/OpenStrapWatch Watch App/OpenStrapWatchApp.swift
@@ -1,9 +1,9 @@
-// Edge Watch App — the on-wrist glance for today's recovery, strain and sleep.
+// OpenStrap Watch App — the on-wrist glance for today's readiness, strain and sleep.
//
// Read-only mirror of the phone's derived metrics (received over WCSession by
-// WatchStore). Styled to match the phone app: "Ember on Paper" (light) / "Char"
-// (dark), tracking the app's own theme via the `theme_dark` flag it syncs. No
-// compute, no BLE — the WHOOP band is the sensor and the phone does the analytics.
+// WatchStore). Styled to match the phone app: it spends lib/ui2/theme.dart's
+// tokens, tracking the app's own theme via the `theme_dark` flag it syncs. No
+// compute, no BLE — the band is the sensor and the phone does the analytics.
import SwiftUI
@@ -21,7 +21,7 @@ struct OpenStrapWatchApp: App {
}
}
-// MARK: - Palette (mirrors lib/theme/tokens.dart — Ember on Paper / Char)
+// MARK: - Palette (mirrors lib/ui2/theme.dart)
extension Color {
init(hex: UInt32) {
@@ -34,34 +34,61 @@ extension Color {
}
}
+/// ui2's raw pigment (`C` in lib/ui2/theme.dart). Arcs and fills only — an
+/// accent used as TEXT goes through the solved `on*` members of `Palette`,
+/// which are `P.on()`'s output for this brightness.
+enum C {
+ static let green = Color(hex: 0x22C55E)
+ static let orange = Color(hex: 0xF97316)
+ static let red = Color(hex: 0xEF4444)
+ static let blue = Color(hex: 0x3B82F6) // sleep
+ static let purple = Color(hex: 0x8B5CF6) // strain / movement
+ static let n400 = Color(hex: 0x94A3B8)
+}
+
struct Palette {
let bg, surface, surfaceAlt, divider: Color
let ink, inkSoft, inkMuted: Color
- let coral, coralSoft, coralInk: Color
- let good, warn, bad, cool: Color
-
- static let ember = Palette(
- bg: Color(hex: 0xF4F1EC), surface: Color(hex: 0xFFFFFF),
- surfaceAlt: Color(hex: 0xECE7DF), divider: Color(hex: 0xE6E0D6),
- ink: Color(hex: 0x16130F), inkSoft: Color(hex: 0x6B6157), inkMuted: Color(hex: 0xA59C90),
- coral: Color(hex: 0xFF5A36), coralSoft: Color(hex: 0xFFE7DF), coralInk: Color(hex: 0x7A2A16),
- good: Color(hex: 0x2BB673), warn: Color(hex: 0xF5A623), bad: Color(hex: 0xE5484D),
- cool: Color(hex: 0x7CA8F0))
-
- static let char = Palette(
- bg: Color(hex: 0x14110D), surface: Color(hex: 0x1E1A15),
- surfaceAlt: Color(hex: 0x2A251F), divider: Color(hex: 0x302A22),
- ink: Color(hex: 0xF1ECE3), inkSoft: Color(hex: 0xB6AB9C), inkMuted: Color(hex: 0x7E7466),
- coral: Color(hex: 0xFF6B47), coralSoft: Color(hex: 0x3A2018), coralInk: Color(hex: 0xFFB59E),
- good: Color(hex: 0x34C988), warn: Color(hex: 0xF7B53A), bad: Color(hex: 0xF26168),
- cool: Color(hex: 0x8FB4F2))
-
- func recovery(_ tier: Int) -> Color {
+ /// `P.on(C.green)` + `P.wash(C.green)` — the Home accent as text, and the
+ /// tinted card it sits on.
+ let onHome, homeWash: Color
+ /// Tier accents as TEXT (`P.on`).
+ let onGood, onWarn, onBad, onNone: Color
+
+ static let light = Palette(
+ bg: Color(hex: 0xF8FAFC), surface: Color(hex: 0xFFFFFF),
+ surfaceAlt: Color(hex: 0xF1F5F9), divider: Color(hex: 0xE2E8F0),
+ ink: Color(hex: 0x0F172A), inkSoft: Color(hex: 0x475569), inkMuted: Color(hex: 0x627188),
+ onHome: Color(hex: 0x1A7948), homeWash: Color(hex: 0xE7F9ED),
+ onGood: Color(hex: 0x1A7948), onWarn: Color(hex: 0xA5521D),
+ onBad: Color(hex: 0xB9393E), onNone: Color(hex: 0x606B80))
+
+ static let dark = Palette(
+ bg: Color(hex: 0x0B1017), surface: Color(hex: 0x151C26),
+ surfaceAlt: Color(hex: 0x1D2632), divider: Color(hex: 0x232D3B),
+ ink: Color(hex: 0xF1F5F9), inkSoft: Color(hex: 0x94A3B8), inkMuted: Color(hex: 0x7F8DA0),
+ onHome: Color(hex: 0x22C55E), homeWash: Color(hex: 0x173A30),
+ onGood: Color(hex: 0x22C55E), onWarn: Color(hex: 0xF87F2A),
+ onBad: Color(hex: 0xEF7373), onNone: Color(hex: 0x97A6BA))
+
+ /// Readiness tier → arc pigment. The tier is computed once, in Dart, and
+ /// shipped as `readiness_tier`; this only paints it.
+ func readinessArc(_ tier: Int) -> Color {
switch tier {
- case 2: return good
- case 1: return warn
- case 0: return bad
- default: return inkMuted
+ case 3, 2: return C.green
+ case 1: return C.orange
+ case 0: return C.red
+ default: return C.n400
+ }
+ }
+
+ /// The same tier, solved for TEXT.
+ func readiness(_ tier: Int) -> Color {
+ switch tier {
+ case 3, 2: return onGood
+ case 1: return onWarn
+ case 0: return onBad
+ default: return onNone
}
}
}
@@ -71,26 +98,23 @@ struct Palette {
struct WatchGlanceView: View {
@EnvironmentObject var store: WatchStore
private var m: WatchMetrics { store.metrics }
- private var p: Palette { m.themeDark ? .char : .ember }
+ private var p: Palette { m.themeDark ? .dark : .light }
var body: some View {
ZStack {
+ // Flat surface. ui2 has no glow: the phone is cards on a plain ground,
+ // and the wrist is one of its cards.
p.bg.ignoresSafeArea()
- // Signature coral ember glow, top-trailing (matches the app's GlowCard/recap).
- RadialGradient(
- colors: [p.coral.opacity(m.themeDark ? 0.20 : 0.16), .clear],
- center: .topTrailing, startRadius: 2, endRadius: 130)
- .ignoresSafeArea()
ScrollView {
VStack(spacing: 12) {
- if !m.hasData { empty } else {
- recoveryHero
+ if !m.fresh { empty } else {
+ readinessHero
HStack(spacing: 10) {
MetricCard(p: p, title: "STRAIN", value: m.strainText,
- fraction: m.strainFraction, accent: p.coral)
+ fraction: m.strainFraction, accent: C.purple)
MetricCard(p: p, title: "SLEEP", value: m.sleepText,
- fraction: m.sleepFraction, accent: p.cool)
+ fraction: m.sleepFraction, accent: C.blue)
}
HStack(spacing: 8) {
StatCell(p: p, label: "HRV", value: m.hrvText, unit: "ms")
@@ -106,15 +130,20 @@ struct WatchGlanceView: View {
.onAppear { store.requestRefresh() }
}
+ /// Nothing to show — either the phone has never pushed, or what it pushed is
+ /// old enough that it is no longer today's answer. The two say different
+ /// things: a snapshot going stale is not the same as never having one, and a
+ /// stale number rendered as current is exactly what this state exists to
+ /// prevent.
private var empty: some View {
VStack(spacing: 8) {
Image(systemName: "heart.text.square")
.font(.system(size: 32))
.foregroundStyle(p.inkMuted)
- Text("No data yet")
+ Text(m.hasData ? "No recent data" : "No data yet")
.font(.system(size: 16, weight: .semibold, design: .rounded))
.foregroundStyle(p.ink)
- Text("Open Edge on your iPhone and sync your strap.")
+ Text("Open OpenStrap on your iPhone and sync your band.")
.font(.system(size: 12))
.multilineTextAlignment(.center)
.foregroundStyle(p.inkSoft)
@@ -122,21 +151,35 @@ struct WatchGlanceView: View {
.padding(.top, 20)
}
- private var recoveryHero: some View {
- let tint = p.recovery(m.recoveryTier)
+ /// Readiness hero. Not "Recovery", and not a percentage — the phone scores
+ /// READINESS out of 100 and the wrist must not rename it. With no score there
+ /// is no arc: an arc trimmed to zero is a ring pinned at empty, which reads
+ /// as "your readiness is 0".
+ private var readinessHero: some View {
+ let arc = p.readinessArc(m.tier)
return ZStack {
- Circle().stroke(tint.opacity(0.18), lineWidth: 11)
- Circle()
- .trim(from: 0, to: m.readinessFraction)
- .stroke(tint, style: StrokeStyle(lineWidth: 11, lineCap: .round))
- .rotationEffect(.degrees(-90))
+ Circle().stroke(p.divider, lineWidth: 11)
+ if m.readinessFraction > 0 {
+ Circle()
+ .trim(from: 0, to: m.readinessFraction)
+ .stroke(arc, style: StrokeStyle(lineWidth: 11, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ }
VStack(spacing: -2) {
- Text(m.readiness >= 0 ? "\(m.readiness)" : "—")
- .font(.system(size: 40, weight: .bold, design: .rounded))
- .foregroundStyle(p.ink)
- Text("RECOVERY")
+ if m.readiness >= 0 {
+ Text("\(m.readiness)")
+ .font(.system(size: 40, weight: .bold, design: .rounded))
+ .foregroundStyle(p.ink)
+ } else {
+ Text("Not scored")
+ .font(.system(size: 13, weight: .semibold, design: .rounded))
+ .foregroundStyle(p.inkSoft)
+ }
+ Text(m.readiness >= 0 && !m.band.isEmpty ? m.band.uppercased() : "READINESS")
.font(.system(size: 9, weight: .semibold, design: .rounded))
.tracking(1.2)
+ .minimumScaleFactor(0.7)
+ .lineLimit(1)
.foregroundStyle(p.inkMuted)
}
}
@@ -148,36 +191,42 @@ struct WatchGlanceView: View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: "sparkles")
.font(.system(size: 11))
- .foregroundStyle(p.coralInk)
+ .foregroundStyle(p.onHome)
Text(m.coachLine)
.font(.system(size: 12, weight: .medium))
- .foregroundStyle(p.coralInk)
+ .foregroundStyle(p.onHome)
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
- .background(p.coralSoft, in: RoundedRectangle(cornerRadius: 12))
+ .background(p.homeWash, in: RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Components
+/// An absent metric is an empty slot — no number, no arc, the card dimmed.
+/// A 54pt circle cannot carry the phone's what/why/fix, but a dash over a ring
+/// drawn at zero is a measurement we do not have, which is worse than silence.
private struct MetricCard: View {
let p: Palette
let title: String
let value: String
let fraction: Double
let accent: Color
+ private var absent: Bool { value.isEmpty }
var body: some View {
VStack(spacing: 6) {
ZStack {
Circle().stroke(accent.opacity(0.18), lineWidth: 6)
- Circle()
- .trim(from: 0, to: fraction)
- .stroke(accent, style: StrokeStyle(lineWidth: 6, lineCap: .round))
- .rotationEffect(.degrees(-90))
+ if fraction > 0 {
+ Circle()
+ .trim(from: 0, to: fraction)
+ .stroke(accent, style: StrokeStyle(lineWidth: 6, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ }
Text(value)
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundStyle(p.ink)
@@ -195,6 +244,7 @@ private struct MetricCard: View {
.padding(.vertical, 10)
.background(p.surface, in: RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(p.divider, lineWidth: 1))
+ .opacity(absent ? 0.4 : 1)
}
}
@@ -203,6 +253,7 @@ private struct StatCell: View {
let label: String
let value: String
let unit: String
+ private var absent: Bool { value.isEmpty }
var body: some View {
VStack(spacing: 1) {
@@ -210,13 +261,19 @@ private struct StatCell: View {
.font(.system(size: 9, weight: .semibold, design: .rounded))
.tracking(0.8)
.foregroundStyle(p.inkMuted)
- HStack(alignment: .firstTextBaseline, spacing: 2) {
- Text(value)
- .font(.system(size: 18, weight: .bold, design: .rounded))
- .foregroundStyle(p.ink)
- Text(unit)
- .font(.system(size: 9))
+ if absent {
+ Text("no reading")
+ .font(.system(size: 11))
.foregroundStyle(p.inkSoft)
+ } else {
+ HStack(alignment: .firstTextBaseline, spacing: 2) {
+ Text(value)
+ .font(.system(size: 18, weight: .bold, design: .rounded))
+ .foregroundStyle(p.ink)
+ Text(unit)
+ .font(.system(size: 9))
+ .foregroundStyle(p.inkSoft)
+ }
}
}
.frame(maxWidth: .infinity)
diff --git a/ios/OpenStrapWatch Watch App/WatchMetrics.swift b/ios/OpenStrapWatch Watch App/WatchMetrics.swift
index 60cccd63..ead2f025 100644
--- a/ios/OpenStrapWatch Watch App/WatchMetrics.swift
+++ b/ios/OpenStrapWatch Watch App/WatchMetrics.swift
@@ -1,22 +1,35 @@
// WatchMetrics — the today snapshot the watch renders.
//
-// Add this file to BOTH watch targets (the Watch App and the Watch Widget
-// Extension). The watch app's WatchStore receives the payload over WCSession and
-// writes it into the watch-side App Group; the complication widget reads the same
-// suite. (The watch app and its widget extension are separate processes, so they
-// share via a watch App Group — NOT the phone's App Group.)
+// Watch App target only. WatchStore receives the payload over WCSession and
+// caches it here; the glance reads it back. This used to be stored in an App
+// Group ("group.wtf.openstrap.watch") that the watch entitlement never declared
+// — a suite nobody else could open, kept alive for a complication extension
+// that was never in the Xcode project. One process writes and reads it, so
+// standard defaults are the whole requirement.
+//
+// HONESTY: the phone forbids a bare dash and a zero-filled ring for an absent
+// metric. Text helpers return "" and ring fractions return a negative sentinel
+// so the views can leave the slot empty instead of drawing "0". And `hasData`
+// is not enough on its own — it is a bool frozen when the phone pushed, so the
+// wrist gates on `fresh`, which ages `updatedAt` at render time.
import Foundation
-enum WatchConfig {
- /// Watch-side App Group, shared between the Watch App and Watch Widget targets.
- /// Create this group and enable it on BOTH watch targets in Xcode.
- static let appGroup = "group.wtf.openstrap.watch"
-}
+/// How old a snapshot may be before the wrist stops presenting it as today's
+/// answer: one whole missed wake cycle plus a little grace. Same value and same
+/// reasoning as `kStaleAfter` in ios/OpenStrapWidget/OpenStrapWidget.swift —
+/// separate build targets, so it cannot be one declaration.
+let kStaleAfter: TimeInterval = 26 * 3600
struct WatchMetrics {
var hasData: Bool
var readiness: Int // 0–100, -1 = none
+ /// Readiness tier, straight from Dart. The thresholds live in exactly one
+ /// place — `readinessBand` in lib/ui2/screens/home_screen.dart, published as
+ /// `readiness_tier`. Never re-derive a band from `readiness` here: the watch
+ /// used to call 38 "yellow" while the phone called the same score red.
+ var tier: Int // -1 not scored · 0 rest · 1 easy · 2 steady · 3 good
+ var band: String // the phone's own label for `tier`
var strain: Double // 0–21, -1 = none
var sleepMin: Int // minutes asleep, -1 = none
var needMin: Int // sleep need (min); -1 = none — never fabricate 8h
@@ -29,47 +42,60 @@ struct WatchMetrics {
var themeDark: Bool // mirror the app's Ember-on-Paper (false) / Char (true)
static let empty = WatchMetrics(
- hasData: false, readiness: -1, strain: -1, sleepMin: -1, needMin: -1,
- hrv: -1, hrvBaseline: -1, rhr: -1, coachLine: "", battPct: -1, updatedAt: 0,
- themeDark: true)
+ hasData: false, readiness: -1, tier: -1, band: "", strain: -1, sleepMin: -1,
+ needMin: -1, hrv: -1, hrvBaseline: -1, rhr: -1, coachLine: "", battPct: -1,
+ updatedAt: 0, themeDark: true)
static func load() -> WatchMetrics {
- let d = UserDefaults(suiteName: WatchConfig.appGroup)
+ let d = UserDefaults.standard
return WatchMetrics(
- hasData: d?.bool(forKey: "has_data") ?? false,
- readiness: d?.object(forKey: "readiness") as? Int ?? -1,
- 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") ?? "",
- battPct: d?.object(forKey: "batt_pct") as? Int ?? -1,
- updatedAt: d?.object(forKey: "updated_at") as? Int ?? 0,
- themeDark: d?.object(forKey: "theme_dark") as? Bool ?? true)
+ hasData: d.bool(forKey: "has_data"),
+ 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") ?? "",
+ battPct: d.object(forKey: "batt_pct") as? Int ?? -1,
+ updatedAt: d.object(forKey: "updated_at") as? Int ?? 0,
+ themeDark: d.object(forKey: "theme_dark") as? Bool ?? true)
+ }
+
+ /// Is this still today's answer? An unknown timestamp is not a claim of
+ /// staleness (a snapshot that never got a push has `hasData` false anyway).
+ var fresh: Bool {
+ guard hasData else { return false }
+ guard updatedAt > 0 else { return true }
+ return Date().timeIntervalSince1970 - Double(updatedAt) <= kStaleAfter
}
// MARK: Display helpers
+ // "" = no measurement. The score the phone calls READINESS out of 100 is not
+ // a percentage and is not called Recovery — one number, one name, one unit.
- var readinessText: String { readiness >= 0 ? "\(readiness)%" : "—" }
- var strainText: String { strain >= 0 ? String(format: "%.1f", strain) : "—" }
- var hrvText: String { hrv >= 0 ? "\(hrv)" : "—" }
- var rhrText: String { rhr >= 0 ? "\(rhr)" : "—" }
+ var readinessText: String { readiness >= 0 ? "\(readiness)" : "" }
+ var strainText: String { strain >= 0 ? String(format: "%.1f", strain) : "" }
+ var hrvText: String { hrv >= 0 ? "\(hrv)" : "" }
+ var rhrText: String { rhr >= 0 ? "\(rhr)" : "" }
+ /// "45m" / "7h 05m" — the phone's `hm()` (lib/ui2/screens/home_screen.dart).
var sleepText: String {
- guard sleepMin >= 0 else { return "—" }
- return "\(sleepMin / 60)h \(sleepMin % 60)m"
+ guard sleepMin >= 0 else { return "" }
+ if sleepMin < 60 { return "\(sleepMin)m" }
+ return String(format: "%dh %02dm", sleepMin / 60, sleepMin % 60)
}
- /// Recovery ring fraction 0–1.
- var readinessFraction: Double { readiness >= 0 ? Double(readiness) / 100.0 : 0 }
- /// Strain ring fraction 0–1 (0–21 scale).
- var strainFraction: Double { strain >= 0 ? min(strain / 21.0, 1) : 0 }
+
+ // Ring fractions; negative = nothing measured, so draw the track only.
+ var readinessFraction: Double { readiness >= 0 ? Double(readiness) / 100.0 : -1 }
+ /// Strain ring fraction 0–1. 0–21 is the headline scale `strainScore` maps
+ /// TRIMP onto (analytics/lib/src/onehz/clinical/load_trimp.dart:104-122).
+ var strainFraction: Double { strain >= 0 ? min(strain / 21.0, 1) : -1 }
/// Sleep-vs-need fraction 0–1.
var sleepFraction: Double {
- guard sleepMin >= 0, needMin > 0 else { return 0 }
+ guard sleepMin >= 0, needMin > 0 else { return -1 }
return min(Double(sleepMin) / Double(needMin), 1)
}
-
- /// WHOOP-style recovery color: red < 34, yellow 34–66, green ≥ 67.
- var recoveryTier: Int { readiness < 0 ? -1 : (readiness < 34 ? 0 : (readiness < 67 ? 1 : 2)) }
}
diff --git a/ios/OpenStrapWatch Watch App/WatchStore.swift b/ios/OpenStrapWatch Watch App/WatchStore.swift
index 4f074e8e..8a08b4dc 100644
--- a/ios/OpenStrapWatch Watch App/WatchStore.swift
+++ b/ios/OpenStrapWatch Watch App/WatchStore.swift
@@ -1,14 +1,18 @@
// WatchStore — receives today's metrics from the iPhone and caches them.
//
// Add to the Watch App target only. On activation it pulls the latest snapshot;
-// thereafter it receives coalesced `applicationContext` pushes (and complication
-// user-info) from WatchBridge on the phone. Every ingest writes the watch-side
-// App Group and reloads complication timelines so the face stays in sync.
+// thereafter it receives coalesced `applicationContext` pushes from WatchBridge
+// on the phone. Every ingest caches the payload so the glance stays in sync.
+//
+// There are no complications: the OpenStrapWatchWidget sources were never in
+// the Xcode project, so nothing compiled them and there was no timeline to
+// reload. Re-adding them means an Xcode-created watchOS widget extension target
+// embedded in this app (plus its own bundle id and a watch App Group), not a
+// loose folder.
import Combine
import Foundation
import WatchConnectivity
-import WidgetKit
final class WatchStore: NSObject, ObservableObject, WCSessionDelegate {
static let shared = WatchStore()
@@ -34,14 +38,10 @@ final class WatchStore: NSObject, ObservableObject, WCSessionDelegate {
}
private func ingest(_ payload: [String: Any]) {
- NSLog("[WatchStore] ingest keys=%@ suiteNil=%d", payload.keys.sorted().joined(separator: ","),
- (UserDefaults(suiteName: WatchConfig.appGroup) == nil) ? 1 : 0)
- guard !payload.isEmpty, let d = UserDefaults(suiteName: WatchConfig.appGroup) else { return }
+ guard !payload.isEmpty else { return }
+ let d = UserDefaults.standard
for (k, v) in payload { d.set(v, forKey: k) }
- DispatchQueue.main.async {
- self.metrics = .load()
- WidgetCenter.shared.reloadAllTimelines()
- }
+ DispatchQueue.main.async { self.metrics = .load() }
}
// MARK: - WCSessionDelegate
diff --git a/ios/OpenStrapWatchWidget/OpenStrapWatchWidgetBundle.swift b/ios/OpenStrapWatchWidget/OpenStrapWatchWidgetBundle.swift
deleted file mode 100644
index f5a26d27..00000000
--- a/ios/OpenStrapWatchWidget/OpenStrapWatchWidgetBundle.swift
+++ /dev/null
@@ -1,146 +0,0 @@
-// OpenStrap Watch complications — WidgetKit widgets for the Apple Watch face.
-//
-// Add these to a "Watch Widget Extension" target embedded in the Watch App.
-// They read the watch-side App Group (written by WatchStore when the phone
-// pushes fresh metrics) and render as accessory-family complications. Add
-// WatchMetrics.swift to this target too (shared model + WatchConfig.appGroup).
-
-import SwiftUI
-import WidgetKit
-
-// MARK: - Timeline
-
-struct RecoveryEntry: TimelineEntry {
- let date: Date
- let metrics: WatchMetrics
-}
-
-struct RecoveryProvider: TimelineProvider {
- func placeholder(in context: Context) -> RecoveryEntry {
- RecoveryEntry(date: Date(), metrics: .empty)
- }
-
- func getSnapshot(in context: Context, completion: @escaping (RecoveryEntry) -> Void) {
- completion(RecoveryEntry(date: Date(), metrics: .load()))
- }
-
- func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
- // Data is push-driven (WatchStore reloads timelines on new WCSession data),
- // so a single current entry with a periodic safety refresh is enough.
- let entry = RecoveryEntry(date: Date(), metrics: .load())
- let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
- completion(Timeline(entries: [entry], policy: .after(next)))
- }
-}
-
-private func recoveryColor(_ tier: Int) -> Color {
- // Matches lib/theme/tokens.dart good/warn/bad (Char variant, for the dark face).
- switch tier {
- case 2: return Color(red: 0x34 / 255, green: 0xC9 / 255, blue: 0x88 / 255) // good
- case 1: return Color(red: 0xF7 / 255, green: 0xB5 / 255, blue: 0x3A / 255) // warn
- case 0: return Color(red: 0xF2 / 255, green: 0x61 / 255, blue: 0x68 / 255) // bad
- default: return .gray
- }
-}
-
-// MARK: - Recovery complication (circular / corner / inline)
-
-struct RecoveryComplication: Widget {
- let kind = "OpenStrapRecovery"
-
- var body: some WidgetConfiguration {
- StaticConfiguration(kind: kind, provider: RecoveryProvider()) { entry in
- RecoveryComplicationView(m: entry.metrics)
- .containerBackground(.clear, for: .widget)
- }
- .configurationDisplayName("Recovery")
- .description("Today's recovery from OpenStrap.")
- .supportedFamilies([.accessoryCircular, .accessoryCorner, .accessoryInline])
- }
-}
-
-struct RecoveryComplicationView: View {
- @Environment(\.widgetFamily) private var family
- let m: WatchMetrics
-
- var body: some View {
- switch family {
- case .accessoryInline:
- Label("Recovery \(m.readinessText)", systemImage: "bolt.heart")
- case .accessoryCorner:
- Text(m.readinessText)
- .font(.system(size: 18, weight: .bold, design: .rounded))
- .widgetLabel {
- Gauge(value: m.readinessFraction) { EmptyView() }
- .tint(recoveryColor(m.recoveryTier))
- }
- default: // accessoryCircular
- Gauge(value: m.readinessFraction) {
- Image(systemName: "bolt.heart")
- } currentValueLabel: {
- Text(m.readiness >= 0 ? "\(m.readiness)" : "—")
- .font(.system(size: 15, weight: .bold, design: .rounded))
- }
- .gaugeStyle(.accessoryCircular)
- .tint(recoveryColor(m.recoveryTier))
- }
- }
-}
-
-// MARK: - Combined rectangular (recovery · strain · sleep)
-
-struct TodayComplication: Widget {
- let kind = "OpenStrapToday"
-
- var body: some WidgetConfiguration {
- StaticConfiguration(kind: kind, provider: RecoveryProvider()) { entry in
- TodayComplicationView(m: entry.metrics)
- .containerBackground(.clear, for: .widget)
- }
- .configurationDisplayName("Today")
- .description("Recovery, strain and sleep at a glance.")
- .supportedFamilies([.accessoryRectangular])
- }
-}
-
-struct TodayComplicationView: View {
- let m: WatchMetrics
-
- var body: some View {
- HStack(spacing: 8) {
- Gauge(value: m.readinessFraction) {
- EmptyView()
- } currentValueLabel: {
- Text(m.readiness >= 0 ? "\(m.readiness)" : "—")
- .font(.system(size: 13, weight: .bold, design: .rounded))
- }
- .gaugeStyle(.accessoryCircular)
- .tint(recoveryColor(m.recoveryTier))
-
- VStack(alignment: .leading, spacing: 1) {
- Text("Recovery")
- .font(.system(size: 12, weight: .semibold))
- Text("Strain \(m.strainText) · \(m.sleepText)")
- .font(.system(size: 12))
- .foregroundStyle(.secondary)
- if !m.coachLine.isEmpty {
- Text(m.coachLine)
- .font(.system(size: 11))
- .foregroundStyle(.secondary)
- .lineLimit(1)
- }
- }
- Spacer(minLength: 0)
- }
- }
-}
-
-// MARK: - Bundle
-
-@main
-struct OpenStrapWatchWidgetBundle: WidgetBundle {
- var body: some Widget {
- RecoveryComplication()
- TodayComplication()
- }
-}
diff --git a/ios/OpenStrapWidget/OpenStrapBatteryWidget.swift b/ios/OpenStrapWidget/OpenStrapBatteryWidget.swift
index 865b642a..f99d0dd4 100644
--- a/ios/OpenStrapWidget/OpenStrapBatteryWidget.swift
+++ b/ios/OpenStrapWidget/OpenStrapBatteryWidget.swift
@@ -8,7 +8,8 @@
// it is NOT part of /today — so unlike OpenStrapWidget this one does NOT
// self-refresh over the network. It renders the last snapshot the app wrote
// into the shared App Group (keys batt_pct / batt_charging / batt_at) the last
-// time the band was connected. "—" until we've ever seen the band.
+// time the band was connected. Until we have ever seen the band it says so in
+// words — it never draws a bar at empty, which reads as "0%".
//
// Primary surface is the lock screen (accessory* families); a systemSmall
// variant is included so it can also live on the home screen.
@@ -19,7 +20,7 @@ import SwiftUI
private let kAppGroup = AppGroup.identifier
-// MARK: - Theme (mirrors OpenStrapWidget's Ember-on-Paper / Char)
+// MARK: - Theme (lib/ui2/theme.dart; mirrors OpenStrapWidget's)
private extension Color {
init(_ r: Int, _ g: Int, _ b: Int) {
@@ -29,10 +30,10 @@ private extension Color {
private struct BattPal {
let bg: Color, ink: Color, inkMuted: Color, track: Color
- static let light = BattPal(bg: Color(244, 241, 236), ink: Color(26, 23, 20),
- inkMuted: Color(165, 156, 144), track: Color(236, 231, 223))
- static let dark = BattPal(bg: Color(30, 26, 21), ink: Color(241, 236, 227),
- inkMuted: Color(126, 116, 102), track: Color(42, 37, 31))
+ static let light = BattPal(bg: Color(0xFF, 0xFF, 0xFF), ink: Color(0x0F, 0x17, 0x2A),
+ inkMuted: Color(0x62, 0x71, 0x88), track: Color(0xE2, 0xE8, 0xF0))
+ static let dark = BattPal(bg: Color(0x15, 0x1C, 0x26), ink: Color(0xF1, 0xF5, 0xF9),
+ inkMuted: Color(0x7F, 0x8D, 0xA0), track: Color(0x23, 0x2D, 0x3B))
static var current: BattPal {
let isDark = UserDefaults(suiteName: kAppGroup)?.object(forKey: "theme_dark") as? Bool ?? false
return isDark ? .dark : .light
@@ -44,43 +45,74 @@ private extension Color {
static var battInk: Color { BattPal.current.ink }
static var battInkMuted: Color { BattPal.current.inkMuted }
static var battTrack: Color { BattPal.current.track }
- static let battCoral = Color(255, 90, 54)
- static let battCoralDeep = Color(232, 67, 31)
- static let battGood = Color(43, 182, 115)
- static let battCharge = Color(124, 168, 240)
+ // Raw ui2 pigment: a battery bar is non-text UI, so it spends `C.*` directly.
+ static let battLow = Color(0xF9, 0x73, 0x16) // C.orange
+ static let battCritical = Color(0xEF, 0x44, 0x44) // C.red
+ static let battGood = Color(0x22, 0xC5, 0x5E) // C.green
+ static let battCharge = Color(0x3B, 0x82, 0xF6) // C.blue
}
// MARK: - Model
+/// A battery reading older than this is not the band's current level — we
+/// simply have not talked to it. Shorter than the metrics widget's 26 h
+/// (OpenStrapWidget.swift) on purpose: readiness describes a night that stays
+/// true all day, a battery percentage describes right now.
+private let kBattStaleAfter: TimeInterval = 86_400
+
struct BatteryEntry: TimelineEntry {
- let date: Date
- let name: String // strap advertising name (falls back to "Strap")
+ var date: Date
+ let name: String // the band's advertising name (falls back to "Band")
let pct: Int // -1 = never seen the band
let charging: Bool
let updatedAt: Int // epoch seconds, 0 = unknown
- let stale: Bool // last reading is old enough that we mute it
static let placeholder = BatteryEntry(
- date: Date(), name: "WHOOP 4.0", pct: 68, charging: false,
- updatedAt: Int(Date().timeIntervalSince1970), stale: false)
+ date: Date(), name: "Band", pct: 68, charging: false,
+ updatedAt: Int(Date().timeIntervalSince1970))
var hasData: Bool { pct >= 0 }
+
+ /// Computed from THIS ENTRY'S date, not from `Date()` at read time — the same
+ /// reason the metrics widget does it: a flag frozen when the timeline was
+ /// built can never go stale on its own, so WidgetKit renders the flip from an
+ /// entry it already holds (see getTimeline).
+ var stale: Bool {
+ guard hasData, updatedAt > 0 else { return false }
+ return date.timeIntervalSince1970 - Double(updatedAt) > kBattStaleAfter
+ }
+
+ /// The instant this reading stops being the band's current level.
+ var stalenessDeadline: Date? {
+ guard hasData, updatedAt > 0 else { return nil }
+ let at = Date(timeIntervalSince1970: Double(updatedAt) + kBattStaleAfter)
+ return at > date ? at : nil
+ }
+
+ func at(_ d: Date) -> BatteryEntry { var c = self; c.date = d; return c }
var t: Double { pct >= 0 ? min(max(Double(pct) / 100.0, 0), 1) : 0 }
+ /// `charging` is a fact about the moment the app last wrote, so it goes stale
+ /// with the level it came with. Without this the widget said "Charging" in
+ /// blue, with a bolt, about a band that came off the puck four days ago —
+ /// the charge branch was tested before the staleness branch everywhere.
+ /// Mirrored in OpenStrapBatteryWidgetProvider.kt — keep the two in step.
+ var chargingNow: Bool { charging && !stale }
+
/// Coral when low, deep-coral when critical, blue while charging, otherwise ink.
var color: Color {
if !hasData { return .battInkMuted }
- if charging { return .battCharge }
- if pct <= 10 { return .battCoralDeep }
- if pct <= 25 { return .battCoral }
+ if chargingNow { return .battCharge }
+ if pct <= 10 { return .battCritical }
+ if pct <= 25 { return .battLow }
return .battGood
}
- var valueText: String { pct >= 0 ? "\(pct)%" : "—" }
+ var valueText: String { pct >= 0 ? "\(pct)%" : "" }
- /// Icon: a charging bolt while plugged in, otherwise the strap glyph
+ /// Icon: a charging bolt while plugged in, otherwise the band glyph
/// (mirrors the app's device icon, HugeIcons SmartWatch01).
- var symbol: String { charging ? "bolt.fill" : "applewatch" }
+ var symbol: String { chargingNow ? "bolt.fill" : "applewatch" }
}
// MARK: - Shared store (App Group, read-only here)
@@ -92,12 +124,9 @@ private enum BatteryStore {
let charging = d?.object(forKey: "batt_charging") as? Bool ?? false
let at = d?.object(forKey: "batt_at") as? Int ?? 0
let raw = (d?.string(forKey: "batt_name") ?? "").trimmingCharacters(in: .whitespaces)
- let name = raw.isEmpty ? "Strap" : raw
- // Mute (still show the number, but greyed) once the reading is > 24h old —
- // we genuinely don't know the current level if we haven't talked to the band.
- let stale = at > 0 && (Int(Date().timeIntervalSince1970) - at) > 86_400
+ let name = raw.isEmpty ? "Band" : raw
return BatteryEntry(date: Date(), name: name, pct: pct, charging: charging,
- updatedAt: at, stale: stale)
+ updatedAt: at)
}
}
@@ -113,10 +142,16 @@ struct BatteryProvider: TimelineProvider {
}
func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
- let entry = BatteryStore.read()
- let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date())
- ?? Date().addingTimeInterval(1800)
- completion(Timeline(entries: [entry], policy: .after(next)))
+ // Second entry at the staleness deadline: the reading labels itself "last
+ // known" at exactly that moment with no process wake. The 30-min `.after`
+ // only picks up a newer snapshot.
+ let now = Date()
+ let entry = BatteryStore.read().at(now)
+ var entries = [entry]
+ if let deadline = entry.stalenessDeadline { entries.append(entry.at(deadline)) }
+ let next = Calendar.current.date(byAdding: .minute, value: 30, to: now)
+ ?? now.addingTimeInterval(1800)
+ completion(Timeline(entries: entries, policy: .after(next)))
}
}
@@ -143,7 +178,7 @@ private struct BattBar: View {
}
}
-/// Home-screen small: strap name + level + linear bar.
+/// Home-screen small: band name + level + linear bar.
private struct BatterySmallView: View {
let e: BatteryEntry
var body: some View {
@@ -157,32 +192,39 @@ private struct BatterySmallView: View {
Text(e.valueText).font(battNumFont(30)).foregroundColor(.battInk)
.minimumScaleFactor(0.6).lineLimit(1)
Spacer(minLength: 8)
- BattBar(t: e.t, color: e.color, height: 9)
- Text(e.charging ? "Charging" : (e.hasData ? "Battery" : "Not connected"))
+ if e.hasData { BattBar(t: e.t, color: e.color, height: 9) }
+ // Dimming alone does not SAY anything. A reading we can no longer vouch
+ // for names itself, on every family.
+ Text(!e.hasData ? "Not connected yet"
+ : (e.stale ? "Last known level" : (e.charging ? "Charging" : "Battery")))
.font(.system(size: 10, weight: .medium)).foregroundColor(.battInkMuted)
.padding(.top, 5)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
- .opacity(e.stale ? 0.5 : 1)
.padding(14)
}
}
-@available(iOSApplicationExtension 16.0, *)
+// A Gauge with no reading is a bar drawn at empty, which reads as "0% battery"
+// rather than "we haven't heard from the band" — so an unknown level gets the
+// glyph and a word, never a gauge.
private struct BatteryCircularView: View {
let e: BatteryEntry
var body: some View {
- Gauge(value: e.t) {
- Image(systemName: e.symbol)
- } currentValueLabel: {
- Text(e.valueText)
+ if e.hasData {
+ Gauge(value: e.t) {
+ Image(systemName: e.symbol)
+ } currentValueLabel: {
+ Text(e.valueText)
+ }
+ .gaugeStyle(.accessoryCircularCapacity)
+ .widgetAccentable()
+ } else {
+ Image(systemName: "applewatch.slash").font(.system(size: 18)).widgetAccentable()
}
- .gaugeStyle(.accessoryCircularCapacity)
- .widgetAccentable()
}
}
-@available(iOSApplicationExtension 16.0, *)
private struct BatteryRectangularView: View {
let e: BatteryEntry
var body: some View {
@@ -195,24 +237,27 @@ private struct BatteryRectangularView: View {
.font(.system(size: 13, weight: .semibold))
.widgetAccentable()
- // Linear lock-screen battery bar with the level inline.
- Gauge(value: e.t) {
- Text("")
- } currentValueLabel: {
- Text(e.hasData ? "\(e.pct)%" : "—")
+ if e.hasData {
+ // Linear lock-screen battery bar with the level inline.
+ Gauge(value: e.t) {
+ Text("")
+ } currentValueLabel: {
+ Text("\(e.pct)%")
+ }
+ .gaugeStyle(.accessoryLinearCapacity)
+ } else {
+ Text("Not connected yet").font(.system(size: 12)).foregroundStyle(.secondary)
+ }
+ if e.stale {
+ Text("Last known").font(.system(size: 11)).foregroundStyle(.secondary)
}
- .gaugeStyle(.accessoryLinearCapacity)
}
}
}
private extension View {
@ViewBuilder func battWidgetBackground(_ color: Color) -> some View {
- if #available(iOSApplicationExtension 17.0, *) {
- containerBackground(color, for: .widget)
- } else {
- background(color)
- }
+ containerBackground(color, for: .widget)
}
}
@@ -221,26 +266,26 @@ struct OpenStrapBatteryEntryView: View {
var entry: BatteryEntry
var body: some View {
- content.battWidgetBackground(family == .systemSmall ? Color.battPaper : Color.clear)
+ // The staleness mute used to be applied only inside BatterySmallView, so a
+ // lock-screen complication showed a week-old percentage at full strength.
+ // It belongs here, above every family.
+ content
+ .opacity(entry.stale ? 0.5 : 1)
+ .battWidgetBackground(family == .systemSmall ? Color.battPaper : Color.clear)
}
@ViewBuilder private var content: some View {
switch family {
- case .systemSmall: BatterySmallView(e: entry)
- default:
- if #available(iOSApplicationExtension 16.0, *) {
- switch family {
- case .accessoryCircular: BatteryCircularView(e: entry)
- case .accessoryRectangular: BatteryRectangularView(e: entry)
- case .accessoryInline:
- Label(
- entry.hasData ? "\(entry.name) \(entry.pct)%" : "\(entry.name) —",
- systemImage: entry.symbol)
- default: BatterySmallView(e: entry)
- }
- } else {
- BatterySmallView(e: entry)
- }
+ case .systemSmall: BatterySmallView(e: entry)
+ case .accessoryCircular: BatteryCircularView(e: entry)
+ case .accessoryRectangular: BatteryRectangularView(e: entry)
+ case .accessoryInline:
+ Label(
+ entry.hasData
+ ? "\(entry.name) \(entry.pct)%\(entry.stale ? " · last known" : "")"
+ : "\(entry.name) not connected",
+ systemImage: entry.symbol)
+ default: BatterySmallView(e: entry)
}
}
}
@@ -254,13 +299,7 @@ struct OpenStrapBatteryWidget: Widget {
}
.configurationDisplayName("Band Battery")
.description("Your band's battery level at a glance.")
- .supportedFamilies(supportedFamilies)
- }
-
- private var supportedFamilies: [WidgetFamily] {
- if #available(iOSApplicationExtension 16.0, *) {
- return [.systemSmall, .accessoryCircular, .accessoryRectangular, .accessoryInline]
- }
- return [.systemSmall]
+ .supportedFamilies([.systemSmall, .accessoryCircular,
+ .accessoryRectangular, .accessoryInline])
}
}
diff --git a/ios/OpenStrapWidget/OpenStrapBreathingLiveActivity.swift b/ios/OpenStrapWidget/OpenStrapBreathingLiveActivity.swift
index a29fa44f..346ded86 100644
--- a/ios/OpenStrapWidget/OpenStrapBreathingLiveActivity.swift
+++ b/ios/OpenStrapWidget/OpenStrapBreathingLiveActivity.swift
@@ -32,7 +32,7 @@ struct OpenStrapBreathingAttributes: ActivityAttributes {
var startedAt: Date
}
-// MARK: - Palette (mirrors OpenStrapWidgetLiveActivity's, kept local —
+// MARK: - Palette (lib/ui2/theme.dart; mirrors OpenStrapWidgetLiveActivity's, kept local —
// that file's helpers are `private` to it, so a small deliberate duplication
// here is safer than widening that file's access just to share four colors)
@@ -46,11 +46,15 @@ private extension Color {
private struct BreathingPal {
let clayPaper: Color, ink: Color, inkMuted: Color
+ /// `P.on(C.teal)` — the Mind accent as TEXT on this surface.
+ let onMind: Color
let isDark: Bool
- static let light = BreathingPal(clayPaper: Color(246, 242, 236), ink: Color(26, 23, 20),
- inkMuted: Color(150, 142, 131), isDark: false)
- static let dark = BreathingPal(clayPaper: Color(32, 28, 23), ink: Color(241, 236, 227),
- inkMuted: Color(126, 116, 102), isDark: true)
+ static let light = BreathingPal(clayPaper: Color(0xFF, 0xFF, 0xFF), ink: Color(0x0F, 0x17, 0x2A),
+ inkMuted: Color(0x62, 0x71, 0x88),
+ onMind: Color(0x12, 0x76, 0x74), isDark: false)
+ static let dark = BreathingPal(clayPaper: Color(0x15, 0x1C, 0x26), ink: Color(0xF1, 0xF5, 0xF9),
+ inkMuted: Color(0x7F, 0x8D, 0xA0),
+ onMind: Color(0x14, 0xB8, 0xA6), isDark: true)
static var current: BreathingPal {
(UserDefaults(suiteName: kAppGroup)?.object(forKey: "theme_dark") as? Bool ?? false)
? .dark : .light
@@ -61,7 +65,13 @@ private extension Color {
static var bClayPaper: Color { BreathingPal.current.clayPaper }
static var bInk: Color { BreathingPal.current.ink }
static var bInkMuted: Color { BreathingPal.current.inkMuted }
- static let bRecovery = Color(43, 182, 115) // matches DomainAccent.recovery
+ /// Breathing is the Mind domain — `C.teal` in lib/ui2/theme.dart, the fifth
+ /// tab's accent. Raw pigment for glyphs and keylines…
+ static let bMind = Color(0x14, 0xB8, 0xA6)
+ /// …`P.fill(C.teal)` for anything white sits on (buttons, tints)…
+ static let bMindFill = Color(0x0F, 0x85, 0x78)
+ /// …and `P.on(C.teal)` for accent TEXT.
+ static var bOnMind: Color { BreathingPal.current.onMind }
}
private func coherenceText(_ v: Double) -> String { v >= 0 ? "\(Int(v.rounded()))%" : "Calibrating…" }
@@ -90,9 +100,9 @@ private struct BreathingLockScreenView: View {
HStack(spacing: 14) {
if #available(iOSApplicationExtension 17.0, *) {
Image(systemName: "wind").font(.system(size: 26))
- .foregroundStyle(Color.bRecovery).symbolEffect(.pulse, options: .repeating)
+ .foregroundStyle(Color.bMind).symbolEffect(.pulse, options: .repeating)
} else {
- Image(systemName: "wind").font(.system(size: 26)).foregroundStyle(Color.bRecovery)
+ Image(systemName: "wind").font(.system(size: 26)).foregroundStyle(Color.bMind)
}
VStack(alignment: .leading, spacing: 2) {
Text(coherenceText(score))
@@ -109,7 +119,7 @@ private struct BreathingLockScreenView: View {
Button(intent: EndBreathingIntent()) {
Image(systemName: "stop.fill").font(.system(size: 12, weight: .bold))
}
- .tint(Color.bRecovery).buttonBorderShape(.capsule)
+ .tint(Color.bMindFill).buttonBorderShape(.capsule)
}
}
.padding(16)
@@ -126,18 +136,18 @@ struct OpenStrapBreathingLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: OpenStrapBreathingAttributes.self) { context in
BreathingLockScreenView(context: context)
- .activitySystemActionForegroundColor(Color.bRecovery)
+ .activitySystemActionForegroundColor(Color.bMindFill)
} dynamicIsland: { context in
let score = context.state.coherenceScore
return DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
- Image(systemName: "wind").font(.system(size: 18)).foregroundStyle(Color.bRecovery)
+ Image(systemName: "wind").font(.system(size: 18)).foregroundStyle(Color.bMind)
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing, spacing: 0) {
Text(coherenceText(score))
.font(.system(size: 18, weight: .bold, design: .rounded))
- .foregroundStyle(Color.bRecovery).contentTransition(.numericText())
+ .foregroundStyle(Color.bOnMind).contentTransition(.numericText())
Text("COHERENCE").font(.system(size: 8, weight: .semibold)).tracking(1).foregroundStyle(.secondary)
}
}
@@ -151,18 +161,18 @@ struct OpenStrapBreathingLiveActivity: Widget {
Button(intent: EndBreathingIntent()) {
Label("End session", systemImage: "stop.fill").font(.system(size: 12, weight: .bold))
}
- .tint(Color.bRecovery).buttonBorderShape(.capsule)
+ .tint(Color.bMindFill).buttonBorderShape(.capsule)
}
}
} compactLeading: {
- Image(systemName: "wind").font(.system(size: 14)).foregroundStyle(Color.bRecovery)
+ Image(systemName: "wind").font(.system(size: 14)).foregroundStyle(Color.bMind)
} compactTrailing: {
Text(score >= 0 ? "\(Int(score.rounded()))%" : "·")
- .font(.system(size: 13, weight: .bold, design: .rounded)).foregroundStyle(Color.bRecovery)
+ .font(.system(size: 13, weight: .bold, design: .rounded)).foregroundStyle(Color.bOnMind)
} minimal: {
- Image(systemName: "wind").font(.system(size: 12)).foregroundStyle(Color.bRecovery)
+ Image(systemName: "wind").font(.system(size: 12)).foregroundStyle(Color.bMind)
}
- .keylineTint(Color.bRecovery)
+ .keylineTint(Color.bMind)
}
}
}
diff --git a/ios/OpenStrapWidget/OpenStrapWidget.entitlements b/ios/OpenStrapWidget/OpenStrapWidget.entitlements
deleted file mode 100644
index d9849a81..00000000
--- a/ios/OpenStrapWidget/OpenStrapWidget.entitlements
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
- com.apple.security.application-groups
-
- $(APP_GROUP_IDENTIFIER)
-
-
-
diff --git a/ios/OpenStrapWidget/OpenStrapWidget.swift b/ios/OpenStrapWidget/OpenStrapWidget.swift
index 99b57963..8dfe7319 100644
--- a/ios/OpenStrapWidget/OpenStrapWidget.swift
+++ b/ios/OpenStrapWidget/OpenStrapWidget.swift
@@ -2,24 +2,48 @@
// OpenStrapWidget.swift
// OpenStrapWidget
//
-// Home/lock-screen widget — "Ember on Paper". Renders the snapshot the app
-// writes into the shared App Group; ALSO self-refreshes ~hourly by fetching
-// /today directly (using the JWT + backend URL the app stores in the group), so
-// it stays current even when the app is fully closed. No @main here — the bundle
-// (OpenStrapWidgetBundle.swift) owns it.
+// 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.
//
// Shows three rings: Strain · Sleep · HRV. (Recovery was retired — the app no
// longer surfaces a recovery score; HRV is the real measured autonomic signal.)
+//
+// 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 (Ember on Paper / Char)
+// 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). Surfaces + ink
-// flip between paper and char; the ember coral + ring accents stay constant.
+// (which already resolves "System" to the actual OS brightness).
+//
+// 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`.
+//
+// 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) {
@@ -27,12 +51,29 @@ private extension Color {
}
}
+/// 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
- static let light = Pal(bg: Color(244, 241, 236), ink: Color(26, 23, 20),
- inkMuted: Color(165, 156, 144), track: Color(236, 231, 223))
- static let dark = Pal(bg: Color(30, 26, 21), ink: Color(241, 236, 227),
- inkMuted: Color(126, 116, 102), track: Color(42, 37, 31))
+ /// 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
}
@@ -44,18 +85,30 @@ private extension Color {
static var ink: Color { Pal.current.ink }
static var inkMuted: Color { Pal.current.inkMuted }
static var surfaceAlt: Color { Pal.current.track }
- static let coral = Color(255, 90, 54)
- static let coralDeep = Color(232, 67, 31)
- static let good = Color(43, 182, 115)
- static let sleepBlue = Color(124, 168, 240)
}
// 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 {
- let date: Date
+ 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
@@ -65,35 +118,84 @@ struct OpenStrapEntry: TimelineEntry {
let coachLine: String
static let placeholder = OpenStrapEntry(
- date: Date(), hasData: true, readiness: 72, strain: 12.4,
- sleepMin: 437, needMin: 480, hrv: 62, hrvBaseline: 58, rhr: 54,
+ 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")
- // Ring fractions (0…1).
- var readinessT: Double { readiness >= 0 ? Double(readiness) / 100.0 : 0 }
+ /// 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
+ }
+
+ /// 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
+ }
+
+ 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 {
- if readiness < 0 { return .inkMuted }
- if readiness >= 66 { return .good }
- if readiness >= 40 { return .coral }
- return .coralDeep
+ 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
+ }
}
- var strainT: Double { strain >= 0 ? min(strain / 21.0, 1) : 0 }
- var sleepT: Double { (sleepMin >= 0 && needMin > 0) ? min(Double(sleepMin) / Double(needMin), 1) : 0 }
+ /// 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 else { return 0 }
- if hrvBaseline > 0 { return min(Double(hrv) / (1.5 * Double(hrvBaseline)), 1) }
- return min(Double(hrv) / 100.0, 1)
- }
- // HRV reads green at/above your baseline, warmer as it drops below it.
- var hrvColor: Color {
- guard hrv >= 0, hrvBaseline > 0 else { return .good }
- if hrv >= hrvBaseline { return .good }
- if hrv >= Int(0.8 * Double(hrvBaseline)) { return .coral }
- return .coralDeep
+ 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)
+// MARK: - Shared store (App Group, read-only)
private enum Store {
static var defaults: UserDefaults? { UserDefaults(suiteName: kAppGroup) }
@@ -103,7 +205,10 @@ private enum Store {
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,
@@ -112,80 +217,6 @@ private enum Store {
rhr: d?.object(forKey: "rhr") as? Int ?? -1,
coachLine: d?.string(forKey: "coach_line") ?? "")
}
-
- static func write(_ e: OpenStrapEntry) {
- let d = defaults
- d?.set(true, forKey: "has_data")
- d?.set(e.readiness, forKey: "readiness")
- d?.set(e.strain, forKey: "strain")
- d?.set(e.sleepMin, forKey: "sleep_min")
- d?.set(e.needMin, forKey: "sleep_need_min")
- d?.set(e.hrv, forKey: "hrv")
- d?.set(e.hrvBaseline, forKey: "hrv_baseline")
- d?.set(e.rhr, forKey: "rhr")
- d?.set(e.coachLine, forKey: "coach_line")
- d?.set(Int(Date().timeIntervalSince1970), forKey: "updated_at")
- }
-
- static var backendURL: String { defaults?.string(forKey: "backend_url") ?? "" }
- static var jwt: String { defaults?.string(forKey: "access_jwt") ?? "" }
-}
-
-// MARK: - Self-refresh: fetch /today directly
-
-private enum TodayAPI {
- /// GET {url}/today with the stored JWT, parse into an entry. Falls back to the
- /// cached entry on any failure (offline / expired token / parse error).
- static func fetch(fallback: OpenStrapEntry, completion: @escaping (OpenStrapEntry) -> Void) {
- let base = Store.backendURL
- let token = Store.jwt
- guard !base.isEmpty, !token.isEmpty, let url = URL(string: base + "/today") else {
- completion(fallback); return
- }
- var req = URLRequest(url: url)
- req.httpMethod = "GET"
- req.timeoutInterval = 12
- req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
- URLSession.shared.dataTask(with: req) { data, resp, _ in
- guard
- let http = resp as? HTTPURLResponse, http.statusCode == 200,
- let data = data,
- let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
- else { completion(fallback); return }
- let entry = parse(json) ?? fallback
- Store.write(entry) // keep the cache fresh for the next instant render
- completion(entry)
- }.resume()
- }
-
- private static func parse(_ j: [String: Any]) -> OpenStrapEntry? {
- func obj(_ m: Any?) -> [String: Any]? { m as? [String: Any] }
- func val(_ parent: [String: Any]?, _ key: String) -> Double? {
- guard let leaf = obj(parent?[key]), let v = leaf["value"] as? NSNumber else { return nil }
- return v.doubleValue
- }
- let daily = obj(j["daily"]); let sleep = obj(j["sleep"]); let coach = obj(j["coach"])
- let hrvObj = obj(j["hrv"]) // top-level { rmssd, baseline, ... }
-
- let readiness = val(daily, "readiness").map { Int($0.rounded()) } ?? -1
- let strain = val(daily, "strain") ?? -1
- let rhr = val(daily, "resting_hr").map { Int($0.rounded()) } ?? -1
- let sleepMin = val(sleep, "duration_min").map { Int($0.rounded()) } ?? -1
- let needMin = val(sleep, "need_min").map { Int($0.rounded()) } ?? -1
- let hrv = (hrvObj?["rmssd"] as? NSNumber).map { Int($0.doubleValue.rounded()) } ?? -1
- let hrvBase = (hrvObj?["baseline"] as? NSNumber).map { Int($0.doubleValue.rounded()) } ?? -1
-
- var coachLine = ""
- if let plan = coach?["plan"] as? [[String: Any]], let first = plan.first,
- let title = first["title"] as? String { coachLine = title }
- else if let tgt = obj(coach?["strain_target"]), let v = tgt["value"] as? NSNumber {
- coachLine = "Aim for strain \(Int(v.doubleValue.rounded()))"
- }
- let hasData = daily != nil || sleep != nil
- return OpenStrapEntry(date: Date(), hasData: hasData, readiness: readiness, strain: strain,
- sleepMin: sleepMin, needMin: needMin, hrv: hrv,
- hrvBaseline: hrvBase, rhr: rhr, coachLine: coachLine)
- }
}
// MARK: - Provider
@@ -198,13 +229,26 @@ struct Provider: TimelineProvider {
}
func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
- let cached = Store.read()
- // Refresh from the network (best-effort); fall back to cache. Re-render hourly.
- TodayAPI.fetch(fallback: cached) { entry in
- let next = Calendar.current.date(byAdding: .hour, value: 1, to: Date())
- ?? Date().addingTimeInterval(3600)
- completion(Timeline(entries: [entry], policy: .after(next)))
- }
+ // 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)))
}
}
@@ -227,17 +271,22 @@ private struct Ring: View {
}
}
+/// 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 "—" }
- let h = min / 60, m = min % 60
- if h == 0 { return "\(m)m" }
- if m == 0 { return "\(h)h" }
- return "\(h)h \(m)m"
+ if min < 0 { return "" }
+ if min < 60 { return "\(min)m" }
+ return String(format: "%dh %02dm", min / 60, min % 60)
}
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
@@ -246,6 +295,7 @@ private struct MetricRing: View {
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 {
@@ -255,6 +305,7 @@ private struct MetricRing: View {
.frame(width: size, height: size)
Text(label).font(.system(size: 9, weight: .semibold)).tracking(0.8).foregroundColor(.inkMuted)
}
+ .opacity(absent ? 0.4 : 1)
}
}
@@ -268,13 +319,13 @@ private struct TripleRings: View {
var body: some View {
HStack(spacing: 0) {
MetricRing(label: "STRAIN",
- value: e.strain >= 0 ? String(format: "%.1f", e.strain) : "—",
- t: e.strainT, color: .coral, size: size, line: line, valueSize: valueSize)
+ 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: .sleepBlue, size: size, line: line, valueSize: valueSize - 1)
+ 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)" : "—",
+ MetricRing(label: "HRV", value: e.hrv >= 0 ? "\(e.hrv)" : "",
t: e.hrvT, color: e.hrvColor, size: size, line: line, valueSize: valueSize)
.frame(maxWidth: .infinity)
}
@@ -282,24 +333,35 @@ private struct TripleRings: View {
}
}
-/// Readiness headline row — big ring + score + what it blends.
+/// Readiness headline row — big ring + score + the phone's own band label.
private struct ReadinessRow: View {
let e: OpenStrapEntry
var ring: CGFloat = 64
var body: some View {
HStack(spacing: 12) {
ZStack {
- Ring(t: e.readinessT, color: e.readinessColor, lineWidth: 9)
- Text(e.readiness >= 0 ? "\(e.readiness)" : "—").font(numFont(22)).foregroundColor(e.readinessColor)
+ 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)
- Text(e.readiness >= 0 ? "HRV recovery + sleep" : "Building baseline")
+ // "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)
}
+ .opacity(e.readiness >= 0 ? 1 : 0.55)
}
}
@@ -309,14 +371,14 @@ private struct SmallView: 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.readinessColor, 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: .coral, size: 44, line: 6, valueSize: 13).frame(maxWidth: .infinity)
+ 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: .sleepBlue, 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)
+ 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)
}
}
.padding(12)
@@ -335,41 +397,84 @@ private struct MediumView: View {
}
}
-@available(iOSApplicationExtension 16.0, *)
+/// `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)
+ }
+ 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)
+ }
+ .padding(12)
+ }
+ }
+}
+
private struct AccessoryCircularView: View {
let e: OpenStrapEntry
var body: some View {
- Gauge(value: e.readinessT) {
- Text("RDY")
- } currentValueLabel: {
- Text(e.readiness >= 0 ? "\(e.readiness)" : "—")
+ // 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")
+ } currentValueLabel: {
+ Text("\(e.readiness)")
+ }
+ .gaugeStyle(.accessoryCircular)
+ .widgetAccentable()
+ } else {
+ VStack(spacing: 0) {
+ Image(systemName: "bolt.heart").font(.system(size: 15)).widgetAccentable()
+ Text("RDY").font(.system(size: 9, weight: .semibold))
+ }
}
- .gaugeStyle(.accessoryCircular)
- .widgetAccentable()
}
}
-@available(iOSApplicationExtension 16.0, *)
private struct AccessoryRectangularView: View {
let e: OpenStrapEntry
var body: some View {
VStack(alignment: .leading, spacing: 2) {
- Text("Readiness \(e.readiness >= 0 ? "\(e.readiness)" : "—")").font(.system(size: 13, weight: .bold)).widgetAccentable()
- Text("Strain \(e.strain >= 0 ? String(format: "%.1f", e.strain) : "—") HRV \(e.hrv >= 0 ? "\(e.hrv)" : "—")")
+ Text(e.readiness >= 0 ? "Readiness \(e.readiness)" : "Readiness not scored")
+ .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))
.font(.system(size: 13, weight: .semibold))
- Text("Sleep \(hm(e.sleepMin))" + (e.rhr >= 0 ? " RHR \(e.rhr)" : ""))
+ Text(pair("Sleep", hm(e.sleepMin).isEmpty ? nil : hm(e.sleepMin),
+ "RHR", e.rhr >= 0 ? "\(e.rhr)" : nil))
.font(.system(size: 12)).foregroundStyle(.secondary)
}
}
+
+ /// 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 extension View {
@ViewBuilder func widgetBackground(_ color: Color) -> some View {
- if #available(iOSApplicationExtension 17.0, *) {
- containerBackground(color, for: .widget)
- } else {
- background(color)
- }
+ containerBackground(color, for: .widget)
}
}
@@ -384,20 +489,17 @@ struct OpenStrapWidgetEntryView: View {
private var isSystem: Bool { family == .systemSmall || family == .systemMedium }
@ViewBuilder private var content: some View {
- switch family {
- case .systemSmall: SmallView(e: entry)
- case .systemMedium: MediumView(e: entry)
- default:
- if #available(iOSApplicationExtension 16.0, *) {
- switch family {
- case .accessoryCircular: AccessoryCircularView(e: entry)
- case .accessoryRectangular: AccessoryRectangularView(e: entry)
- case .accessoryInline:
- Text("Ready \(entry.readiness >= 0 ? "\(entry.readiness)" : "—") · Strain \(entry.strain >= 0 ? String(format: "%.1f", entry.strain) : "—")")
- default: SmallView(e: entry)
- }
- } else {
- SmallView(e: entry)
+ if !entry.fresh {
+ NoDataView()
+ } else {
+ switch family {
+ case .systemSmall: SmallView(e: entry)
+ case .systemMedium: MediumView(e: entry)
+ case .accessoryCircular: AccessoryCircularView(e: entry)
+ case .accessoryRectangular: AccessoryRectangularView(e: entry)
+ case .accessoryInline:
+ Text(entry.readiness >= 0 ? "Ready \(entry.readiness)" : "Readiness not scored")
+ default: SmallView(e: entry)
}
}
}
@@ -412,13 +514,7 @@ struct OpenStrapWidget: Widget {
}
.configurationDisplayName("OpenStrap")
.description("Readiness, strain, sleep and HRV at a glance.")
- .supportedFamilies(supportedFamilies)
- }
-
- private var supportedFamilies: [WidgetFamily] {
- if #available(iOSApplicationExtension 16.0, *) {
- return [.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular, .accessoryInline]
- }
- return [.systemSmall, .systemMedium]
+ .supportedFamilies([.systemSmall, .systemMedium,
+ .accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
diff --git a/ios/OpenStrapWidget/OpenStrapWidgetBundle.swift b/ios/OpenStrapWidget/OpenStrapWidgetBundle.swift
index 968ac51e..3f83a519 100644
--- a/ios/OpenStrapWidget/OpenStrapWidgetBundle.swift
+++ b/ios/OpenStrapWidget/OpenStrapWidgetBundle.swift
@@ -13,7 +13,6 @@ struct OpenStrapWidgetBundle: WidgetBundle {
var body: some Widget {
OpenStrapWidget()
OpenStrapBatteryWidget()
- OpenStrapWidgetControl()
OpenStrapWidgetLiveActivity()
OpenStrapBreathingLiveActivity()
}
diff --git a/ios/OpenStrapWidget/OpenStrapWidgetControl.swift b/ios/OpenStrapWidget/OpenStrapWidgetControl.swift
deleted file mode 100644
index 3f086fef..00000000
--- a/ios/OpenStrapWidget/OpenStrapWidgetControl.swift
+++ /dev/null
@@ -1,54 +0,0 @@
-//
-// OpenStrapWidgetControl.swift
-// OpenStrapWidget
-//
-// Created by Abdul Sahil - garden on 12/06/26.
-//
-
-import AppIntents
-import SwiftUI
-import WidgetKit
-
-struct OpenStrapWidgetControl: ControlWidget {
- var body: some ControlWidgetConfiguration {
- StaticControlConfiguration(
- kind: Bundle.main.bundleIdentifier ?? "OpenStrapWidget",
- provider: Provider()
- ) { value in
- ControlWidgetToggle(
- "Start Timer",
- isOn: value,
- action: StartTimerIntent()
- ) { isRunning in
- Label(isRunning ? "On" : "Off", systemImage: "timer")
- }
- }
- .displayName("Timer")
- .description("A an example control that runs a timer.")
- }
-}
-
-extension OpenStrapWidgetControl {
- struct Provider: ControlValueProvider {
- var previewValue: Bool {
- false
- }
-
- func currentValue() async throws -> Bool {
- let isRunning = true // Check if the timer is running
- return isRunning
- }
- }
-}
-
-struct StartTimerIntent: SetValueIntent {
- static let title: LocalizedStringResource = "Start a timer"
-
- @Parameter(title: "Timer is running")
- var value: Bool
-
- func perform() async throws -> some IntentResult {
- // Start / stop the timer based on `value`.
- return .result()
- }
-}
diff --git a/ios/OpenStrapWidget/OpenStrapWidgetLiveActivity.swift b/ios/OpenStrapWidget/OpenStrapWidgetLiveActivity.swift
index 5b54a7cd..f14cad05 100644
--- a/ios/OpenStrapWidget/OpenStrapWidgetLiveActivity.swift
+++ b/ios/OpenStrapWidget/OpenStrapWidgetLiveActivity.swift
@@ -37,10 +37,16 @@ struct OpenStrapWidgetAttributes: ActivityAttributes {
var targetKcal: Int
}
-// MARK: - Palette (Ember on Paper / Char, clay)
+// MARK: - Palette (lib/ui2/theme.dart, clay)
// Mirrors the app's in-app appearance via the shared App Group flag "theme_dark"
// (which already accounts for an OS-overriding choice). The clay surface + ink
-// flip; the ember coral + zone accents stay constant in both modes.
+// flip; the accents stay constant in both modes.
+//
+// ui2 tokens, not the retired lib/theme/tokens.dart ones. The clay surface is
+// `P.card` over a `P.card2` sunk well; live HR carries the Heart domain accent
+// (`C.red`, as on the phone's Heart-rate card) and strain the Movement one
+// (`C.purple`). Accent TEXT uses the `P.on()`-solved variant, accent FILL the
+// `P.fill()` one — see the note at the top of OpenStrapWidget.swift.
private let kAppGroup = AppGroup.identifier
@@ -52,11 +58,27 @@ private extension Color {
private struct Pal {
let clayPaper: Color, claySunk: Color, ink: Color, inkMuted: Color
+ /// `P.on(C.red)` / `P.on(C.purple)` — the accents as TEXT on this surface.
+ let onHeart: Color, onMove: Color
+ /// The five HR-zone bands as ui2 draws them: `ZoneBar.pigment` run through
+ /// `P.on` (lib/ui2/charts.dart:729-733), because raw zone 1 measured 1.80:1
+ /// on a light card and read as a pale smear.
+ let zones: [Color]
let isDark: Bool
- static let light = Pal(clayPaper: Color(246, 242, 236), claySunk: Color(232, 226, 217),
- ink: Color(26, 23, 20), inkMuted: Color(150, 142, 131), isDark: false)
- static let dark = Pal(clayPaper: Color(32, 28, 23), claySunk: Color(46, 40, 32),
- ink: Color(241, 236, 227), inkMuted: Color(126, 116, 102), isDark: true)
+ static let light = Pal(clayPaper: Color(0xFF, 0xFF, 0xFF), claySunk: Color(0xF1, 0xF5, 0xF9),
+ ink: Color(0x0F, 0x17, 0x2A), inkMuted: Color(0x62, 0x71, 0x88),
+ onHeart: Color(0xB9, 0x39, 0x3E), onMove: Color(0x74, 0x4E, 0xCF),
+ zones: [Color(0x51, 0x6E, 0x95), Color(0x30, 0x65, 0xC1),
+ Color(0x1A, 0x79, 0x48), Color(0xA5, 0x52, 0x1D),
+ Color(0xB9, 0x39, 0x3E)],
+ isDark: false)
+ static let dark = Pal(clayPaper: Color(0x15, 0x1C, 0x26), claySunk: Color(0x1D, 0x26, 0x32),
+ ink: Color(0xF1, 0xF5, 0xF9), inkMuted: Color(0x7F, 0x8D, 0xA0),
+ onHeart: Color(0xEF, 0x73, 0x73), onMove: Color(0xA9, 0x89, 0xF6),
+ zones: [Color(0x93, 0xC5, 0xFD), Color(0x68, 0x9F, 0xF7),
+ Color(0x22, 0xC5, 0x5E), Color(0xF8, 0x7F, 0x2A),
+ Color(0xEF, 0x73, 0x73)],
+ isDark: true)
static var current: Pal {
(UserDefaults(suiteName: kAppGroup)?.object(forKey: "theme_dark") as? Bool ?? false)
? .dark : .light
@@ -68,18 +90,17 @@ private extension Color {
static var claySunk: Color { Pal.current.claySunk }
static var ink: Color { Pal.current.ink }
static var inkMuted: Color { Pal.current.inkMuted }
- static let coral = Color(255, 90, 54)
- static let coralDeep = Color(232, 67, 31)
+ /// The raw Heart pigment, for glyphs and arcs (non-text UI).
+ static let heart = Color(0xEF, 0x44, 0x44)
+ /// `P.fill(C.red)` — darkened until white on it clears AA. Buttons, tints.
+ static let heartFill = Color(0xD8, 0x3D, 0x3D)
+ static var onHeart: Color { Pal.current.onHeart }
+ static var onMove: Color { Pal.current.onMove }
}
-private let zonePalette: [Color] = [
- Color(124, 168, 240), // Z1 blue
- Color(43, 182, 115), // Z2 green
- Color(255, 90, 54), // Z3 coral
- Color(232, 67, 31), // Z4 deep
- Color(229, 72, 77), // Z5 red
-]
-private func zoneColor(_ z: Int) -> Color { (z >= 1 && z <= 5) ? zonePalette[z - 1] : .inkMuted }
+private func zoneColor(_ z: Int) -> Color {
+ (z >= 1 && z <= 5) ? Pal.current.zones[z - 1] : .inkMuted
+}
// MARK: - Claymorphic surface
@@ -110,9 +131,9 @@ private struct PulseHeart: View {
var body: some View {
if #available(iOSApplicationExtension 17.0, *) {
Image(systemName: "heart.fill").font(.system(size: size))
- .foregroundStyle(Color.coral).symbolEffect(.pulse, options: .repeating)
+ .foregroundStyle(Color.heart).symbolEffect(.pulse, options: .repeating)
} else {
- Image(systemName: "heart.fill").font(.system(size: size)).foregroundStyle(Color.coral)
+ Image(systemName: "heart.fill").font(.system(size: size)).foregroundStyle(Color.heart)
}
}
}
@@ -132,10 +153,13 @@ private struct ZoneBar: View {
}
}
-private func hrText(_ v: Int) -> String { v > 0 ? "\(v)" : "—" }
-// Unscored sessions push null rather than 0 — render the absence.
-private func strainText(_ v: Double?) -> String { v.map { String(format: "%.1f", $0) } ?? "—" }
-private func kcalText(_ v: Int?) -> String { v.map { "\($0)" } ?? "—" }
+// "" = nothing measured. A bare em-dash is the one rendering the phone's
+// grammar forbids outright (lib/ui2/grammar.dart:566-568), and it forbids it
+// here too: the lock screen dims the slot instead. Unscored sessions push null
+// rather than 0, so absence arrives as absence and stays that way.
+private func hrText(_ v: Int) -> String { v > 0 ? "\(v)" : "" }
+private func strainText(_ v: Double?) -> String { v.map { String(format: "%.1f", $0) } ?? "" }
+private func kcalText(_ v: Int?) -> String { v.map { "\($0)" } ?? "" }
// MARK: - Finish (interactive, iOS 17+)
@@ -169,8 +193,8 @@ private struct LockScreenView: View {
}
Spacer()
HStack(spacing: 14) {
- stat("STRAIN", strainText(s.strain), .coralDeep)
- stat("KCAL", kcalText(s.calories), .coral)
+ stat("STRAIN", strainText(s.strain), .onMove)
+ stat("KCAL", kcalText(s.calories), .onHeart)
}
}
VStack(alignment: .leading, spacing: 5) {
@@ -191,6 +215,7 @@ private struct LockScreenView: View {
VStack(spacing: 1) {
Text(value).font(.system(size: 18, weight: .bold, design: .rounded)).foregroundStyle(c)
.contentTransition(.numericText())
+ .opacity(value.isEmpty ? 0.4 : 1)
Text(label).font(.system(size: 8, weight: .semibold)).tracking(1).foregroundStyle(Color.inkMuted)
}
}
@@ -202,7 +227,7 @@ struct OpenStrapWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: OpenStrapWidgetAttributes.self) { context in
LockScreenView(context: context)
- .activitySystemActionForegroundColor(Color.coralDeep)
+ .activitySystemActionForegroundColor(Color.heartFill)
} dynamicIsland: { context in
let s = context.state
return DynamicIsland {
@@ -217,7 +242,7 @@ struct OpenStrapWidgetLiveActivity: Widget {
VStack(alignment: .trailing, spacing: 0) {
Text(strainText(s.strain))
.font(.system(size: 20, weight: .bold, design: .rounded))
- .foregroundStyle(Color.coral).contentTransition(.numericText())
+ .foregroundStyle(Color.onMove).contentTransition(.numericText())
Text("STRAIN").font(.system(size: 8, weight: .semibold)).tracking(1).foregroundStyle(.secondary)
}
}
@@ -229,12 +254,15 @@ struct OpenStrapWidgetLiveActivity: Widget {
DynamicIslandExpandedRegion(.bottom) {
HStack(spacing: 10) {
ZoneBar(zone: s.zone)
- Text("\(kcalText(s.calories)) kcal").font(.system(size: 12, weight: .semibold)).foregroundStyle(.secondary)
+ // Absent stays absent: a bare " kcal" with nothing in front of it
+ // is the unit claiming a measurement we don't have. The lock
+ // screen dims the empty slot; here the whole label goes.
+ Text(s.calories.map { "\($0) kcal" } ?? "").font(.system(size: 12, weight: .semibold)).foregroundStyle(.secondary)
if #available(iOSApplicationExtension 17.0, *) {
Button(intent: EndSessionIntent()) {
Image(systemName: "stop.fill").font(.system(size: 12, weight: .bold))
}
- .tint(Color.coralDeep).buttonBorderShape(.capsule)
+ .tint(Color.heartFill).buttonBorderShape(.capsule)
}
}.padding(.top, 2)
}
@@ -247,9 +275,9 @@ struct OpenStrapWidgetLiveActivity: Widget {
Text(s.zone >= 1 ? "Z\(s.zone)" : "·")
.font(.system(size: 13, weight: .bold, design: .rounded)).foregroundStyle(zoneColor(s.zone))
} minimal: {
- Text(hrText(s.hr)).font(.system(size: 13, weight: .bold, design: .rounded)).foregroundStyle(Color.coral)
+ Text(hrText(s.hr)).font(.system(size: 13, weight: .bold, design: .rounded)).foregroundStyle(Color.heart)
}
- .keylineTint(Color.coral)
+ .keylineTint(Color.heart)
}
}
}
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 74afa0cd..aeb7a31f 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -174,7 +174,7 @@ PODS:
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- - GoogleDataTransport (10.1.0):
+ - GoogleDataTransport (10.1.1):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleUtilities/AppDelegateSwizzler (8.1.2):
@@ -370,7 +370,7 @@ SPEC CHECKSUMS:
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
GoogleAdsOnDeviceConversion: e3b71da24ff4cf01cf520756ac1c2b93c07b86ff
GoogleAppMeasurement: a6d37949071d456e9147dac6789c4342e0e7a8c5
- GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
+ GoogleDataTransport: a24e58982ab3ba2f64d79613e027fe7f57e88539
GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850
health: a4ddeac72091000e94776864d0028f6be31ec7a5
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
@@ -389,4 +389,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: b50997058227f33b81189532a9f3fc5007ec070b
-COCOAPODS: 1.16.2
+COCOAPODS: 1.17.0
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index b32e7afd..4b86b7f2 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -20,9 +20,11 @@
534897A72FDC2B310033A4D9 /* LiveActivityBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 534897A62FDC2B310033A4D9 /* LiveActivityBridge.swift */; };
53962E962FF6EE120061A61B /* WatchBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53962E952FF6EE120061A61B /* WatchBridge.swift */; };
53962E972FF6EE120061A61B /* OpenStrapIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53962E942FF6EE120061A61B /* OpenStrapIntents.swift */; };
+ 60DE9D949573401269D6DF2E /* HealthRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46A2B400A52A2CA90242C195 /* HealthRoutes.swift */; };
630172CC9317145AD5F8F3B7 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C721EA0C8D31A3834E66203 /* Pods_RunnerTests.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -99,6 +101,7 @@
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 46A2B400A52A2CA90242C195 /* HealthRoutes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HealthRoutes.swift; sourceTree = ""; };
5348973B2FDC19C80033A4D9 /* OpenStrapWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenStrapWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
5348973C2FDC19C80033A4D9 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
5348973E2FDC19C80033A4D9 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
@@ -116,6 +119,7 @@
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
958D0478FBC7185E997FABAA /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
@@ -198,6 +202,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
0DCDABAA1F7E5CB91B882313 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -236,6 +241,7 @@
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -292,6 +298,7 @@
606824C7302BC5108CEC40DF /* BleRestoreManager.swift */,
ACCE55E7000000000000F11E /* AccessorySetup.swift */,
0BGTASK00000000000000002 /* BgSyncScheduler.swift */,
+ 46A2B400A52A2CA90242C195 /* HealthRoutes.swift */,
);
path = Runner;
sourceTree = "";
@@ -396,6 +403,9 @@
FADE0004FADE0004FADE0004 /* PBXTargetDependency */,
);
name = Runner;
+ packageProductDependencies = (
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
+ );
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
@@ -437,6 +447,9 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
+ packageReferences = (
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
+ );
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -541,6 +554,21 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
+ 47E3AF2498550A854A4821B6 /* Ensure GoogleService-Info.plist */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "Ensure GoogleService-Info.plist";
+ outputPaths = (
+ "$(SRCROOT)/Runner/GoogleService-Info.plist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "# GoogleService-Info.plist is gitignored (no committed credentials, real or\n# placeholder). The Resources phase below still statically references it, so\n# a fresh clone with no Firebase project configured needs SOMETHING on disk\n# at this path or the build fails outright. Generate a format-valid, inert\n# placeholder here (never committed, never overwrites a real file a\n# contributor has placed for their own local Firebase project) so Firebase\n# telemetry stays fully optional at build time, matching the Android side.\n# `-s` (exists AND non-empty), not `-f`: CI writes this file unconditionally\n# from a secret (base64 -d > ...), so an unset/blank secret leaves a 0-byte\n# file that `-f` would treat as \\\"already present\\\" and skip regenerating.\nPLIST=\"$SRCROOT/Runner/GoogleService-Info.plist\"\nif [ ! -s \"$PLIST\" ]; then\n cat > \"$PLIST\" <<'EOF'\n\n\n\n\n\tAPI_KEY\n\tAIzaSyDummyDummyDummyDummyDummyDummyDum\n\tGCM_SENDER_ID\n\t000000000000\n\tPLIST_VERSION\n\t1\n\tBUNDLE_ID\n\twtf.openstrap.dummy\n\tPROJECT_ID\n\tdummy-project\n\tSTORAGE_BUCKET\n\tdummy-project.appspot.com\n\tIS_ADS_ENABLED\n\t\n\tIS_ANALYTICS_ENABLED\n\t\n\tIS_APPINVITE_ENABLED\n\t\n\tIS_GCM_ENABLED\n\t\n\tIS_SIGNIN_ENABLED\n\t\n\tGOOGLE_APP_ID\n\t1:000000000000:ios:0000000000000000000000\n\n\nEOF\nfi\n";
+ };
937F4BC2D23308952D408C29 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -578,21 +606,6 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
- 47E3AF2498550A854A4821B6 /* Ensure GoogleService-Info.plist */ = {
- isa = PBXShellScriptBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- inputPaths = (
- );
- name = "Ensure GoogleService-Info.plist";
- outputPaths = (
- "$(SRCROOT)/Runner/GoogleService-Info.plist",
- );
- runOnlyForDeploymentPostprocessing = 0;
- shellPath = /bin/sh;
- shellScript = "# GoogleService-Info.plist is gitignored (no committed credentials, real or\n# placeholder). The Resources phase below still statically references it, so\n# a fresh clone with no Firebase project configured needs SOMETHING on disk\n# at this path or the build fails outright. Generate a format-valid, inert\n# placeholder here (never committed, never overwrites a real file a\n# contributor has placed for their own local Firebase project) so Firebase\n# telemetry stays fully optional at build time, matching the Android side.\n# `-s` (exists AND non-empty), not `-f`: CI writes this file unconditionally\n# from a secret (base64 -d > ...), so an unset/blank secret leaves a 0-byte\n# file that `-f` would treat as \\\"already present\\\" and skip regenerating.\nPLIST=\"$SRCROOT/Runner/GoogleService-Info.plist\"\nif [ ! -s \"$PLIST\" ]; then\n cat > \"$PLIST\" <<'EOF'\n\n\n\n\n\tAPI_KEY\n\tAIzaSyDummyDummyDummyDummyDummyDummyDum\n\tGCM_SENDER_ID\n\t000000000000\n\tPLIST_VERSION\n\t1\n\tBUNDLE_ID\n\twtf.openstrap.dummy\n\tPROJECT_ID\n\tdummy-project\n\tSTORAGE_BUCKET\n\tdummy-project.appspot.com\n\tIS_ADS_ENABLED\n\t\n\tIS_ANALYTICS_ENABLED\n\t\n\tIS_APPINVITE_ENABLED\n\t\n\tIS_GCM_ENABLED\n\t\n\tIS_SIGNIN_ENABLED\n\t\n\tGOOGLE_APP_ID\n\t1:000000000000:ios:0000000000000000000000\n\n\nEOF\nfi\n";
- };
AEFDDC7BFE6597FE6A6F1BB9 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -666,6 +679,7 @@
46AF580757DC25648B6B0C95 /* BleRestoreManager.swift in Sources */,
ACCE55E7000000000000B11D /* AccessorySetup.swift in Sources */,
0BGTASK00000000000000001 /* BgSyncScheduler.swift in Sources */,
+ 60DE9D949573401269D6DF2E /* HealthRoutes.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -767,6 +781,8 @@
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = AppIconBW;
+ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
@@ -858,7 +874,7 @@
INFOPLIST_KEY_CFBundleDisplayName = OpenStrapWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_OpenStrapAppGroupIdentifier = "$(APP_GROUP_IDENTIFIER)";
- IPHONEOS_DEPLOYMENT_TARGET = 26.5;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -905,7 +921,7 @@
INFOPLIST_KEY_CFBundleDisplayName = OpenStrapWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_OpenStrapAppGroupIdentifier = "$(APP_GROUP_IDENTIFIER)";
- IPHONEOS_DEPLOYMENT_TARGET = 26.5;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -949,7 +965,7 @@
INFOPLIST_KEY_CFBundleDisplayName = OpenStrapWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_OpenStrapAppGroupIdentifier = "$(APP_GROUP_IDENTIFIER)";
- IPHONEOS_DEPLOYMENT_TARGET = 26.5;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1246,6 +1262,8 @@
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = AppIconBW;
+ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
@@ -1270,6 +1288,8 @@
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = AppIconBW;
+ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
@@ -1342,6 +1362,20 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = FlutterGeneratedPluginSwiftPackage;
+ };
+/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
index e3773d42..c3fedb29 100644
--- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
+++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -5,6 +5,24 @@
+
+
+
+
+
+
+
+
+
+
from else {
+ result(0)
+ return
+ }
+ guard from >= Date().addingTimeInterval(-cacheWindow) else {
+ result(notCovered)
+ return
+ }
+ pedometer.queryPedometerData(from: from, to: to) { data, error in
+ DispatchQueue.main.async {
+ guard let data = data, error == nil else {
+ result(nil)
+ return
+ }
+ result(data.numberOfSteps.intValue)
+ }
+ }
+
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+ }
+}
+
// Band-gesture actions on iOS. Media control is deliberately NOT offered: iOS has no
// public API to control a third-party player (Spotify et al.) — only Apple Music via
// systemMusicPlayer — so advertising it would be misleading. The only sanctioned
@@ -175,3 +293,47 @@ enum ActionBridge {
}
}
}
+
+/// Switching the home-screen icon.
+///
+/// `setAlternateIconName` is the only public way to do this, and it comes with
+/// a cost the UI has to be honest about: iOS puts up its own "You have changed
+/// the icon for OpenStrap" alert on every change, and there is no way to
+/// suppress it. The icons themselves are compiled into the asset catalog
+/// (AppIcon / AppIconBW) and named by ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES
+/// in the Runner target — nothing here can invent one that was not built in.
+///
+/// `available` is asked rather than assumed: alternate icons are refused on some
+/// managed/enterprise configurations, and a settings row that cannot work should
+/// not be drawn.
+enum AppIconBridge {
+ private static let channelName = "openstrap/app_icon"
+
+ static func register(messenger: FlutterBinaryMessenger) {
+ let channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger)
+ channel.setMethodCallHandler { call, result in
+ switch call.method {
+ case "available":
+ result(UIApplication.shared.supportsAlternateIcons)
+ case "current":
+ // nil means the primary icon. iOS owns this state — nothing is mirrored
+ // into prefs, so the app can never disagree with the home screen.
+ result(UIApplication.shared.alternateIconName)
+ case "set":
+ guard UIApplication.shared.supportsAlternateIcons else {
+ result(false)
+ return
+ }
+ let name = (call.arguments as? [String: Any])?["name"] as? String
+ UIApplication.shared.setAlternateIconName(name) { error in
+ if let error = error {
+ NSLog("[app_icon] setAlternateIconName(\(name ?? "nil")) failed: \(error)")
+ }
+ result(error == nil)
+ }
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
index d5e1e7b8..cba651ab 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
index 59a05975..f7ed7c5a 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
index 03388cb5..43910ade 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
index 0d285034..ddffdb30 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
index 835273fe..9fcaf9e4 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
index 90c20740..31d05139 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
index 5731414c..37767b3e 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
index 03388cb5..43910ade 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
index 6a05fa22..77c9e19a 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
index 0354f826..deedab73 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png
index 5e39c02d..8394f961 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png
index e4ecfad3..065dc987 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png
index 2e701207..ae81588d 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png
index 410b5ce3..df8c3e54 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
index 0354f826..deedab73 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
index a9c0089d..1f989cf5 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png
index 2fc52a5b..804ea9cb 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png
index e2a9f247..48b87c6f 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
index 7aad4884..dde0d505 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
index 43146a01..7149b95f 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
index 00d64ace..073d8859 100644
Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Contents.json
new file mode 100644
index 00000000..75c10194
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Contents.json
@@ -0,0 +1,116 @@
+{
+ "images": [
+ {
+ "size": "20x20",
+ "idiom": "iphone",
+ "filename": "Icon-BW-20x20@2x.png",
+ "scale": "2x"
+ },
+ {
+ "size": "20x20",
+ "idiom": "iphone",
+ "filename": "Icon-BW-20x20@3x.png",
+ "scale": "3x"
+ },
+ {
+ "size": "29x29",
+ "idiom": "iphone",
+ "filename": "Icon-BW-29x29@1x.png",
+ "scale": "1x"
+ },
+ {
+ "size": "29x29",
+ "idiom": "iphone",
+ "filename": "Icon-BW-29x29@2x.png",
+ "scale": "2x"
+ },
+ {
+ "size": "29x29",
+ "idiom": "iphone",
+ "filename": "Icon-BW-29x29@3x.png",
+ "scale": "3x"
+ },
+ {
+ "size": "40x40",
+ "idiom": "iphone",
+ "filename": "Icon-BW-40x40@2x.png",
+ "scale": "2x"
+ },
+ {
+ "size": "40x40",
+ "idiom": "iphone",
+ "filename": "Icon-BW-40x40@3x.png",
+ "scale": "3x"
+ },
+ {
+ "size": "60x60",
+ "idiom": "iphone",
+ "filename": "Icon-BW-60x60@2x.png",
+ "scale": "2x"
+ },
+ {
+ "size": "60x60",
+ "idiom": "iphone",
+ "filename": "Icon-BW-60x60@3x.png",
+ "scale": "3x"
+ },
+ {
+ "size": "20x20",
+ "idiom": "ipad",
+ "filename": "Icon-BW-20x20@1x.png",
+ "scale": "1x"
+ },
+ {
+ "size": "20x20",
+ "idiom": "ipad",
+ "filename": "Icon-BW-20x20@2x.png",
+ "scale": "2x"
+ },
+ {
+ "size": "29x29",
+ "idiom": "ipad",
+ "filename": "Icon-BW-29x29@1x.png",
+ "scale": "1x"
+ },
+ {
+ "size": "29x29",
+ "idiom": "ipad",
+ "filename": "Icon-BW-29x29@2x.png",
+ "scale": "2x"
+ },
+ {
+ "size": "40x40",
+ "idiom": "ipad",
+ "filename": "Icon-BW-40x40@1x.png",
+ "scale": "1x"
+ },
+ {
+ "size": "40x40",
+ "idiom": "ipad",
+ "filename": "Icon-BW-40x40@2x.png",
+ "scale": "2x"
+ },
+ {
+ "size": "76x76",
+ "idiom": "ipad",
+ "filename": "Icon-BW-76x76@1x.png",
+ "scale": "1x"
+ },
+ {
+ "size": "76x76",
+ "idiom": "ipad",
+ "filename": "Icon-BW-76x76@2x.png",
+ "scale": "2x"
+ },
+ {
+ "size": "83.5x83.5",
+ "idiom": "ipad",
+ "filename": "Icon-BW-83.5x83.5@2x.png",
+ "scale": "2x"
+ }
+ ],
+ "info": {
+ "version": 1,
+ "author": "xcode"
+ }
+}
\ No newline at end of file
diff --git a/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-20x20@1x.png
new file mode 100644
index 00000000..ed43ffaa
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-20x20@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-20x20@2x.png
new file mode 100644
index 00000000..923b33ec
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-20x20@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-20x20@3x.png
new file mode 100644
index 00000000..6270e041
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-20x20@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-29x29@1x.png
new file mode 100644
index 00000000..eb7965ed
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-29x29@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-29x29@2x.png
new file mode 100644
index 00000000..f09f53d4
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-29x29@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-29x29@3x.png
new file mode 100644
index 00000000..ab86af1f
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-29x29@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-40x40@1x.png
new file mode 100644
index 00000000..923b33ec
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-40x40@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-40x40@2x.png
new file mode 100644
index 00000000..2ac05090
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-40x40@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-40x40@3x.png
new file mode 100644
index 00000000..4cf27dd4
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-40x40@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-60x60@2x.png
new file mode 100644
index 00000000..4cf27dd4
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-60x60@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-60x60@3x.png
new file mode 100644
index 00000000..64789f64
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-60x60@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-76x76@1x.png
new file mode 100644
index 00000000..d70633c2
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-76x76@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-76x76@2x.png
new file mode 100644
index 00000000..588e6c5b
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-76x76@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-83.5x83.5@2x.png
new file mode 100644
index 00000000..babb7e78
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIconBW.appiconset/Icon-BW-83.5x83.5@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json
index 19292716..0139c048 100644
--- a/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json
+++ b/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json
@@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "0xEC",
- "green" : "0xF1",
- "red" : "0xF4"
+ "blue" : "0xFC",
+ "green" : "0xFA",
+ "red" : "0xF8"
}
},
"idiom" : "universal"
@@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "0x0D",
- "green" : "0x11",
- "red" : "0x14"
+ "blue" : "0x17",
+ "green" : "0x10",
+ "red" : "0x0B"
}
},
"idiom" : "universal"
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
deleted file mode 100644
index 0bedcf2f..00000000
--- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "images" : [
- {
- "idiom" : "universal",
- "filename" : "LaunchImage.png",
- "scale" : "1x"
- },
- {
- "idiom" : "universal",
- "filename" : "LaunchImage@2x.png",
- "scale" : "2x"
- },
- {
- "idiom" : "universal",
- "filename" : "LaunchImage@3x.png",
- "scale" : "3x"
- }
- ],
- "info" : {
- "version" : 1,
- "author" : "xcode"
- }
-}
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
deleted file mode 100644
index bf1b93a1..00000000
Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
deleted file mode 100644
index 664f151c..00000000
Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
deleted file mode 100644
index 89f9b6b7..00000000
Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
deleted file mode 100644
index 89c2725b..00000000
--- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Launch Screen Assets
-
-You can customize the launch screen with your own desired assets by replacing the image files in this directory.
-
-You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard
index ad5c6556..fa344125 100644
--- a/ios/Runner/Base.lproj/LaunchScreen.storyboard
+++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -1,4 +1,18 @@
+
@@ -15,15 +29,7 @@
-
-
-
-
-
-
-
-
@@ -31,7 +37,4 @@
-
-
-
diff --git a/ios/Runner/HealthRoutes.swift b/ios/Runner/HealthRoutes.swift
new file mode 100644
index 00000000..6678388a
--- /dev/null
+++ b/ios/Runner/HealthRoutes.swift
@@ -0,0 +1,173 @@
+import CoreLocation
+import Flutter
+import HealthKit
+
+// HKWorkoutRoute → Dart. The `health` plugin this app already depends on reads
+// workouts but has no route API whatsoever (the string "route" does not appear
+// anywhere in health 11.1.1's lib/, ios/ or android/ sources), and a route is
+// the one thing in an imported workout that cannot be reconstructed from the
+// summary. So: the smallest possible channel — one method, coordinates only.
+//
+// Workouts themselves are deliberately NOT read here. The plugin already does
+// that correctly on both platforms, and duplicating it natively would mean two
+// definitions of "a workout" that could drift. This returns coordinates keyed
+// by the workout's uuid, and Dart joins them to the workouts it already has.
+//
+// The uuid string is `workout.uuid.uuidString`, which is byte-identical to what
+// the plugin emits for the same sample ("\(sample.uuid)"), so the join is exact
+// rather than a start-time match with a tolerance.
+enum HealthRouteBridge {
+ private static let channelName = "openstrap/health_routes"
+ private static let store = HKHealthStore()
+
+ /// One point per this many seconds. HKWorkoutRoute delivers roughly 1 Hz, so
+ /// an hour's run is ~3600 locations and a 90-day import is easily a million
+ /// rows — for a line on a map that is a few hundred pixels wide.
+ ///
+ /// ponytail: fixed 5 s decimation. If a route ever needs real per-point
+ /// analysis (grade, live pace) rather than drawing, take the raw cadence and
+ /// decimate at read time instead.
+ private static let minPointInterval: TimeInterval = 5
+
+ static func register(messenger: FlutterBinaryMessenger) {
+ let channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger)
+ channel.setMethodCallHandler { call, result in
+ switch call.method {
+ case "routes":
+ guard HKHealthStore.isHealthDataAvailable() else {
+ result([]) // iPad and the simulator. Not an error, just no store.
+ return
+ }
+ let args = call.arguments as? [String: Any] ?? [:]
+ guard let fromMs = args["fromMs"] as? Int, let toMs = args["toMs"] as? Int else {
+ result([])
+ return
+ }
+ let from = Date(timeIntervalSince1970: Double(fromMs) / 1000)
+ let to = Date(timeIntervalSince1970: Double(toMs) / 1000)
+ // `HKSeriesType.workoutRoute()` is a SEPARATE read authorization from
+ // the workout type, and the `health` plugin requests only the latter —
+ // so without this the route query returns nothing on a store the user
+ // has already granted workouts for, which is indistinguishable from
+ // owning no routes. Idempotent: HealthKit re-prompts only for types the
+ // user has not yet decided on.
+ //
+ // The grant is deliberately not checked. Apple does not report READ
+ // denial (`authorizationStatus` answers for writes only, by design, so
+ // an app cannot detect what it is being refused) — an empty result is
+ // the only honest answer to both "denied" and "none recorded".
+ store.requestAuthorization(
+ toShare: [], read: [HKSeriesType.workoutRoute(), HKObjectType.workoutType()]
+ ) { _, _ in
+ routes(
+ from: from, to: to,
+ completion: { payload in
+ DispatchQueue.main.async { result(payload) }
+ })
+ }
+
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+ }
+
+ /// Every workout in the window that HAS a route, as
+ /// `[{uuid, points: [[lat, lng, alt, tsMs], …]}]`.
+ ///
+ /// Workouts without a route are omitted entirely rather than returned with an
+ /// empty list: Dart stores the workouts from the plugin regardless, and an
+ /// empty entry here would only be a second way of saying nothing.
+ private static func routes(
+ from: Date, to: Date, completion: @escaping ([[String: Any]]) -> Void
+ ) {
+ let predicate = HKQuery.predicateForSamples(withStart: from, end: to, options: [])
+ let query = HKSampleQuery(
+ sampleType: HKObjectType.workoutType(), predicate: predicate,
+ limit: HKObjectQueryNoLimit, sortDescriptors: nil
+ ) { _, samples, error in
+ guard let workouts = samples as? [HKWorkout], error == nil, !workouts.isEmpty else {
+ completion([])
+ return
+ }
+ // Each workout's route is its own async query, so collect under a group
+ // and answer once. Serialised through a queue rather than a lock: the
+ // HealthKit callbacks arrive on arbitrary threads and `out` is a value
+ // type, so appending from several of them at once is a data race.
+ let group = DispatchGroup()
+ let queue = DispatchQueue(label: "openstrap.health.routes")
+ var out: [[String: Any]] = []
+ for workout in workouts {
+ group.enter()
+ points(for: workout) { pts in
+ if !pts.isEmpty {
+ queue.async {
+ out.append(["uuid": workout.uuid.uuidString, "points": pts])
+ group.leave()
+ }
+ } else {
+ group.leave()
+ }
+ }
+ }
+ group.notify(queue: queue) { completion(out) }
+ }
+ store.execute(query)
+ }
+
+ /// The decimated coordinate list for one workout, or empty if it has none.
+ private static func points(
+ for workout: HKWorkout, completion: @escaping ([[Any]]) -> Void
+ ) {
+ let routeQuery = HKSampleQuery(
+ sampleType: HKSeriesType.workoutRoute(),
+ predicate: HKQuery.predicateForObjects(from: workout),
+ limit: HKObjectQueryNoLimit, sortDescriptors: nil
+ ) { _, samples, error in
+ guard let routes = samples as? [HKWorkoutRoute], error == nil, !routes.isEmpty else {
+ completion([]) // No route, or the user withheld it. Both mean no line.
+ return
+ }
+ let group = DispatchGroup()
+ let queue = DispatchQueue(label: "openstrap.health.route.points")
+ var all: [CLLocation] = []
+ for route in routes {
+ group.enter()
+ // HKWorkoutRouteQuery streams in batches and calls back repeatedly
+ // until `done`. Leaving the group on anything else double-counts the
+ // route and the completion fires while points are still arriving.
+ let q = HKWorkoutRouteQuery(route: route) { _, locations, done, _ in
+ if let locations = locations {
+ queue.async { all.append(contentsOf: locations) }
+ }
+ if done { queue.async { group.leave() } }
+ }
+ store.execute(q)
+ }
+ group.notify(queue: queue) {
+ let sorted = all.sorted { $0.timestamp < $1.timestamp }
+ var out: [[Any]] = []
+ var lastKept: Date?
+ for loc in sorted {
+ // A negative horizontal accuracy means the fix is invalid — Apple's
+ // own sentinel. Drawing it puts a spike through the middle of the map.
+ guard loc.horizontalAccuracy >= 0 else { continue }
+ if let last = lastKept,
+ loc.timestamp.timeIntervalSince(last) < minPointInterval
+ {
+ continue
+ }
+ lastKept = loc.timestamp
+ out.append([
+ loc.coordinate.latitude,
+ loc.coordinate.longitude,
+ loc.altitude,
+ Int(loc.timestamp.timeIntervalSince1970 * 1000),
+ ])
+ }
+ completion(out)
+ }
+ }
+ store.execute(routeQuery)
+ }
+}
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 45d903a2..338d3723 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -71,8 +71,21 @@
LSSupportsOpeningDocumentsInPlace
+
NSHealthShareUsageDescription
- OpenStrap reads your step count from Apple Health to show your daily steps, and reads back its own recent samples so it never writes duplicates.
+ OpenStrap reads your height, weight, date of birth and sex to set up your profile; your resting heart rate so it has a starting baseline; any blood pressure, blood glucose or body temperature readings, so a device that is cleared to measure those can sit beside your own data with that device's name on it — never blended into an OpenStrap number; and your workouts and their routes, so a run recorded by another app can be listed and drawn on a map here, named with the app that recorded it and never counted as one OpenStrap measured. It also reads back its own recent samples so it never writes duplicates.
+
+ NSMotionUsageDescription
+ OpenStrap counts your daily steps from this iPhone's own motion chip, because a band on your wrist cannot tell walking from stirring a pot, and the phone in your pocket can. It reads step counts only — never your location or anything else the motion chip records — and only the last few days of them. Your steps stay on this device and are never uploaded.
+ NSCameraUsageDescription
+ OpenStrap uses the camera to read the barcode on a food packet, so logging a meal does not mean typing its nutrition panel out by hand. It reads the barcode digits and nothing else — no photo is taken, kept, or sent anywhere.
NSHealthUpdateUsageDescription
OpenStrap writes your sleep, resting heart rate, HRV, respiratory rate, energy and workouts into Apple Health.
NSBluetoothPeripheralUsageDescription
diff --git a/ios/WatchBridge.swift b/ios/WatchBridge.swift
index e298d771..5e0bd21b 100644
--- a/ios/WatchBridge.swift
+++ b/ios/WatchBridge.swift
@@ -1,11 +1,11 @@
// WatchBridge — phone → Apple Watch data ferry (WatchConnectivity).
//
// iPhone and Apple Watch do NOT share an App Group container (they're separate
-// devices), so the watch cannot read `group.wtf.openstrap` directly. This bridge
+// devices), so the watch cannot read the phone's App Group directly. This bridge
// takes the exact snapshot the app already writes for the home-screen widget
// (WidgetService → home_widget → App Group UserDefaults) and pushes it to the
-// watch over WCSession. The watch caches it locally and its glance + complications
-// render from that. One source of truth: we never recompute here, we mirror.
+// watch over WCSession. The watch caches it locally and its glance renders from
+// that (there are no complications — see WatchStore.swift). One source of truth: we never recompute here, we mirror.
//
// Trigger: Dart calls the `syncWatch` method on the existing `openstrap/ios_config`
// channel right after it updates the widget (see WidgetService). We also push on
@@ -20,16 +20,18 @@ final class WatchBridge: NSObject, WCSessionDelegate {
// The keys mirrored from WidgetService.push()/pushBattery(). Kept in lockstep
// with lib/widget/widget_service.dart and OpenStrapWidget.swift.
private static let intKeys = [
- "readiness", "hrv", "hrv_baseline", "sleep_min", "sleep_need_min", "rhr",
- "updated_at", "batt_pct", "batt_at",
+ "readiness", "readiness_tier", "hrv", "hrv_baseline", "sleep_min",
+ "sleep_need_min", "rhr", "updated_at", "batt_pct", "batt_at",
]
private static let doubleKeys = ["strain"]
- private static let stringKeys = ["coach_line", "stress_band", "batt_name"]
+ private static let stringKeys = ["readiness_band", "coach_line", "batt_name"]
private static let boolKeys = ["has_data", "batt_charging", "theme_dark"]
private var appGroupId: String {
Bundle.main.object(forInfoDictionaryKey: "OpenStrapAppGroupIdentifier") as? String
- ?? "group.wtf.openstrap"
+ // Same fallback as AppGroup.swift and WidgetService.fallbackAppGroupId —
+ // see the note there.
+ ?? "group.com.example.openstrap"
}
/// Activate the WCSession. Safe to call once at launch; no-op if unsupported
@@ -53,9 +55,12 @@ final class WatchBridge: NSObject, WCSessionDelegate {
}
/// Push the latest snapshot to the watch. `updateApplicationContext` delivers a
- /// single coalesced latest-state in the background (perfect for "today's numbers");
- /// the complication transfer keeps the watch face reasonably fresh within its
- /// daily budget. Both are best-effort.
+ /// single coalesced latest-state in the background — exactly right for "today's
+ /// numbers". Best-effort.
+ ///
+ /// No complication transfer: the watch ships no complications (their sources
+ /// were never in the Xcode project), so `transferCurrentComplicationUserInfo`
+ /// was spending a scarce daily budget on a face that cannot show it.
func pushCurrentState() {
guard WCSession.isSupported() else { return }
let s = WCSession.default
@@ -63,11 +68,6 @@ final class WatchBridge: NSObject, WCSessionDelegate {
let payload = snapshot()
guard !payload.isEmpty else { return }
do { try s.updateApplicationContext(payload) } catch { /* transient — next push retries */ }
- #if os(iOS)
- if s.isComplicationEnabled {
- s.transferCurrentComplicationUserInfo(payload)
- }
- #endif
}
// MARK: - WCSessionDelegate
diff --git a/lib/ai/ai_prefs.dart b/lib/ai/ai_prefs.dart
index 61f5ccb3..e840146a 100644
--- a/lib/ai/ai_prefs.dart
+++ b/lib/ai/ai_prefs.dart
@@ -14,6 +14,10 @@ class AiPrefs {
final bool journalEnabled;
/// Fire times as minutes-from-midnight (local wall clock).
+ ///
+ /// [eveningMin] is a FALLBACK, not the time: the nightly sweep fires relative
+ /// to the user's own bedtime when the sleep coach has learned one — see
+ /// [resolvedEveningMin]. A fixed 20:00 is somebody else's evening.
final int morningMin;
final int eveningMin;
@@ -80,6 +84,24 @@ class AiPrefs {
journalMin: journalMin ?? this.journalMin,
);
+ /// An hour before bed. Long enough that "get to bed by 22:45" is still
+ /// actionable tonight; late enough that the day it sweeps is over.
+ static const int eveningBedtimeLeadMin = 60;
+
+ /// Resolved nightly-sweep time (minutes-from-midnight): an hour before the
+ /// sleep coach's recommended bedtime when it has one, else [eveningMin].
+ ///
+ /// The coach needs a couple of free-day nights before it recommends anything,
+ /// so a new install genuinely has no bedtime and gets the fallback — that is
+ /// the case the fallback exists for, not a fixed default with a bedtime
+ /// override bolted on.
+ int resolvedEveningMin({double? bedtimeMinOfDay}) {
+ if (bedtimeMinOfDay != null && bedtimeMinOfDay >= 0) {
+ return (bedtimeMinOfDay.round() - eveningBedtimeLeadMin) % 1440;
+ }
+ return eveningMin % 1440;
+ }
+
/// Resolved journal-prompt time (minutes-from-midnight): explicit user time,
/// else ~30 min before the recommended bedtime, else the 22:30 fallback.
int resolvedJournalMin({double? bedtimeMinOfDay}) {
diff --git a/lib/ai/briefing.dart b/lib/ai/briefing.dart
index be194ecd..332fb5da 100644
--- a/lib/ai/briefing.dart
+++ b/lib/ai/briefing.dart
@@ -1,7 +1,8 @@
// briefing.dart — the daily AI briefing value type + its on-device cache.
//
-// A Briefing is one generated summary for one local day + period (morning =
-// last night's sleep/recovery; evening = today's strain/activity). It carries
+// A Briefing is one generated note for one local day + period (morning = last
+// night's sleep/recovery; evening = the nightly sweep, which is findings about
+// today or nothing at all — see nightly_sweep.dart). It carries
// BOTH the notification-length one-liner and the short structured breakdown,
// plus the exact inputs snapshot it was generated from (so the breakdown screen
// can show "based on" metrics without re-querying, and regeneration is honest
@@ -22,7 +23,7 @@ enum BriefingPeriod { morning, evening }
extension BriefingPeriodLabel on BriefingPeriod {
String get id => this == BriefingPeriod.morning ? 'morning' : 'evening';
String get title =>
- this == BriefingPeriod.morning ? 'Morning briefing' : 'Evening recap';
+ this == BriefingPeriod.morning ? 'Morning briefing' : 'Nightly sweep';
}
/// Which period the Today card should surface right now. Mornings through the
@@ -57,6 +58,16 @@ class Briefing {
required this.inputs,
});
+ /// Whether producing this note involved a model at all.
+ ///
+ /// The nightly sweep with no findings is written on-device and asks nobody:
+ /// no request, no payload, nothing to disclose. THE one place that rule is
+ /// stated — the "what was sent" screen reads it rather than re-deriving it,
+ /// because a screen that guesses wrong about this is the worst bug this app
+ /// can ship.
+ bool get calledModel =>
+ period != BriefingPeriod.evening || inputs.isNotEmpty;
+
Map toJson() => {
'day': day,
'period': period.id,
diff --git a/lib/ai/briefing_engine.dart b/lib/ai/briefing_engine.dart
index 3c5a63ca..823edb87 100644
--- a/lib/ai/briefing_engine.dart
+++ b/lib/ai/briefing_engine.dart
@@ -17,6 +17,7 @@ import '../coach/coach_engine.dart';
import '../data/day_label.dart';
import '../data/local_repository.dart';
import 'briefing.dart';
+import 'nightly_sweep.dart';
/// Injectable one-shot completion (tests pass a fake; production defaults to
/// [CoachEngine.completeText] — the shared BYOK plumbing).
@@ -97,41 +98,111 @@ Future