Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Strand/App/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ enum NavItem: String, CaseIterable, Identifiable, Hashable {
case notifications = "Notifications"
case automation = "Automations"
case smartAlarm = "Smart Alarm"
case powerSaving = "Power saving"
case settings = "Settings"
case testCentre = "Test Centre"

Expand Down Expand Up @@ -68,6 +69,7 @@ enum NavItem: String, CaseIterable, Identifiable, Hashable {
// Automations) and the evening wind-down reminder, in one place. Previously "Wind-Down" (#730).
// The case name and rawValue stay `smartAlarm`/"Smart Alarm" as the in-memory nav identifier only.
case .smartAlarm: return "Alarms"
case .powerSaving: return "Power saving"
case .settings: return "Settings"
case .testCentre: return "Test Centre"
}
Expand Down Expand Up @@ -109,6 +111,7 @@ enum NavItem: String, CaseIterable, Identifiable, Hashable {
case .automation: return String(localized: "Automations")
// Mirrors the `titleKey` remap above (#766): the row reads "Alarms", not the raw "Smart Alarm".
case .smartAlarm: return String(localized: "Alarms")
case .powerSaving: return String(localized: "Power saving")
case .settings: return String(localized: "Settings")
case .testCentre: return String(localized: "Test Centre")
}
Expand Down Expand Up @@ -143,6 +146,7 @@ enum NavItem: String, CaseIterable, Identifiable, Hashable {
case .notifications: return "bell.badge.fill"
case .automation: return "wand.and.stars"
case .smartAlarm: return "alarm.fill"
case .powerSaving: return "battery.25"
case .settings: return "gearshape.fill"
case .testCentre: return "stethoscope"
}
Expand Down Expand Up @@ -178,7 +182,7 @@ struct NavGroup: Identifiable {
]),
NavGroup(title: "Data & App", id: "data_app", items: [
.devices, .noopLimitations, .dataSources, .appleHealth, .xiaomi, .backupSync, .fusedRecord,
.notifications, .automation, .smartAlarm, .settings, .testCentre,
.notifications, .automation, .smartAlarm, .powerSaving, .settings, .testCentre,
]),
]

Expand Down Expand Up @@ -442,6 +446,7 @@ struct RootView: View {
case .notifications: NotificationSettingsView()
case .automation: AutomationsView()
case .smartAlarm: SmartAlarmView()
case .powerSaving: PowerSavingView()
case .settings: settingsDetail
case .testCentre: TestCentreView()
}
Expand Down
102 changes: 102 additions & 0 deletions Strand/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -207218,6 +207218,108 @@
}
}
}
},
"Power saving": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Energiesparen"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Ahorro de energía"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Économie d’énergie"
}
},
"pt-PT": {
"stringUnit": {
"state": "translated",
"value": "Poupança de energia"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Oszczędzanie energii"
}
}
}
},
"Ease the load on your strap when its battery is running low.": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Entlaste dein Band, wenn sein Akku zur Neige geht."
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Reduce la carga de tu banda cuando le queda poca batería."
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Allégez la charge de votre bracelet quand sa batterie faiblit."
}
},
"pt-PT": {
"stringUnit": {
"state": "translated",
"value": "Alivia a carga da tua pulseira quando a bateria está a acabar."
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Odciąż opaskę, gdy jej bateria się kończy."
}
}
}
},
"The strap keeps banking data on its own, so nothing is lost — NOOP just talks to it less often to help it last until you can charge it.": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Das Band speichert seine Daten weiterhin selbst, es geht also nichts verloren — NOOP spricht nur seltener mit ihm, damit es bis zum Laden durchhält."
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "La banda sigue guardando los datos por su cuenta, así que no se pierde nada: NOOP simplemente habla con ella menos a menudo para que aguante hasta que puedas cargarla."
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Le bracelet continue d’enregistrer ses données de son côté, donc rien n’est perdu — NOOP lui parle simplement moins souvent pour qu’il tienne jusqu’à la recharge."
}
},
"pt-PT": {
"stringUnit": {
"state": "translated",
"value": "A pulseira continua a guardar os dados sozinha, por isso nada se perde — o NOOP apenas fala com ela menos vezes para durar até a poderes carregar."
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Opaska nadal zapisuje dane samodzielnie, więc nic nie ginie — NOOP po prostu rzadziej się z nią komunikuje, aby wystarczyła do naładowania."
}
}
}
}
},
"version": "1.0"
Expand Down
112 changes: 112 additions & 0 deletions Strand/Screens/PowerSavingView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import SwiftUI
import StrandDesign

// MARK: - Power saving (#477)
//
// Lifted out of Settings into its own screen: on iPhone it is a first-class More row (between Test
// Centre and Settings) and on macOS its own sidebar item, so the strap-battery levers are one tap away
// instead of buried in the middle of the Settings scroll. The controls, their prefs and their wiring are
// UNCHANGED — `AppModel.applyPowerSaving()` still reads every value from `PuffinExperiment`, so moving
// the surface cannot alter behaviour.
//
// The master gates the sub-options: the threshold slider, "Pause HRV capture" and "Low refresh" only
// appear (and only apply) while Power saving is on — `applyPowerSaving` ANDs each one with the master.
struct PowerSavingView: View {
@EnvironmentObject var model: AppModel

@AppStorage(PuffinExperiment.powerSavingKey) private var powerSavingEnabled = false
@AppStorage(PuffinExperiment.powerSavingBatteryPctKey) private var powerSavingPct = 20
/// Stored INVERTED so the default (absent = false) reads as "HRV pause on". The toggle shows `!this`.
@AppStorage(PuffinExperiment.pauseHrvDisabledKey) private var pauseHrvDisabled = false
@AppStorage(PuffinExperiment.lowRefreshKey) private var lowRefreshEnabled = false

var body: some View {
ScreenScaffold(title: "Power saving",
subtitle: "Ease the load on your strap when its battery is running low.") {
VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) {
NoopCard {
VStack(alignment: .leading, spacing: 16) {
Text("Power saving").strandOverline()
Text("The strap keeps banking data on its own, so nothing is lost — NOOP just talks to it less often to help it last until you can charge it.")
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)

rowDivider
Toggle(isOn: $powerSavingEnabled) {
Text("Power saving mode")
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.textPrimary)
}
.toggleStyle(.switch)
.tint(StrandPalette.accent)
.onChangeCompat(of: powerSavingEnabled) { _ in model.applyPowerSaving() }
Text("Slows background strap-sync (every 45 min instead of 15) while your strap's battery is low. No data loss — the strap banks everything, so sync just batches into larger, less frequent pulls.")
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)

if powerSavingEnabled {
rowDivider
HStack {
Text("Kick in at (strap battery)")
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.textPrimary)
Spacer()
Text(verbatim: "\(powerSavingPct)%")
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.accent)
}
// 10…35 in 5s. 35 buys one more step of strap life than the old 30 ceiling:
// the levers engage ~5% earlier in the discharge, at the cost of a slightly
// longer stretch of quieter syncing.
Slider(
value: Binding(get: { Double(powerSavingPct) }, set: { powerSavingPct = Int($0) }),
in: 10...35, step: 5,
onEditingChanged: { editing in if !editing { model.applyPowerSaving() } }
)
.tint(StrandPalette.accent)

rowDivider
// HRV pause: a sub-option, ON by default when the master is on (stored inverted).
Toggle(isOn: Binding(get: { !pauseHrvDisabled }, set: { pauseHrvDisabled = !$0 })) {
Text("Pause HRV capture")
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.textPrimary)
}
.toggleStyle(.switch)
.tint(StrandPalette.accent)
.onChangeCompat(of: pauseHrvDisabled) { _ in model.applyPowerSaving() }
Text("While your strap's battery is low, stop the always-on background HRV stream — the biggest continuous drain on the strap. A Live screen still shows heart rate, and it re-arms automatically once the strap is charged.")
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)

rowDivider
// Low refresh: a sub-option that applies at ANY charge, not just below the threshold.
Toggle(isOn: $lowRefreshEnabled) {
Text("Low refresh")
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.textPrimary)
}
.toggleStyle(.switch)
.tint(StrandPalette.accent)
.onChangeCompat(of: lowRefreshEnabled) { _ in model.applyPowerSaving() }
Text("Sync in the background every hour instead of every 15 minutes, whatever the strap's charge — fewer reconnections is the biggest saving on a WHOOP 4.0. Nothing is lost: the strap banks everything and hands it over in larger batches. Pull to sync still runs straight away, and live heart rate is untouched.")
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
}
}

private var rowDivider: some View {
Rectangle()
.fill(StrandPalette.hairline)
.frame(height: 1)
.padding(.vertical, 4)
}
}
86 changes: 5 additions & 81 deletions Strand/Screens/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,9 @@ struct SettingsView: View {
/// false and get the 24/7 behaviour they were trying to avoid.
@AppStorage(PuffinExperiment.continuousHrvOvernightOnlyKey) private var continuousHrvOvernightOnly = true

// #477 Power saving (parity with Android). Battery-adaptive sync cadence + an HRV-pause sub-option.
@AppStorage(PuffinExperiment.powerSavingKey) private var powerSavingEnabled = false
@AppStorage(PuffinExperiment.lowRefreshKey) private var lowRefreshEnabled = false
@AppStorage(PuffinExperiment.powerSavingBatteryPctKey) private var powerSavingPct = 20
/// Stored INVERTED so the default (absent = false) reads as "HRV pause on". The toggle shows `!this`.
@AppStorage(PuffinExperiment.pauseHrvDisabledKey) private var pauseHrvDisabled = false
// #477 Power saving moved OUT of this screen into `PowerSavingView` — a first-class More row on
// iPhone (between Test Centre and Settings) and its own sidebar item on macOS. Its `@AppStorage`
// keys live there now; nothing here reads them.

/// "Experimental sleep staging (V2)" (ON by default, promoted after the 44-subject cross-subject
/// benchmark). When on, detected nights are re-staged with `SleepStagerV2` (the transparent
Expand Down Expand Up @@ -315,9 +312,8 @@ struct SettingsView: View {
unitsCard.staggeredAppear(index: 1)
appearanceCard.staggeredAppear(index: 2)
strapCard.staggeredAppear(index: 3)
powerSavingCard.staggeredAppear(index: 4)
streakCard.staggeredAppear(index: 5)
featuresCard.staggeredAppear(index: 6)
streakCard.staggeredAppear(index: 4)
featuresCard.staggeredAppear(index: 5)

// Lower-frequency sections collapse behind a single default-closed disclosure so the
// screen opens at ~6 sections instead of 11. Nothing is removed; every section here
Expand Down Expand Up @@ -1394,78 +1390,6 @@ struct SettingsView: View {
}
}

// MARK: - Power saving (#477)
private var powerSavingCard: some View {
SettingsSection(
icon: "battery.25",
title: "Power saving",
blurb: "Ease the load on your strap when its battery is running low. The strap keeps banking data on its own, so nothing is lost — NOOP just talks to it less often to help it last until you can charge it."
) {
VStack(alignment: .leading, spacing: 16) {
Toggle(isOn: $powerSavingEnabled) {
Text("Power saving mode")
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.textPrimary)
}
.toggleStyle(.switch)
.tint(StrandPalette.accent)
.onChangeCompat(of: powerSavingEnabled) { _ in model.applyPowerSaving() }
Text("Slows background strap-sync (every 45 min instead of 15) while your strap's battery is low. No data loss — the strap banks everything, so sync just batches into larger, less frequent pulls.")
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)

if powerSavingEnabled {
rowDivider
HStack {
Text("Kick in at (strap battery)")
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.textPrimary)
Spacer()
Text(verbatim: "\(powerSavingPct)%")
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.accent)
}
Slider(
value: Binding(get: { Double(powerSavingPct) }, set: { powerSavingPct = Int($0) }),
in: 10...30, step: 5,
onEditingChanged: { editing in if !editing { model.applyPowerSaving() } }
)
.tint(StrandPalette.accent)

rowDivider
// HRV pause: a sub-option, ON by default when the master is on (stored inverted).
Toggle(isOn: Binding(get: { !pauseHrvDisabled }, set: { pauseHrvDisabled = !$0 })) {
Text("Pause HRV capture")
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.textPrimary)
}
.toggleStyle(.switch)
.tint(StrandPalette.accent)
.onChangeCompat(of: pauseHrvDisabled) { _ in model.applyPowerSaving() }
Text("While your strap's battery is low, stop the always-on background HRV stream — the biggest continuous drain on the strap. A Live screen still shows heart rate, and it re-arms automatically once the strap is charged.")
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)

rowDivider
// Low refresh: a sub-option that applies at ANY charge, not just below the threshold.
Toggle(isOn: $lowRefreshEnabled) {
Text("Low refresh")
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.textPrimary)
}
.toggleStyle(.switch)
.tint(StrandPalette.accent)
.onChangeCompat(of: lowRefreshEnabled) { _ in model.applyPowerSaving() }
Text("Sync in the background every hour instead of every 15 minutes, whatever the strap's charge — fewer reconnections is the biggest saving on a WHOOP 4.0. Nothing is lost: the strap banks everything and hands it over in larger batches. Pull to sync still runs straight away, and live heart rate is untouched.")
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}

/// Rename the WHOOP 4.0's BLE advertising name. Shows the current name (read back from firmware in
/// the connect handshake → `LiveState.advertisingName`) and writes a new one via `renameStrap`. The
Expand Down
Loading
Loading