diff --git a/Strand/Data/Profile.swift b/Strand/Data/Profile.swift
index 029ea4e297..1cda29f995 100644
--- a/Strand/Data/Profile.swift
+++ b/Strand/Data/Profile.swift
@@ -209,6 +209,7 @@ final class ProfileStore: ObservableObject {
var next = hrZoneThresholds
let floor = index == 0 ? HRZones.customBPMRange.lowerBound : next[index - 1] + 1
let ceiling = index == next.count - 1 ? HRZones.customBPMRange.upperBound : next[index + 1] - 1
+ guard floor <= ceiling else { return } // no room between neighbours -> no-op (parity w/ Kotlin)
next[index] = min(max(next[index] + (up ? 1 : -1), floor), ceiling)
hrZoneThresholds = next
}
diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings
index 0d5c909a8e..e1d119a59b 100644
--- a/Strand/Resources/Localizable.xcstrings
+++ b/Strand/Resources/Localizable.xcstrings
@@ -199230,6 +199230,262 @@
}
}
}
+ },
+ "Custom HR zones": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Eigene HF-Zonen"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Custom HR zones"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Zonas de FC personalizadas"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Zones FC personnalisées"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Zone FC personalizzate"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Własne strefy HR"
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Zonas de RH personalizadas"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Свои зоны пульса"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "自定义心率区间"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "自訂心率區間"
+ }
+ }
+ }
+ },
+ "Set the BPM where each zone begins. Turn off to restore the default percentage-of-max zones.": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Lege die Herzfrequenz fest, bei der jede Zone beginnt. Ausschalten, um die Standardzonen in Prozent der maximalen Herzfrequenz wiederherzustellen."
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Set the BPM where each zone begins. Turn off to restore the default percentage-of-max zones."
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Define las pulsaciones en las que empieza cada zona. Desactiva para restaurar las zonas predeterminadas por porcentaje de la frecuencia máxima."
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Définis la fréquence cardiaque à laquelle chaque zone commence. Désactive pour rétablir les zones par défaut en pourcentage de la fréquence maximale."
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Imposta la frequenza cardiaca a cui inizia ogni zona. Disattiva per ripristinare le zone predefinite in percentuale della frequenza massima."
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Ustaw tętno, przy którym zaczyna się każda strefa. Wyłącz, aby przywrócić domyślne strefy oparte na procencie tętna maksymalnego."
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Define os batimentos em que cada zona começa. Desativa para repor as zonas predefinidas por percentagem da frequência máxima."
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Задайте пульс, с которого начинается каждая зона. Выключите, чтобы вернуть стандартные зоны в процентах от максимального пульса."
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "设置每个区间开始时的心率。关闭可恢复按最大心率百分比划分的默认区间。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "設定每個區間開始時的心率。關閉可恢復按最大心率百分比劃分的預設區間。"
+ }
+ }
+ }
+ },
+ "Zone %lld starts": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Beginn Zone %lld"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Zone %lld starts"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Inicio de la zona %lld"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Début de la zone %lld"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Inizio della zona %lld"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Początek strefy %lld"
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Início da zona %lld"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Начало зоны %lld"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "区间 %lld 起点"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "區間 %lld 起點"
+ }
+ }
+ }
+ },
+ "Zone %lld starts at %lld beats per minute": {
+ "localizations": {
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Zone %1$lld beginnt bei %2$lld Schlägen pro Minute"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Zone %lld starts at %lld beats per minute"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "La zona %1$lld empieza en %2$lld pulsaciones por minuto"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "La zone %1$lld débute à %2$lld battements par minute"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "La zona %1$lld inizia a %2$lld battiti al minuto"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Strefa %1$lld zaczyna się przy %2$lld uderzeniach na minutę"
+ }
+ },
+ "pt-PT": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "A zona %1$lld começa aos %2$lld batimentos por minuto"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Зона %1$lld начинается при %2$lld ударах в минуту"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "区间 %1$lld 从每分钟 %2$lld 次开始"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "區間 %1$lld 從每分鐘 %2$lld 次開始"
+ }
+ }
+ }
}
},
"version": "1.0"
diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift
index e206257186..48c6e8a171 100644
--- a/Strand/Screens/SettingsView.swift
+++ b/Strand/Screens/SettingsView.swift
@@ -482,6 +482,29 @@ struct SettingsView: View {
}
}
rowDivider
+ // Custom HR zones (#531, @kavemang): replace the conventional %HRmax bands with five
+ // personalized inclusive BPM lower bounds. Off = the effective set stays conventional.
+ FormRow(label: "Custom HR zones") {
+ Toggle("Custom HR zones", isOn: Binding(
+ get: { profile.hasCustomHRZones },
+ set: { profile.setCustomHRZonesEnabled($0) }
+ ))
+ .labelsHidden()
+ .accessibilityLabel("Custom HR zones")
+ }
+ if profile.hasCustomHRZones {
+ Text("Set the BPM where each zone begins. Turn off to restore the default percentage-of-max zones.")
+ .font(StrandFont.footnote)
+ .foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
+ ForEach(profile.hrZoneThresholds.indices, id: \.self) { index in
+ rowDivider
+ FormRow(label: "Zone \(index + 1) starts") {
+ hrZoneThresholdField(index: index)
+ }
+ }
+ }
+ rowDivider
// Step calibration (#139/#132): daily steps = @57 counter ticks ÷ this divisor.
// 1.0 = raw pass-through until the true 5/MG tick rate is known. The divisor goes
// up to 30 because a 5/MG motion counter can overcount by ~24×; the stepper uses a
@@ -879,6 +902,31 @@ struct SettingsView: View {
.fixedSize()
}
+ /// One personalized zone lower bound (bpm), stepped neighbour-aware (see `Profile.stepHRZoneThreshold`)
+ /// so the five bounds stay strictly increasing. Mirrors `hrMaxField`'s compact value + stepper layout.
+ private func hrZoneThresholdField(index: Int) -> some View {
+ let value = profile.hrZoneThresholds.indices.contains(index) ? profile.hrZoneThresholds[index] : 0
+ return HStack(spacing: NoopMetrics.space2) {
+ HStack(alignment: .firstTextBaseline, spacing: NoopMetrics.space1) {
+ Text("\(value)")
+ .font(StrandFont.bodyNumber)
+ .foregroundStyle(StrandPalette.textPrimary)
+ .frame(width: NoopMetrics.formValueColumnWidth, alignment: .center)
+ Text("bpm")
+ .font(StrandFont.caption)
+ .foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize()
+ }
+ .fixedSize()
+ Stepper("",
+ onIncrement: { profile.stepHRZoneThreshold(at: index, up: true) },
+ onDecrement: { profile.stepHRZoneThreshold(at: index, up: false) })
+ .labelsHidden()
+ .accessibilityLabel("Zone \(index + 1) starts at \(value) beats per minute")
+ }
+ .fixedSize()
+ }
+
// MARK: - Units
/// Imperial/Metric display toggle + a separate temperature override. Display-only — nothing stored
diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt
index b39015d70d..1964f32171 100644
--- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt
@@ -321,6 +321,7 @@ class ProfileStore(private val prefs: SharedPreferences) {
if (index !in current.indices) return
val floor = if (index == 0) HrZones.customBPMRange.first else current[index - 1] + 1
val ceiling = if (index == current.lastIndex) HrZones.customBPMRange.last else current[index + 1] - 1
+ if (floor > ceiling) return // no room between neighbours -> no-op (coerceIn throws on empty range)
current[index] = (current[index] + if (up) 1 else -1).coerceIn(floor, ceiling)
hrZoneThresholds = current
}
@@ -1015,6 +1016,42 @@ fun SettingsScreen(
}
}
SettingsRowDivider()
+ // Custom HR zones (#531, @kavemang): five personalized inclusive BPM lower bounds that
+ // replace the conventional %HRmax bands. Off -> the effective set stays conventional.
+ SettingsFormRow(label = uiString(R.string.l10n_settings_screen_custom_hr_zones_84736ca5)) {
+ Switch(
+ checked = profile.hasCustomHrZones,
+ onCheckedChange = { mutate { profile.setCustomHrZonesEnabled(it) } },
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = Palette.surfaceBase,
+ checkedTrackColor = Palette.accent,
+ uncheckedThumbColor = Palette.textSecondary,
+ uncheckedTrackColor = Palette.surfaceInset,
+ uncheckedBorderColor = Palette.hairline,
+ ),
+ )
+ }
+ if (profile.hasCustomHrZones) {
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = uiString(R.string.l10n_settings_screen_custom_hr_zones_hint_f7fa3bae),
+ style = NoopType.footnote,
+ color = Palette.textTertiary,
+ )
+ profile.hrZoneThresholds?.forEachIndexed { index, value ->
+ SettingsRowDivider()
+ SettingsFormRow(label = uiString(R.string.l10n_settings_screen_zone_starts_a0a60d45, index + 1)) {
+ StepperField(
+ value = value.toString(),
+ unit = "bpm",
+ accessibility = "Zone ${index + 1} starts at $value bpm",
+ onMinus = { mutate { profile.stepHrZoneThreshold(index, up = false) } },
+ onPlus = { mutate { profile.stepHrZoneThreshold(index, up = true) } },
+ )
+ }
+ }
+ }
+ SettingsRowDivider()
// Step calibration (#139/#132): daily steps = @57 counter ticks ÷ this divisor.
// 1.0 = raw pass-through until the true 5/MG tick rate is known. The divisor goes
// up to 30 because a 5/MG motion counter can overcount by ~24×; the stepper uses a
diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml
index a92fc06600..918c872d08 100644
--- a/android/app/src/main/res/values-de/strings.xml
+++ b/android/app/src/main/res/values-de/strings.xml
@@ -1991,4 +1991,7 @@
Hauttemperatur Δ
ggü. Basiswert
Mach NOOP zu deinem — Akzentfarben und eigene Hintergründe, vierzig weitere Sportarten mit GPS-Strecken und eine Kalorien-Heatmap
+ Eigene HF-Zonen
+ Lege die Herzfrequenz fest, bei der jede Zone beginnt. Ausschalten, um die Standardzonen in Prozent der maximalen Herzfrequenz wiederherzustellen.
+ Beginn Zone %1$d
diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml
index e1c150b587..02c729f475 100644
--- a/android/app/src/main/res/values-es/strings.xml
+++ b/android/app/src/main/res/values-es/strings.xml
@@ -1976,4 +1976,7 @@
Temp. de piel Δ
vs. referencia
Haz NOOP tuyo: colores de acento y fondos personalizados, cuarenta deportes más con rutas GPS y un mapa de calor de calorías
+ Zonas de FC personalizadas
+ Define las pulsaciones en las que empieza cada zona. Desactiva para restaurar las zonas predeterminadas por porcentaje de la frecuencia máxima.
+ Inicio de la zona %1$d
diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml
index 751429218f..2333eae146 100644
--- a/android/app/src/main/res/values-fr/strings.xml
+++ b/android/app/src/main/res/values-fr/strings.xml
@@ -1976,4 +1976,7 @@
Temp. cutanée Δ
vs référence
NOOP à votre image — couleurs d\'accent et fonds personnalisés, quarante sports de plus avec tracés GPS, et une carte de chaleur des calories
+ Zones FC personnalisées
+ Définis la fréquence cardiaque à laquelle chaque zone commence. Désactive pour rétablir les zones par défaut en pourcentage de la fréquence maximale.
+ Début de la zone %1$d
diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml
index ece040ab0e..6688216739 100644
--- a/android/app/src/main/res/values-pl/strings.xml
+++ b/android/app/src/main/res/values-pl/strings.xml
@@ -1970,4 +1970,7 @@
Fazy
Fazy na tle typowych wartości
Dostosuj NOOP do siebie — kolory motywu i własne tła, czterdzieści kolejnych dyscyplin z trasami GPS oraz mapa cieplna kalorii
+ Własne strefy HR
+ Ustaw tętno, przy którym zaczyna się każda strefa. Wyłącz, aby przywrócić domyślne strefy oparte na procencie tętna maksymalnego.
+ Początek strefy %1$d
diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml
index 4019708573..987b09bbee 100644
--- a/android/app/src/main/res/values-pt-rPT/strings.xml
+++ b/android/app/src/main/res/values-pt-rPT/strings.xml
@@ -1970,4 +1970,7 @@
Temperatura da pele Δ
vs referência
Torna o NOOP teu — cores de destaque e fundos personalizados, mais quarenta desportos com percursos GPS e um mapa de calor de calorias
+ Zonas de RH personalizadas
+ Define os batimentos em que cada zona começa. Desativa para repor as zonas predefinidas por percentagem da frequência máxima.
+ Início da zona %1$d
diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml
index 7cfa232b87..ecb82a85d9 100644
--- a/android/app/src/main/res/values-zh/strings.xml
+++ b/android/app/src/main/res/values-zh/strings.xml
@@ -1903,4 +1903,7 @@
皮肤体温 Δ
相对基线
把 NOOP 变成你的——强调色与自定义背景、新增四十多项运动及 GPS 轨迹,还有卡路里热力图
+ 自定义心率区间
+ 设置每个区间开始时的心率。关闭可恢复按最大心率百分比划分的默认区间。
+ 区间 %1$d 起点
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 49b15a1b9c..4ab4a1312c 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -2003,4 +2003,7 @@
Skin Temp Δ
vs baseline
Make NOOP yours — theme colours and custom backgrounds, forty more sports with GPS routes, and a calorie heatmap
+ Custom HR zones
+ Set the BPM where each zone begins. Turn off to restore the default percentage-of-max zones.
+ Zone %1$d starts