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
1 change: 1 addition & 0 deletions Strand/Data/Profile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
256 changes: 256 additions & 0 deletions Strand/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
48 changes: 48 additions & 0 deletions Strand/Screens/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions android/app/src/main/java/com/noop/ui/SettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions android/app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1991,4 +1991,7 @@
<string name="skin_temp_delta_title">Hauttemperatur Δ</string>
<string name="skin_temp_vs_baseline">ggü. Basiswert</string>
<string name="l10n_app_changelog_make_noop_yours_theme_colours_and_6bdef2a7">Mach NOOP zu deinem — Akzentfarben und eigene Hintergründe, vierzig weitere Sportarten mit GPS-Strecken und eine Kalorien-Heatmap</string>
<string name="l10n_settings_screen_custom_hr_zones_84736ca5">Eigene HF-Zonen</string>
<string name="l10n_settings_screen_custom_hr_zones_hint_f7fa3bae">Lege die Herzfrequenz fest, bei der jede Zone beginnt. Ausschalten, um die Standardzonen in Prozent der maximalen Herzfrequenz wiederherzustellen.</string>
<string name="l10n_settings_screen_zone_starts_a0a60d45">Beginn Zone %1$d</string>
</resources>
3 changes: 3 additions & 0 deletions android/app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1976,4 +1976,7 @@
<string name="skin_temp_delta_title">Temp. de piel Δ</string>
<string name="skin_temp_vs_baseline">vs. referencia</string>
<string name="l10n_app_changelog_make_noop_yours_theme_colours_and_6bdef2a7">Haz NOOP tuyo: colores de acento y fondos personalizados, cuarenta deportes más con rutas GPS y un mapa de calor de calorías</string>
<string name="l10n_settings_screen_custom_hr_zones_84736ca5">Zonas de FC personalizadas</string>
<string name="l10n_settings_screen_custom_hr_zones_hint_f7fa3bae">Define las pulsaciones en las que empieza cada zona. Desactiva para restaurar las zonas predeterminadas por porcentaje de la frecuencia máxima.</string>
<string name="l10n_settings_screen_zone_starts_a0a60d45">Inicio de la zona %1$d</string>
</resources>
Loading
Loading