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