From 002a12405b24a7237c98f4f6487907ca9cda4b24 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:54:16 +0200 Subject: [PATCH 1/3] settings: one switch each for what NOOP shows on the Lock Screen NOOP shows three things on the Lock Screen and in the Dynamic Island: the live heart rate, a Lift Log session and a strap sync. Two had switches, at the foot of the Strap card; the Lift Log banner had none and followed the live heart rate switch ("a user who turned Live Activities off meant all of them"), so turning off the everyday heart-rate banner also took away the gym banner and the Lock Screen light-up a strap step sends through it. The Lift Log banner now has its own switch (UnitPrefs.liftLiveActivityEnabled, default on), and the three sit together in a small "Lock Screen & Dynamic Island" section after Strap (iOS only). Each decides only what is shown: the heart rate is still measured, recorded and scored with its banner off. Same keys and strings for the two existing switches; four new strings in all ten languages. Co-Authored-By: Claude Opus 5.5 --- Strand/Data/Units.swift | 13 +- Strand/Resources/Localizable.xcstrings | 232 ++++++++++++++++++ Strand/Screens/SettingsView.swift | 91 ++++--- .../Widgets/LiftLiveActivityController.swift | 6 +- 4 files changed, 307 insertions(+), 35 deletions(-) diff --git a/Strand/Data/Units.swift b/Strand/Data/Units.swift index fb692b2eaa..80036ac0fa 100644 --- a/Strand/Data/Units.swift +++ b/Strand/Data/Units.swift @@ -116,8 +116,8 @@ enum UnitPrefs { } /// Whether the live-HR Live Activity (Lock Screen + Dynamic Island) may show, iOS only (#336). - /// Defaults to ON. The user can turn it off in Notifications settings without digging into iOS - /// Settings — `liveActivityEnabled()` reads it default-true so an unset key keeps the old behaviour. + /// Defaults to ON. The user can turn it off in Settings → Lock Screen & Dynamic Island without digging into + /// iOS Settings — `liveActivityEnabled()` reads it default-true so an unset key keeps the old behaviour. static let liveActivityKey = "liveActivity.enabled" static func liveActivityEnabled() -> Bool { UserDefaults.standard.object(forKey: liveActivityKey) == nil @@ -133,6 +133,15 @@ enum UnitPrefs { ? true : UserDefaults.standard.bool(forKey: syncLiveActivityKey) } + /// Whether the Lift Log session's Live Activity may show, iOS only. Its own switch: it used to follow the + /// live-HR one, so turning off the everyday heart-rate banner also took away the gym banner and the Lock + /// Screen light-up a strap step sends through it. Defaults to ON, read default-true like its siblings. + static let liftLiveActivityKey = "liveActivity.lift.enabled" + static func liftLiveActivityEnabled() -> Bool { + UserDefaults.standard.object(forKey: liftLiveActivityKey) == nil + ? true : UserDefaults.standard.bool(forKey: liftLiveActivityKey) + } + /// Resolve temperature, following body measurements when no explicit override is set. static func resolveTemperature(system: UnitSystem, override raw: String) -> TemperatureUnit { if let explicit = TemperatureUnit(rawValue: raw) { return explicit } diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index f48591a1fd..d67cc07798 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -283,6 +283,64 @@ } } }, + "Lift Log session in Dynamic Island": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Trainingsbuch-Einheit in der Dynamic Island" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sesión del Registro de fuerza en la Dynamic Island" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Séance du Carnet de force dans la Dynamic Island" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sessione del Diario dei pesi nella Dynamic Island" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Sessão do Registo de força na Dynamic Island" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тренировка «Дневника тренировок» в Dynamic Island" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在灵动岛中显示力量日志训练" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在動態島中顯示力量日誌訓練" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sesja Dziennika siłowni w Dynamic Island" + } + } + } + }, "Live heart rate is flowing from the ring.": { "localizations": { "de": { @@ -341,6 +399,64 @@ } } }, + "Lock Screen & Dynamic Island": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sperrbildschirm & Dynamic Island" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pantalla de bloqueo y Dynamic Island" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Écran verrouillé et Dynamic Island" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Schermata di blocco e Dynamic Island" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Ecrã de bloqueio e Dynamic Island" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Экран блокировки и Dynamic Island" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "锁定屏幕与灵动岛" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "鎖定畫面與動態島" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ekran blokady i Dynamic Island" + } + } + } + }, "NOOP is connecting to your strap and will sync as soon as it's ready.": { "localizations": { "de": { @@ -689,6 +805,64 @@ } } }, + "Shows the set you are on, your rest and your heart rate while a Lift Log session runs, and lights the Lock Screen when the strap moves the session on. Independent of the live heart rate switch.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigt während einer Trainingsbuch-Einheit deinen aktuellen Satz, deine Pause und deine Herzfrequenz und lässt den Sperrbildschirm aufleuchten, wenn der Strap die Einheit weiterschaltet. Unabhängig vom Schalter für die Live-Herzfrequenz." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Muestra la serie en la que estás, tu descanso y tu frecuencia cardíaca durante una sesión del Registro de fuerza, e ilumina la pantalla de bloqueo cuando la pulsera avanza la sesión. Independiente del interruptor de la frecuencia cardíaca en vivo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Affiche la série en cours, votre repos et votre fréquence cardiaque pendant une séance du Carnet de force, et allume l'écran verrouillé quand le bracelet fait avancer la séance. Indépendant de l'interrupteur de la fréquence cardiaque en direct." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra la serie in corso, il recupero e la frequenza cardiaca durante una sessione del Diario dei pesi, e accende la schermata di blocco quando la fascia fa avanzare la sessione. Indipendente dall'interruttore della frequenza cardiaca in diretta." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Mostra a série em que estás, o teu descanso e a tua frequência cardíaca durante uma sessão do Registo de força, e acende o ecrã de bloqueio quando a pulseira avança a sessão. Independente do interruptor da frequência cardíaca em direto." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывает текущий подход, отдых и пульс во время тренировки «Дневника тренировок» и включает экран блокировки, когда браслет переключает тренировку дальше. Не зависит от переключателя пульса в реальном времени." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在力量日志训练期间显示你当前的组、休息时间和心率,并在手环推进训练时点亮锁定屏幕。与实时心率开关互不影响。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在力量日誌訓練期間顯示你目前的組、休息時間和心率,並在手環推進訓練時點亮鎖定畫面。與即時心率開關互不影響。" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokazuje bieżącą serię, przerwę i tętno podczas sesji Dziennika siłowni oraz podświetla ekran blokady, gdy pasek przesuwa sesję dalej. Niezależne od przełącznika tętna na żywo." + } + } + } + }, "Strap sync in Dynamic Island": { "localizations": { "de": { @@ -1690,6 +1864,64 @@ "Working sets": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Arbeitssätze"}}, "es": {"stringUnit": {"state": "translated", "value": "Series efectivas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Séries de travail"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie di lavoro"}}, "pl": {"stringUnit": {"state": "translated", "value": "Serie robocze"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Séries de trabalho"}}, "ru": {"stringUnit": {"state": "translated", "value": "Рабочие подходы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "正式组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "正式組"}} } }, + "What NOOP shows there, each on its own switch.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Was NOOP dort anzeigt, jeweils mit eigenem Schalter." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Lo que NOOP muestra ahí, cada cosa con su propio interruptor." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ce que NOOP y affiche, chaque élément avec son propre interrupteur." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cosa mostra NOOP lì, ognuno con il proprio interruttore." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "O que o NOOP mostra aí, cada um com o seu próprio interruptor." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Что NOOP там показывает — у каждого свой переключатель." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "NOOP 在这里显示的内容,每项都有单独的开关。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "NOOP 在這裡顯示的內容,每項都有單獨的開關。" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Co NOOP tam pokazuje, każde z własnym przełącznikiem." + } + } + } + }, "Your strap is already syncing.": { "localizations": { "de": { diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index e0f0cdf31f..323e9b9450 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -120,6 +120,7 @@ struct SettingsView: View { @AppStorage(UnitPrefs.liveActivityKey) private var liveActivityEnabled = true // Strap-sync Live Activity, iOS only. Separate from the live-HR one on purpose. Default on. @AppStorage(UnitPrefs.syncLiveActivityKey) private var syncLiveActivityEnabled = true + @AppStorage(UnitPrefs.liftLiveActivityKey) private var liftLiveActivityEnabled = true @AppStorage(DayCycleMode.storageKey) private var dayCycleModeRaw = DayCycleMode.sleepOnset.rawValue // Alternate app icon (iOS only) — false = Titanium (primary AppIcon), true = Blue Titanium // ("AppIcon-Navy"). Display-only preference; the live switch goes through setAlternateIconName. @@ -263,6 +264,9 @@ struct SettingsView: View { unitsCard.staggeredAppear(index: 1) appearanceCard.staggeredAppear(index: 2) strapCard.staggeredAppear(index: 3) + #if os(iOS) + lockScreenCard.staggeredAppear(index: 3) + #endif streakCard.staggeredAppear(index: 4) featuresCard.staggeredAppear(index: 5) #if os(iOS) @@ -1461,36 +1465,6 @@ struct SettingsView: View { strapNameControl } - #if os(iOS) - rowDivider - // MARK: Live Activity — show live HR on the Lock Screen + Dynamic Island (#336). - Toggle(isOn: $liveActivityEnabled) { - Text("Live heart rate in Dynamic Island") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - Text("Shows your live heart rate on the Lock Screen and in the Dynamic Island while the strap is connected. Turn it off to keep your live HR out of the Dynamic Island. (Any one already showing clears within a moment.)") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - - rowDivider - // MARK: Strap-sync Live Activity — its own switch, independent of the live-HR one. - Toggle(isOn: $syncLiveActivityEnabled) { - Text("Strap sync in Dynamic Island") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - .accessibilityHint("Shows sync progress on the Lock Screen and in the Dynamic Island") - Text("Shows Connecting… / Syncing… with the chunk count and elapsed time while NOOP pulls history from your strap, including a sync started by the Sync Strap shortcut. Independent of the live heart rate switch above.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - #endif } } } @@ -1583,6 +1557,63 @@ struct SettingsView: View { /// or an early reading that anchored too high). It writes now (epoch SECONDS) to BOTH the /// `noop.hrvBaselineEpoch` and `noop.recoveryBaselineEpoch` settings the recovery engine reads, then /// kicks a recompute the same way the sleep-edit path does (analyzeRecent → refresh). History stays. + #if os(iOS) + /// Everything NOOP shows on the Lock Screen and in the Dynamic Island, one switch each: the live heart rate, + /// the Lift Log session and the strap sync. They are independent — a person can keep the gym banner and drop + /// the everyday heart-rate one — and each only decides what is SHOWN: the heart rate is still measured, + /// recorded and scored with its banner off. + private var lockScreenCard: some View { + SettingsSection( + icon: "iphone", + title: "Lock Screen & Dynamic Island", + blurb: "What NOOP shows there, each on its own switch." + ) { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { + // MARK: Live Activity — show live HR on the Lock Screen + Dynamic Island (#336). + Toggle(isOn: $liveActivityEnabled) { + Text("Live heart rate in Dynamic Island") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + } + .toggleStyle(.switch) + .tint(StrandPalette.accent) + Text("Shows your live heart rate on the Lock Screen and in the Dynamic Island while the strap is connected. Turn it off to keep your live HR out of the Dynamic Island. (Any one already showing clears within a moment.)") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + rowDivider + // MARK: Lift Log session Live Activity — its own switch, so the gym banner can stay while the + // everyday heart-rate one is off. + Toggle(isOn: $liftLiveActivityEnabled) { + Text("Lift Log session in Dynamic Island") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + } + .toggleStyle(.switch) + .tint(StrandPalette.accent) + Text("Shows the set you are on, your rest and your heart rate while a Lift Log session runs, and lights the Lock Screen when the strap moves the session on. Independent of the live heart rate switch.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + rowDivider + // MARK: Strap-sync Live Activity — its own switch, independent of the live-HR one. + Toggle(isOn: $syncLiveActivityEnabled) { + Text("Strap sync in Dynamic Island") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + } + .toggleStyle(.switch) + .tint(StrandPalette.accent) + .accessibilityHint("Shows sync progress on the Lock Screen and in the Dynamic Island") + Text("Shows Connecting… / Syncing… with the chunk count and elapsed time while NOOP pulls history from your strap, including a sync started by the Sync Strap shortcut. Independent of the live heart rate switch above.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + #endif + private var recoveryCard: some View { SettingsSection( icon: "heart.text.square", diff --git a/StrandiOS/Widgets/LiftLiveActivityController.swift b/StrandiOS/Widgets/LiftLiveActivityController.swift index 1beefdb4c4..eaad2720d5 100644 --- a/StrandiOS/Widgets/LiftLiveActivityController.swift +++ b/StrandiOS/Widgets/LiftLiveActivityController.swift @@ -88,9 +88,9 @@ final class LiftLiveActivityController { ? Activity.activities.first(where: Self.isShowing) : nil if let adopted { activity = adopted } - // Shares the existing Live Activity opt-out rather than adding a second switch: a user who - // turned Live Activities off meant all of them. - guard UnitPrefs.liveActivityEnabled(), let state else { + // Its own switch (`UnitPrefs.liftLiveActivityEnabled`), so the everyday heart-rate banner can be off while + // the gym banner stays; turning this one off also ends a banner already showing. + guard UnitPrefs.liftLiveActivityEnabled(), let state else { if activity != nil { Task { await end() } } return alert ? .noBanner : nil } From 54e70c3c6697b7181fb70f17802dff6a769a70be Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:03:27 +0200 Subject: [PATCH 2/3] settings: call them live notifications, say it shortly, and let each switch touch only its own Tester feedback on the section: the switches said "in Dynamic Island" while each shows on the Lock Screen too, and the captions were long. The section is now "Live notifications" (Lock Screen and Dynamic Island, said once, with a line that a switch only hides one), and the rows are "Live heart rate", "Lift Log session" and "Strap sync", each with a one-line caption; the two titles reuse strings already translated. The nine replaced strings leave the catalog; six short ones arrive in all ten languages. These three are every Live Activity NOOP has (NOOPActivityAttributes, LiftActivityAttributes, SyncActivityAttributes); nothing else posts to the Dynamic Island. Each switch now affects only its own banner. The live heart rate banner stood aside for any Lift Log session and any sync, and a sync banner was held back for any session, even when the banner they made room for was switched off, which left the Lock Screen with nothing. They now make room only for a banner whose switch is on. Co-Authored-By: Claude Opus 5.5 --- Strand/Resources/Localizable.xcstrings | 460 ++++++------------ Strand/Screens/SettingsView.swift | 79 ++- StrandiOS/App/StrandiOSApp.swift | 16 +- .../Widgets/SyncLiveActivityController.swift | 2 +- 4 files changed, 189 insertions(+), 368 deletions(-) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index d67cc07798..a4e863c465 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -283,60 +283,60 @@ } } }, - "Lift Log session in Dynamic Island": { + "Lift Log session": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Trainingsbuch-Einheit in der Dynamic Island" + "value": "Trainingsbuch-Einheit" } }, "es": { "stringUnit": { "state": "translated", - "value": "Sesión del Registro de fuerza en la Dynamic Island" + "value": "Sesión del Registro de fuerza" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Séance du Carnet de force dans la Dynamic Island" + "value": "Séance du Carnet de force" } }, "it": { "stringUnit": { "state": "translated", - "value": "Sessione del Diario dei pesi nella Dynamic Island" + "value": "Sessione del Diario dei pesi" } }, "pt-PT": { "stringUnit": { "state": "translated", - "value": "Sessão do Registo de força na Dynamic Island" + "value": "Sessão do Registo de força" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Тренировка «Дневника тренировок» в Dynamic Island" + "value": "Тренировка «Дневника тренировок»" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "在灵动岛中显示力量日志训练" + "value": "力量日志训练" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "在動態島中顯示力量日誌訓練" + "value": "力量日誌訓練" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Sesja Dziennika siłowni w Dynamic Island" + "value": "Sesja Dziennika siłowni" } } } @@ -399,60 +399,60 @@ } } }, - "Lock Screen & Dynamic Island": { + "Live notifications": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Sperrbildschirm & Dynamic Island" + "value": "Live-Mitteilungen" } }, "es": { "stringUnit": { "state": "translated", - "value": "Pantalla de bloqueo y Dynamic Island" + "value": "Notificaciones en vivo" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Écran verrouillé et Dynamic Island" + "value": "Notifications en direct" } }, "it": { "stringUnit": { "state": "translated", - "value": "Schermata di blocco e Dynamic Island" + "value": "Notifiche in tempo reale" } }, "pt-PT": { "stringUnit": { "state": "translated", - "value": "Ecrã de bloqueio e Dynamic Island" + "value": "Notificações em direto" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Экран блокировки и Dynamic Island" + "value": "Уведомления в реальном времени" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "锁定屏幕与灵动岛" + "value": "实时通知" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "鎖定畫面與動態島" + "value": "即時通知" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Ekran blokady i Dynamic Island" + "value": "Powiadomienia na żywo" } } } @@ -515,408 +515,292 @@ } } }, - "Reconnect drops the current link, if any, and connects to the ring again. To pair or switch bands, open Devices.": { + "Progress while NOOP pulls history from the strap.": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "„Neu verbinden“ trennt die aktuelle Verbindung, falls vorhanden, und verbindet den Ring erneut. Zum Koppeln oder Wechseln von Bändern öffne „Geräte“." + "value": "Fortschritt, während NOOP den Verlauf vom Strap lädt." } }, "es": { "stringUnit": { "state": "translated", - "value": "Reconectar corta el enlace actual, si lo hay, y vuelve a conectar con el anillo. Para emparejar o cambiar de banda, abre Dispositivos." + "value": "El progreso mientras NOOP descarga el historial de la pulsera." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Reconnecter coupe le lien actuel, s'il existe, et se reconnecte à l'anneau. Pour associer ou changer de bande, ouvrez Appareils." + "value": "La progression pendant que NOOP récupère l'historique du bracelet." } }, "it": { "stringUnit": { "state": "translated", - "value": "Riconnetti interrompe il collegamento attuale, se presente, e si ricollega all'anello. Per accoppiare o cambiare fascia, apri Dispositivi." - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Połącz ponownie zrywa bieżące połączenie, jeśli istnieje, i ponownie łączy się z pierścieniem. Aby sparować lub przełączyć pasma, otwórz Urządzenia." + "value": "L'avanzamento mentre NOOP scarica lo storico dalla fascia." } }, "pt-PT": { "stringUnit": { "state": "translated", - "value": "Voltar a ligar termina a ligação atual, se existir, e liga-se novamente ao anel. Para emparelhar ou mudar de banda, abra Dispositivos." + "value": "O progresso enquanto o NOOP obtém o histórico da pulseira." } }, "ru": { "stringUnit": { "state": "translated", - "value": "«Переподключить» разрывает текущее соединение, если оно есть, и снова подключается к кольцу. Чтобы выполнить сопряжение или сменить браслет, откройте раздел «Устройства»." + "value": "Прогресс, пока NOOP загружает историю с браслета." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "重新连接会断开当前连接(如果有)并再次连接戒指。要配对或切换手环,请打开「设备」。" + "value": "NOOP 从手环读取历史数据时的进度。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "重新連接會中斷目前連線(如果有)並再次連接戒指。要配對或切換手環,請打開「裝置」。" - } - } - } - }, - "Reconnect ring": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Ring neu verbinden" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Reconectar anillo" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Reconnecter l'anneau" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Riconnetti anello" + "value": "NOOP 從手環讀取歷史資料時的進度。" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Połącz ponownie pierścień" - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "Voltar a ligar o anel" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Переподключить кольцо" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重新连接戒指" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "重新連接戒指" + "value": "Postęp, gdy NOOP pobiera historię z paska." } } } }, - "Ring not connected.": { + "Reconnect drops the current link, if any, and connects to the ring again. To pair or switch bands, open Devices.": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Ring nicht verbunden." + "value": "„Neu verbinden“ trennt die aktuelle Verbindung, falls vorhanden, und verbindet den Ring erneut. Zum Koppeln oder Wechseln von Bändern öffne „Geräte“." } }, "es": { "stringUnit": { "state": "translated", - "value": "Anillo no conectado." + "value": "Reconectar corta el enlace actual, si lo hay, y vuelve a conectar con el anillo. Para emparejar o cambiar de banda, abre Dispositivos." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Anneau non connecté." + "value": "Reconnecter coupe le lien actuel, s'il existe, et se reconnecte à l'anneau. Pour associer ou changer de bande, ouvrez Appareils." } }, "it": { "stringUnit": { "state": "translated", - "value": "Anello non connesso." + "value": "Riconnetti interrompe il collegamento attuale, se presente, e si ricollega all'anello. Per accoppiare o cambiare fascia, apri Dispositivi." } }, "pl": { "stringUnit": { "state": "translated", - "value": "Pierścień niepołączony." + "value": "Połącz ponownie zrywa bieżące połączenie, jeśli istnieje, i ponownie łączy się z pierścieniem. Aby sparować lub przełączyć pasma, otwórz Urządzenia." } }, "pt-PT": { "stringUnit": { "state": "translated", - "value": "Anel não ligado." + "value": "Voltar a ligar termina a ligação atual, se existir, e liga-se novamente ao anel. Para emparelhar ou mudar de banda, abra Dispositivos." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Кольцо не подключено." + "value": "«Переподключить» разрывает текущее соединение, если оно есть, и снова подключается к кольцу. Чтобы выполнить сопряжение или сменить браслет, откройте раздел «Устройства»." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "戒指未连接。" + "value": "重新连接会断开当前连接(如果有)并再次连接戒指。要配对或切换手环,请打开「设备」。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "戒指未連接。" + "value": "重新連接會中斷目前連線(如果有)並再次連接戒指。要配對或切換手環,請打開「裝置」。" } } } }, - "Shows Connecting… / Syncing… with the chunk count and elapsed time while NOOP pulls history from your strap, including a sync started by the Sync Strap shortcut. Independent of the live heart rate switch above.": { + "Reconnect ring": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Zeigt „Verbinde…“ / „Synchronisiere…“ mit Chunk-Anzahl und verstrichener Zeit, während NOOP Daten von deinem Strap abruft – auch bei einer Synchronisierung über den Kurzbefehl „Strap synchronisieren“. Unabhängig vom Schalter für den Live-Puls oben." + "value": "Ring neu verbinden" } }, "es": { "stringUnit": { "state": "translated", - "value": "Muestra Conectando… / Sincronizando… con el número de fragmentos y el tiempo transcurrido mientras NOOP descarga el historial de tu pulsera, incluida una sincronización iniciada con el atajo Sincronizar pulsera. Independiente del interruptor de frecuencia cardíaca en vivo de arriba." + "value": "Reconectar anillo" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Affiche Connexion… / Synchronisation… avec le nombre de blocs et le temps écoulé pendant que NOOP récupère l'historique de votre bracelet, y compris une synchronisation lancée par le raccourci Synchroniser le bracelet. Indépendant de l'interrupteur de fréquence cardiaque en direct ci-dessus." + "value": "Reconnecter l'anneau" } }, "it": { "stringUnit": { "state": "translated", - "value": "Mostra Connessione… / Sincronizzazione… con il numero di blocchi e il tempo trascorso mentre NOOP scarica la cronologia dalla fascia, anche per una sincronizzazione avviata dal comando rapido Sincronizza la fascia. Indipendente dall'interruttore della frequenza cardiaca in tempo reale qui sopra." - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "Mostra A ligar… / A sincronizar… com o número de blocos e o tempo decorrido enquanto o NOOP transfere o histórico da sua pulseira, incluindo uma sincronização iniciada pelo atalho Sincronizar pulseira. Independente do interruptor de frequência cardíaca em direto acima." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Показывает «Подключение…» / «Синхронизация…» с числом блоков и прошедшим временем, пока NOOP загружает историю с браслета, в том числе при синхронизации через команду «Синхронизировать браслет». Не зависит от переключателя пульса в реальном времени выше." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "NOOP 从手环读取历史数据时显示“连接中…”/“同步中…”、数据块数量和已用时间,包括通过“同步手环”快捷指令启动的同步。与上方的实时心率开关无关。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "NOOP 從手環讀取歷史資料時顯示「連接中…」/「同步中…」、資料塊數量和已用時間,包括透過「同步手環」捷徑啟動的同步。與上方的即時心率開關無關。" + "value": "Riconnetti anello" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Pokazuje Łączenie… / Synchronizowanie… z liczbą fragmentów i czasem trwania, gdy NOOP pobiera historię z paska, także przy synchronizacji uruchomionej skrótem Synchronizuj pasek. Niezależne od przełącznika tętna na żywo powyżej." - } - } - } - }, - "Shows sync progress on the Lock Screen and in the Dynamic Island": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zeigt den Synchronisierungsfortschritt auf dem Sperrbildschirm und in der Dynamic Island" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Muestra el progreso de la sincronización en la pantalla de bloqueo y en la Dynamic Island" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Affiche la progression de la synchronisation sur l'écran verrouillé et dans la Dynamic Island" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Mostra l'avanzamento della sincronizzazione nella schermata di blocco e nella Dynamic Island" + "value": "Połącz ponownie pierścień" } }, "pt-PT": { "stringUnit": { "state": "translated", - "value": "Mostra o progresso da sincronização no ecrã bloqueado e na Dynamic Island" + "value": "Voltar a ligar o anel" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Показывает ход синхронизации на экране блокировки и в Dynamic Island" + "value": "Переподключить кольцо" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "在锁定屏幕和灵动岛中显示同步进度" + "value": "重新连接戒指" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "在鎖定畫面和動態島中顯示同步進度" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Pokazuje postęp synchronizacji na ekranie blokady i w Dynamic Island" + "value": "重新連接戒指" } } } }, - "Shows the set you are on, your rest and your heart rate while a Lift Log session runs, and lights the Lock Screen when the strap moves the session on. Independent of the live heart rate switch.": { + "Ring not connected.": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Zeigt während einer Trainingsbuch-Einheit deinen aktuellen Satz, deine Pause und deine Herzfrequenz und lässt den Sperrbildschirm aufleuchten, wenn der Strap die Einheit weiterschaltet. Unabhängig vom Schalter für die Live-Herzfrequenz." + "value": "Ring nicht verbunden." } }, "es": { "stringUnit": { "state": "translated", - "value": "Muestra la serie en la que estás, tu descanso y tu frecuencia cardíaca durante una sesión del Registro de fuerza, e ilumina la pantalla de bloqueo cuando la pulsera avanza la sesión. Independiente del interruptor de la frecuencia cardíaca en vivo." + "value": "Anillo no conectado." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Affiche la série en cours, votre repos et votre fréquence cardiaque pendant une séance du Carnet de force, et allume l'écran verrouillé quand le bracelet fait avancer la séance. Indépendant de l'interrupteur de la fréquence cardiaque en direct." + "value": "Anneau non connecté." } }, "it": { "stringUnit": { "state": "translated", - "value": "Mostra la serie in corso, il recupero e la frequenza cardiaca durante una sessione del Diario dei pesi, e accende la schermata di blocco quando la fascia fa avanzare la sessione. Indipendente dall'interruttore della frequenza cardiaca in diretta." + "value": "Anello non connesso." } }, - "pt-PT": { + "pl": { "stringUnit": { "state": "translated", - "value": "Mostra a série em que estás, o teu descanso e a tua frequência cardíaca durante uma sessão do Registo de força, e acende o ecrã de bloqueio quando a pulseira avança a sessão. Independente do interruptor da frequência cardíaca em direto." + "value": "Pierścień niepołączony." } }, - "ru": { + "pt-PT": { "stringUnit": { "state": "translated", - "value": "Показывает текущий подход, отдых и пульс во время тренировки «Дневника тренировок» и включает экран блокировки, когда браслет переключает тренировку дальше. Не зависит от переключателя пульса в реальном времени." + "value": "Anel não ligado." } }, - "zh-Hans": { + "ru": { "stringUnit": { "state": "translated", - "value": "在力量日志训练期间显示你当前的组、休息时间和心率,并在手环推进训练时点亮锁定屏幕。与实时心率开关互不影响。" + "value": "Кольцо не подключено." } }, - "zh-Hant": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "在力量日誌訓練期間顯示你目前的組、休息時間和心率,並在手環推進訓練時點亮鎖定畫面。與即時心率開關互不影響。" + "value": "戒指未连接。" } }, - "pl": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Pokazuje bieżącą serię, przerwę i tętno podczas sesji Dziennika siłowni oraz podświetla ekran blokady, gdy pasek przesuwa sesję dalej. Niezależne od przełącznika tętna na żywo." + "value": "戒指未連接。" } } } }, - "Strap sync in Dynamic Island": { + "Shown on the Lock Screen and in the Dynamic Island. A switch only hides one: NOOP still measures and records everything.": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Strap-Synchronisierung in der Dynamic Island" + "value": "Auf dem Sperrbildschirm und in der Dynamic Island. Ein Schalter blendet nur die Anzeige aus: NOOP misst und speichert weiterhin alles." } }, "es": { "stringUnit": { "state": "translated", - "value": "Sincronización de la pulsera en la Dynamic Island" + "value": "En la pantalla de bloqueo y en la Dynamic Island. Un interruptor solo oculta la notificación: NOOP sigue midiendo y registrando todo." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Synchronisation du bracelet dans la Dynamic Island" + "value": "Sur l'écran verrouillé et dans la Dynamic Island. Un interrupteur ne fait que masquer l'affichage : NOOP continue de tout mesurer et d'enregistrer." } }, "it": { "stringUnit": { "state": "translated", - "value": "Sincronizzazione della fascia nella Dynamic Island" + "value": "Sulla schermata di blocco e nella Dynamic Island. Un interruttore nasconde solo la notifica: NOOP continua a misurare e registrare tutto." } }, "pt-PT": { "stringUnit": { "state": "translated", - "value": "Sincronização da pulseira na Dynamic Island" + "value": "No ecrã de bloqueio e na Dynamic Island. Um interruptor apenas oculta a notificação: o NOOP continua a medir e a registar tudo." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Синхронизация браслета в Dynamic Island" + "value": "На экране блокировки и в Dynamic Island. Переключатель только скрывает уведомление: NOOP продолжает всё измерять и записывать." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "灵动岛中显示手环同步" + "value": "显示在锁定屏幕和灵动岛上。开关只会隐藏通知:NOOP 仍会测量并记录所有数据。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "動態島中顯示手環同步" + "value": "顯示在鎖定畫面和動態島上。開關只會隱藏通知:NOOP 仍會測量並記錄所有資料。" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Synchronizacja paska w Dynamic Island" + "value": "Na ekranie blokady i w Dynamic Island. Przełącznik tylko ukrywa powiadomienie: NOOP nadal wszystko mierzy i zapisuje." } } } @@ -1864,60 +1748,118 @@ "Working sets": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Arbeitssätze"}}, "es": {"stringUnit": {"state": "translated", "value": "Series efectivas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Séries de travail"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie di lavoro"}}, "pl": {"stringUnit": {"state": "translated", "value": "Serie robocze"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Séries de trabalho"}}, "ru": {"stringUnit": {"state": "translated", "value": "Рабочие подходы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "正式组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "正式組"}} } }, - "What NOOP shows there, each on its own switch.": { + "While the strap is connected.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Solange der Strap verbunden ist." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mientras la pulsera está conectada." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tant que le bracelet est connecté." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Finché la fascia è collegata." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Enquanto a pulseira está ligada." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пока браслет подключён." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "手环连接期间。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "手環連接期間。" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Gdy pasek jest połączony." + } + } + } + }, + "Your set, rest and heart rate, and the Lock Screen light-up on a double-tap.": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Was NOOP dort anzeigt, jeweils mit eigenem Schalter." + "value": "Dein Satz, deine Pause und deine Herzfrequenz, dazu das Aufleuchten des Sperrbildschirms beim Doppeltippen." } }, "es": { "stringUnit": { "state": "translated", - "value": "Lo que NOOP muestra ahí, cada cosa con su propio interruptor." + "value": "Tu serie, tu descanso y tu frecuencia cardíaca, y el encendido de la pantalla de bloqueo con un doble toque." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Ce que NOOP y affiche, chaque élément avec son propre interrupteur." + "value": "Votre série, votre repos et votre fréquence cardiaque, et l'allumage de l'écran verrouillé au double-tap." } }, "it": { "stringUnit": { "state": "translated", - "value": "Cosa mostra NOOP lì, ognuno con il proprio interruttore." + "value": "La tua serie, il recupero e la frequenza cardiaca, e l'accensione della schermata di blocco al doppio tocco." } }, "pt-PT": { "stringUnit": { "state": "translated", - "value": "O que o NOOP mostra aí, cada um com o seu próprio interruptor." + "value": "A tua série, o teu descanso e a tua frequência cardíaca, e o acender do ecrã de bloqueio com um duplo toque." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Что NOOP там показывает — у каждого свой переключатель." + "value": "Ваш подход, отдых и пульс, а также включение экрана блокировки по двойному касанию." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "NOOP 在这里显示的内容,每项都有单独的开关。" + "value": "你当前的组、休息和心率,以及双击时点亮锁定屏幕。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "NOOP 在這裡顯示的內容,每項都有單獨的開關。" + "value": "你目前的組、休息和心率,以及雙擊時點亮鎖定畫面。" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Co NOOP tam pokazuje, każde z własnym przełącznikiem." + "value": "Twoja seria, przerwa i tętno oraz podświetlenie ekranu blokady po podwójnym stuknięciu." } } } @@ -94826,64 +94768,6 @@ } } }, - "Live heart rate in Dynamic Island": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Live-Herzfrequenz in der Dynamic Island" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Frecuencia cardíaca en vivo en la Dynamic Island" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Fréquence cardiaque en direct dans la Dynamic Island" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Frequenza cardiaca in diretta nella Dynamic Island" - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "Frequência cardíaca em direto em Dynamic Island" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Пульс в реальном времени в Dynamic Island" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "在灵动岛中显示实时心率" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "在動態島中顯示即時心率" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Tętno na żywo w Dynamic Island" - } - } - } - }, "Live heart rate is flowing; full strap controls need an encrypted bond.": { "localizations": { "de": { @@ -146383,64 +146267,6 @@ } } }, - "Shows your live heart rate on the Lock Screen and in the Dynamic Island while the strap is connected. Turn it off to keep your live HR out of the Dynamic Island. (Any one already showing clears within a moment.)": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zeigt deine Live-Herzfrequenz auf dem Sperrbildschirm und in der Dynamic Island, während der Strap verbunden ist. Schalte es aus, um deine Live-HF aus der Dynamic Island fernzuhalten. (Eine bereits angezeigte verschwindet gleich darauf.)" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Muestra tu frecuencia cardíaca en vivo en la pantalla de bloqueo y en la Dynamic Island mientras la pulsera está conectada. Desactívalo para mantener tu FC en vivo fuera de la Dynamic Island. (Cualquiera que ya se muestre desaparece en un momento.)" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Affiche votre fréquence cardiaque en direct sur l'écran verrouillé et dans la Dynamic Island tant que le bracelet est connecté. Désactivez-la pour garder votre FC en direct hors de la Dynamic Island. (Toute FC déjà affichée disparaît sous peu.)" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Mostra la tua frequenza cardiaca in diretta sulla schermata di blocco e nella Dynamic Island mentre la fascia è connessa. Disattivalo per tenere la tua FC in diretta fuori dalla Dynamic Island. (Quella eventualmente già mostrata sparisce in un attimo.)" - } - }, - "pt-PT": { - "stringUnit": { - "state": "translated", - "value": "Mostra a tua frequência cardíaca em direto no ecrã de bloqueio e na Ilha Dinâmica enquanto a bracelete está ligada. Desligue-o para manter o teu RH ativo fora da Ilha Dinâmica. (Qualquer um que já esteja a aparecer será eliminado daqui a pouco.)" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Показывает ваш текущий пульс на заблокированном экране и в Dynamic Island, пока браслет подключён. Отключите, чтобы пульс не отображался в Dynamic Island. (Уже показанный индикатор исчезнет через мгновение.)" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "在手环连接期间,于锁定屏幕和灵动岛上显示你的实时心率。将它关闭可让你的实时心率不出现在灵动岛中。(任何已在显示的都会在片刻内清除。)" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "在手環連接期間,於鎖定畫面和動態島上顯示你的即時心率。將它關閉可讓你的即時心率不出現在動態島中。(任何已在顯示的都會在片刻內清除。)" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Pokazuje Twoje tętno na żywo na ekranie blokady i na Dynamic Island, gdy pasek jest podłączony. Wyłącz tę opcję, aby Twoje tętno na żywo nie znajdowało się poza dynamiczną wyspą. (Każdy, kto już pokazał, znika w ciągu chwili.)" - } - } - } - }, "Signal Trust": { "localizations": { "de": { diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 323e9b9450..63f00af533 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -265,7 +265,7 @@ struct SettingsView: View { appearanceCard.staggeredAppear(index: 2) strapCard.staggeredAppear(index: 3) #if os(iOS) - lockScreenCard.staggeredAppear(index: 3) + liveNotificationsCard.staggeredAppear(index: 3) #endif streakCard.staggeredAppear(index: 4) featuresCard.staggeredAppear(index: 5) @@ -1558,58 +1558,43 @@ struct SettingsView: View { /// `noop.hrvBaselineEpoch` and `noop.recoveryBaselineEpoch` settings the recovery engine reads, then /// kicks a recompute the same way the sleep-edit path does (analyzeRecent → refresh). History stays. #if os(iOS) - /// Everything NOOP shows on the Lock Screen and in the Dynamic Island, one switch each: the live heart rate, - /// the Lift Log session and the strap sync. They are independent — a person can keep the gym banner and drop - /// the everyday heart-rate one — and each only decides what is SHOWN: the heart rate is still measured, - /// recorded and scored with its banner off. - private var lockScreenCard: some View { + /// NOOP's live notifications — its Live Activities, on the Lock Screen and in the Dynamic Island — one switch + /// each: the live heart rate, a Lift Log session, a strap sync. These three are every Live Activity the app has. + /// A switch only decides whether its notification is SHOWN: the heart rate is still measured, recorded and + /// scored, a session still runs and buzzes, a sync still runs, with any of them off. + private var liveNotificationsCard: some View { SettingsSection( - icon: "iphone", - title: "Lock Screen & Dynamic Island", - blurb: "What NOOP shows there, each on its own switch." + icon: "bell.badge", + title: "Live notifications", + blurb: "Shown on the Lock Screen and in the Dynamic Island. A switch only hides one: NOOP still measures and records everything." ) { VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { - // MARK: Live Activity — show live HR on the Lock Screen + Dynamic Island (#336). - Toggle(isOn: $liveActivityEnabled) { - Text("Live heart rate in Dynamic Island") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - Text("Shows your live heart rate on the Lock Screen and in the Dynamic Island while the strap is connected. Turn it off to keep your live HR out of the Dynamic Island. (Any one already showing clears within a moment.)") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) + liveNotificationSwitch("Live heart rate", isOn: $liveActivityEnabled, + detail: "While the strap is connected.") rowDivider - // MARK: Lift Log session Live Activity — its own switch, so the gym banner can stay while the - // everyday heart-rate one is off. - Toggle(isOn: $liftLiveActivityEnabled) { - Text("Lift Log session in Dynamic Island") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - Text("Shows the set you are on, your rest and your heart rate while a Lift Log session runs, and lights the Lock Screen when the strap moves the session on. Independent of the live heart rate switch.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) + liveNotificationSwitch("Lift Log session", isOn: $liftLiveActivityEnabled, + detail: "Your set, rest and heart rate, and the Lock Screen light-up on a double-tap.") rowDivider - // MARK: Strap-sync Live Activity — its own switch, independent of the live-HR one. - Toggle(isOn: $syncLiveActivityEnabled) { - Text("Strap sync in Dynamic Island") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - } - .toggleStyle(.switch) - .tint(StrandPalette.accent) - .accessibilityHint("Shows sync progress on the Lock Screen and in the Dynamic Island") - Text("Shows Connecting… / Syncing… with the chunk count and elapsed time while NOOP pulls history from your strap, including a sync started by the Sync Strap shortcut. Independent of the live heart rate switch above.") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) + liveNotificationSwitch("Strap sync", isOn: $syncLiveActivityEnabled, + detail: "Progress while NOOP pulls history from the strap.") + } + } + } + + private func liveNotificationSwitch(_ title: LocalizedStringKey, isOn: Binding, + detail: LocalizedStringKey) -> some View { + VStack(alignment: .leading, spacing: NoopMetrics.space1) { + Toggle(isOn: isOn) { + Text(title) + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) } + .toggleStyle(.switch) + .tint(StrandPalette.accent) + Text(detail) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) } } #endif diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index 3911ac645b..f7e80c1a93 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -49,6 +49,14 @@ struct StrandiOSApp: App { /// unit preference lives here and not in the widget extension. @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue + /// The live heart rate banner makes room for the gym banner during a Lift Log session, and for the sync banner + /// during a sync — but only when that banner's own switch is on, so turning one live notification off never + /// leaves the Lock Screen with none of the others. + private var heartRateBannerStandsAside: Bool { + (liftSession.isActive && UnitPrefs.liftLiveActivityEnabled()) + || (model.live.backfilling && UnitPrefs.syncLiveActivityEnabled()) + } + init() { // #1008: pin the pre-change Overnight-only default for existing installs before // anything reads it. Idempotent; a no-op on fresh installs and after the first launch. @@ -107,8 +115,10 @@ struct StrandiOSApp: App { })) // A gym session keeps ONE banner on the Lock Screen, its own — as the live-HR banner already // stands aside for it. A sync started in the foreground mid-session starts no sync banner. + // Held back only for a gym banner that will actually show: with its switch off, a session leaves the + // Lock Screen to the sync, rather than to nothing. SyncLiveActivityController.shared.holdsBackNewBanner = { [weak liftSession] in - liftSession?.isActive == true + liftSession?.isActive == true && UnitPrefs.liftLiveActivityEnabled() } // Before any view or publisher exists: the first push to the Lock Screen banner must find the // session already running, or it ends the banner iOS kept alive across the restart. @@ -232,7 +242,7 @@ struct StrandiOSApp: App { bpm: model.live.connected ? (model.bpm ?? model.live.heartRate) : nil, recovery: day?.recovery.map { Int($0.rounded()) }, // While a sync runs its own activity is the useful banner; don't stack the HR one. - connected: model.live.connected && !liftSession.isActive && !model.live.backfilling, + connected: model.live.connected && !heartRateBannerStandsAside, effort: day?.strain.map { Int($0.rounded()) } ) // The gym banner's own cheap path: no presentation is built here, and a heart rate moves @@ -249,7 +259,7 @@ struct StrandiOSApp: App { liveActivity.update( bpm: isConnected ? (model.bpm ?? model.live.heartRate) : nil, recovery: day?.recovery.map { Int($0.rounded()) }, - connected: isConnected && !liftSession.isActive && !model.live.backfilling, + connected: isConnected && !heartRateBannerStandsAside, effort: day?.strain.map { Int($0.rounded()) } ) } diff --git a/StrandiOS/Widgets/SyncLiveActivityController.swift b/StrandiOS/Widgets/SyncLiveActivityController.swift index 195dcb9b99..ad2c1ad4be 100644 --- a/StrandiOS/Widgets/SyncLiveActivityController.swift +++ b/StrandiOS/Widgets/SyncLiveActivityController.swift @@ -154,7 +154,7 @@ final class SyncLiveActivityController { return } guard UnitPrefs.syncLiveActivityEnabled() else { - live?.append(log: "Sync activity: not started — \"Strap sync in Dynamic Island\" is off in NOOP Settings") + live?.append(log: "Sync activity: not started — \"Strap sync\" is off in NOOP Settings → Live notifications") return } if let activity { push(activity, state); return } From 99abbcb7a5fd4564b301c2aae35f84edff839f23 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:09:25 +0200 Subject: [PATCH 3/3] settings: leave the heart-rate banner's make-room rule to #2422 #2422 replaces it with a better one: the live heart rate banner makes room only for another NOOP banner actually on screen (not for any session or any sync, which in the background shows no banner and used to end it every 15 minutes). Keeping this PR's switch-aware version as well would give two rules for one decision, and a conflict between the two PRs. The sync banner's hold-back during a session stays here. Co-Authored-By: Claude Opus 5.5 --- StrandiOS/App/StrandiOSApp.swift | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index f7e80c1a93..9f18792679 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -49,14 +49,6 @@ struct StrandiOSApp: App { /// unit preference lives here and not in the widget extension. @AppStorage(UnitPrefs.systemKey) private var unitSystemRaw = UnitSystem.metric.rawValue - /// The live heart rate banner makes room for the gym banner during a Lift Log session, and for the sync banner - /// during a sync — but only when that banner's own switch is on, so turning one live notification off never - /// leaves the Lock Screen with none of the others. - private var heartRateBannerStandsAside: Bool { - (liftSession.isActive && UnitPrefs.liftLiveActivityEnabled()) - || (model.live.backfilling && UnitPrefs.syncLiveActivityEnabled()) - } - init() { // #1008: pin the pre-change Overnight-only default for existing installs before // anything reads it. Idempotent; a no-op on fresh installs and after the first launch. @@ -242,7 +234,7 @@ struct StrandiOSApp: App { bpm: model.live.connected ? (model.bpm ?? model.live.heartRate) : nil, recovery: day?.recovery.map { Int($0.rounded()) }, // While a sync runs its own activity is the useful banner; don't stack the HR one. - connected: model.live.connected && !heartRateBannerStandsAside, + connected: model.live.connected && !liftSession.isActive && !model.live.backfilling, effort: day?.strain.map { Int($0.rounded()) } ) // The gym banner's own cheap path: no presentation is built here, and a heart rate moves @@ -259,7 +251,7 @@ struct StrandiOSApp: App { liveActivity.update( bpm: isConnected ? (model.bpm ?? model.live.heartRate) : nil, recovery: day?.recovery.map { Int($0.rounded()) }, - connected: isConnected && !heartRateBannerStandsAside, + connected: isConnected && !liftSession.isActive && !model.live.backfilling, effort: day?.strain.map { Int($0.rounded()) } ) }