From 37ae5df8273af57ce4b15f096e4caf88acb0bc68 Mon Sep 17 00:00:00 2001 From: paradix86 <2883806+paradix86@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:48:13 +0200 Subject: [PATCH 1/2] feat(android): use profile weight from Health Connect Adds an opt-in "Use weight from Health Connect" toggle to the Profile card (off by default). While on, every Health Connect import writes the newest Health Connect weight into the profile, the weight stepper is locked, and a caption names the source and the reading's day. Switching it on syncs once immediately from the already-imported rows. The profile sync and the Today Weight tile resolve the reading through one helper (ProfileWeightSync.newest), so with the toggle on they cannot show different weights. Android only: Apple platforms have no Health Connect. The toggle and its sync day stay out of the .noopbak whitelist; the synced weight still round-trips as profile.weightKg. --- .../com/noop/analytics/ProfileWeightSync.kt | 34 ++++++ .../main/java/com/noop/data/BackupSettings.kt | 4 +- .../com/noop/ingest/HealthConnectImporter.kt | 15 ++- .../src/main/java/com/noop/ui/Components.kt | 16 ++- .../com/noop/ui/HealthConnectWeightSync.kt | 43 +++++++ .../main/java/com/noop/ui/SettingsScreen.kt | 73 +++++++++++- .../java/com/noop/ui/TodayMetricsLogic.kt | 18 ++- .../src/main/java/com/noop/ui/TodayScreen.kt | 5 +- .../app/src/main/res/values-de/strings.xml | 4 + .../app/src/main/res/values-es/strings.xml | 4 + .../app/src/main/res/values-fr/strings.xml | 4 + .../app/src/main/res/values-it/strings.xml | 4 + .../app/src/main/res/values-pl/strings.xml | 4 + .../src/main/res/values-pt-rPT/strings.xml | 4 + .../app/src/main/res/values-ru/strings.xml | 4 + .../app/src/main/res/values-zh/strings.xml | 4 + android/app/src/main/res/values/strings.xml | 4 + .../noop/analytics/ProfileWeightSyncTest.kt | 38 +++++++ .../noop/ui/HealthConnectWeightSyncTest.kt | 107 ++++++++++++++++++ .../java/com/noop/ui/TodayMetricTilesTest.kt | 16 +++ 20 files changed, 390 insertions(+), 15 deletions(-) create mode 100644 android/app/src/main/java/com/noop/analytics/ProfileWeightSync.kt create mode 100644 android/app/src/main/java/com/noop/ui/HealthConnectWeightSync.kt create mode 100644 android/app/src/test/java/com/noop/analytics/ProfileWeightSyncTest.kt create mode 100644 android/app/src/test/java/com/noop/ui/HealthConnectWeightSyncTest.kt diff --git a/android/app/src/main/java/com/noop/analytics/ProfileWeightSync.kt b/android/app/src/main/java/com/noop/analytics/ProfileWeightSync.kt new file mode 100644 index 0000000000..fdecafebdf --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/ProfileWeightSync.kt @@ -0,0 +1,34 @@ +package com.noop.analytics + +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** One imported body-weight reading: the ISO `yyyy-MM-dd` day it belongs to, in kilograms. */ +data class WeightReading(val day: String, val kg: Double) + +/** + * The single resolver behind "Use weight from Health Connect". + * + * Two readers need the same fact: the sync that copies the newest Health Connect weight into the + * profile (which every analytics pass reads), and the Today Weight tile. Both pick it through [newest], + * so with the sync ON the tile and the profile cannot name different weights. No Android imports, so it + * runs as a plain JVM test. + */ +object ProfileWeightSync { + + /** + * The newest reading, or null when there is none. Days are ISO `yyyy-MM-dd`, which sorts + * chronologically, so the lexically greatest day is the most recent and no date parsing is needed. + */ + fun newest(readings: List): WeightReading? = readings.maxByOrNull { it.day } + + /** + * "25 Sep" in the app language for the Settings caption, verbatim on an unparseable day. + * [locale] defaults to [Locale.getDefault], which `AppLanguagePrefs` keeps on the selected app + * language, so an Italian UI reads "25 set" rather than an English month. + */ + fun captionDate(day: String, locale: Locale = Locale.getDefault()): String = + runCatching { LocalDate.parse(day).format(DateTimeFormatter.ofPattern("d MMM", locale)) } + .getOrDefault(day) +} diff --git a/android/app/src/main/java/com/noop/data/BackupSettings.kt b/android/app/src/main/java/com/noop/data/BackupSettings.kt index 4b7d79df4b..fe96c9f69e 100644 --- a/android/app/src/main/java/com/noop/data/BackupSettings.kt +++ b/android/app/src/main/java/com/noop/data/BackupSettings.kt @@ -43,7 +43,9 @@ object BackupSettingsCodec { * HR-max override (`profile.hrMax`, 0 = auto/Tanaka). Display: the body and exercise-distance * systems, separate temperature override ("" = follow body), and the Effort axis (#268). Deliberately * EXCLUDED: step calibration (per-strap, not per-person), the steps-engine fitted outputs - * (derived), and every noop.* toggle that is device- or install-specific. + * (derived), and every noop.* toggle that is device- or install-specific. That includes the + * Android-only "Use weight from Health Connect" toggle and its sync day (`ProfileStore`): a restore + * needs its own Health Connect grant, and the synced weight itself travels as `profile.weightKg`. */ val WHITELIST: Map = linkedMapOf( "profile.age" to Kind.INT, diff --git a/android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt b/android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt index e5028cb2ba..5893bd8c4e 100644 --- a/android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt +++ b/android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt @@ -33,6 +33,7 @@ import com.noop.data.MetricSeriesRow import com.noop.data.SleepSession import com.noop.data.WhoopRepository import com.noop.data.WorkoutRow +import com.noop.ui.HealthConnectWeightSync import com.noop.ui.NoopPrefs import java.time.Instant import java.time.LocalDate @@ -80,8 +81,8 @@ object HealthConnectImporter { // Health Connect data is stored under its OWN source ("health-connect"), NOT the shared // "apple-health" bucket — otherwise it's mis-attributed to Apple Health in the UI (issue #34). // (The recovery/sleep backfill still lands under "my-whoop"; only the external-health aggregates - // + workouts carry this source.) - private const val HC_DEVICE = "health-connect" + // + workouts carry this source.) Internal so the profile weight sync reads the same bucket. + internal const val HC_DEVICE = "health-connect" private const val HC_WORKOUT_SOURCE = "health-connect" /** Read window: a wide ~10-year span ending now. Health Connect itself caps retention. */ @@ -862,6 +863,16 @@ object HealthConnectImporter { return ImportSummary.failure(SOURCE, "Saving Health Connect data failed: ${e.message}") } + // Opt-in profile weight sync. Hooked here rather than at the callers so every entry point + // (periodic auto-sync, Data Sources, onboarding) keeps the profile current. Best-effort like the + // import stamp below: a profile write must not sink an otherwise good import. + try { + HealthConnectWeightSync.syncFromRepository(context, repo) + } catch (e: Exception) { + // Best-effort, but a cancelled import must still stop here rather than carry on. + if (e is kotlin.coroutines.cancellation.CancellationException) throw e + } + val counts = buildMap { if (appleRows.isNotEmpty()) put("appleDaily", appleRows.size) if (dailyRows.isNotEmpty()) put("dailyMetric", dailyRows.size) diff --git a/android/app/src/main/java/com/noop/ui/Components.kt b/android/app/src/main/java/com/noop/ui/Components.kt index 9230fda679..af1b4e7ae3 100644 --- a/android/app/src/main/java/com/noop/ui/Components.kt +++ b/android/app/src/main/java/com/noop/ui/Components.kt @@ -66,6 +66,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import android.content.Context import androidx.compose.ui.draw.drawBehind @@ -1527,13 +1528,18 @@ fun StepperField( accessibility: String, unit: String? = null, valueColor: Color = Palette.textPrimary, + // False when another source owns the value (e.g. weight synced from Health Connect): the value + // still reads, dimmed, and the buttons stop responding. + enabled: Boolean = true, onMinus: () -> Unit, onPlus: () -> Unit, ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.semantics { contentDescription = accessibility }, + modifier = Modifier + .semantics { contentDescription = accessibility } + .alpha(if (enabled) 1f else Palette.disabledOpacity), ) { Text( value, @@ -1544,20 +1550,20 @@ fun StepperField( if (unit != null) { Text(unit, style = NoopType.caption, color = Palette.textTertiary) } - StepperButton(symbol = "−", onClick = onMinus, label = uiString(R.string.l10n_components_decrease_accessibility_df5f1511, accessibility)) - StepperButton(symbol = "+", onClick = onPlus, label = uiString(R.string.l10n_components_increase_accessibility_0949c0e9, accessibility)) + StepperButton(symbol = "−", onClick = onMinus, label = uiString(R.string.l10n_components_decrease_accessibility_df5f1511, accessibility), enabled = enabled) + StepperButton(symbol = "+", onClick = onPlus, label = uiString(R.string.l10n_components_increase_accessibility_0949c0e9, accessibility), enabled = enabled) } } @Composable -fun StepperButton(symbol: String, onClick: () -> Unit, label: String) { +fun StepperButton(symbol: String, onClick: () -> Unit, label: String, enabled: Boolean = true) { Box( modifier = Modifier .size(30.dp) .clip(RoundedCornerShape(8.dp)) .background(Palette.surfaceInset) .border(1.dp, Palette.hairline, RoundedCornerShape(8.dp)) - .clickable(onClick = onClick) + .clickable(enabled = enabled, onClick = onClick) .semantics { contentDescription = label }, contentAlignment = Alignment.Center, ) { diff --git a/android/app/src/main/java/com/noop/ui/HealthConnectWeightSync.kt b/android/app/src/main/java/com/noop/ui/HealthConnectWeightSync.kt new file mode 100644 index 0000000000..3313fb2ddd --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/HealthConnectWeightSync.kt @@ -0,0 +1,43 @@ +package com.noop.ui + +import android.content.Context +import com.noop.analytics.ProfileWeightSync +import com.noop.analytics.WeightReading +import com.noop.data.AppleDaily +import com.noop.data.WhoopRepository +import com.noop.ingest.HealthConnectImporter + +/** + * "Use weight from Health Connect": copies the newest Health Connect weight into the profile, so every + * analytics reader of [ProfileStore.toUserProfile] (calories, workout energy, BMR) uses the weight the + * user actually measured instead of a manual figure nobody updates. + * + * It reads the whole stored Health Connect history rather than the rows of the import that just ran, so + * switching the toggle on still picks up a weigh-in older than the import window. + */ +object HealthConnectWeightSync { + + /** + * The profile write, separated from the database read so it runs as a plain JVM test. A no-op that + * returns null when the toggle is OFF or no Health Connect row carries a weight; otherwise writes + * [ProfileStore.weightKg] and [ProfileStore.healthConnectWeightDay] and returns the reading applied. + */ + internal fun apply(profile: ProfileStore, healthConnectRows: List): WeightReading? { + if (!profile.useHealthConnectWeight) return null + val newest = ProfileWeightSync.newest(weightReadings(healthConnectRows)) ?: return null + profile.weightKg = newest.kg + profile.healthConnectWeightDay = newest.day + return newest + } + + /** Runs after every Health Connect import, and once when the toggle is switched on. */ + suspend fun syncFromRepository(context: Context, repo: WhoopRepository): WeightReading? { + val profile = ProfileStore.from(context) + // Checked again inside apply(); this early return only skips the full-table read when OFF. + if (!profile.useHealthConnectWeight) return null + return apply(profile, repo.appleDaily(HealthConnectImporter.HC_DEVICE, "0000-01-01", "9999-12-31")) + } + + internal fun weightReadings(rows: List): List = + rows.mapNotNull { row -> row.weightKg?.let { WeightReading(row.day, it) } } +} 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 6f8df9a8b0..5329b464e7 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -126,6 +126,7 @@ import com.noop.analytics.Baselines import com.noop.analytics.DayCycleMode import com.noop.analytics.HrZoneSet import com.noop.analytics.HrZones +import com.noop.analytics.ProfileWeightSync import com.noop.analytics.UserProfile import com.noop.analytics.Zones import com.noop.R @@ -225,6 +226,27 @@ class ProfileStore(private val prefs: SharedPreferences) { get() = prefs.getFloat(KEY_WEIGHT, 75f).toDouble().coerceIn(WEIGHT_MIN, WEIGHT_MAX) set(v) = prefs.edit().putFloat(KEY_WEIGHT, v.coerceIn(WEIGHT_MIN, WEIGHT_MAX).toFloat()).apply() + /** + * Opt-in "Use weight from Health Connect", OFF by default. When ON, every Health Connect import + * copies the newest Health Connect weight into [weightKg] ([HealthConnectWeightSync]) and Settings + * locks the weight stepper. Android only: Apple platforms have no Health Connect. Deliberately NOT + * in the `.noopbak` whitelist, like the other install-specific toggles: a restored device needs its + * own Health Connect grant before this means anything, and there is no Swift twin to keep equal. + */ + var useHealthConnectWeight: Boolean + get() = prefs.getBoolean(KEY_USE_HC_WEIGHT, false) + set(v) = prefs.edit().putBoolean(KEY_USE_HC_WEIGHT, v).apply() + + /** + * ISO day of the Health Connect reading last copied into [weightKg]; null before the first sync. + * Provenance for the Settings caption only, never a value input. Not backed up, as above. + */ + var healthConnectWeightDay: String? + get() = prefs.getString(KEY_HC_WEIGHT_DAY, null) + set(v) = prefs.edit().apply { + if (v == null) remove(KEY_HC_WEIGHT_DAY) else putString(KEY_HC_WEIGHT_DAY, v) + }.apply() + var heightCm: Double get() = prefs.getFloat(KEY_HEIGHT, 178f).toDouble().coerceIn(HEIGHT_MIN, HEIGHT_MAX) set(v) = prefs.edit().putFloat(KEY_HEIGHT, v.coerceIn(HEIGHT_MIN, HEIGHT_MAX).toFloat()).apply() @@ -415,7 +437,7 @@ class ProfileStore(private val prefs: SharedPreferences) { } companion object { - private const val PREFS = "noop_profile" + internal const val PREFS = "noop_profile" /** Date of birth as epoch millis — the #146 source of truth for [age]. */ private const val KEY_DOB = "date_of_birth" /** Pre-#146 age key, now kept mirrored from the DOB so the `.noopbak` whitelist (Int age) @@ -423,6 +445,10 @@ class ProfileStore(private val prefs: SharedPreferences) { private const val KEY_AGE = "age" private const val KEY_SEX = "sex" private const val KEY_WEIGHT = "weight_kg" + private const val KEY_USE_HC_WEIGHT = "use_health_connect_weight" + private const val KEY_HC_WEIGHT_DAY = "health_connect_weight_day" + /** Keys a background Health Connect import may rewrite while Settings is open. */ + internal val HEALTH_CONNECT_WEIGHT_KEYS = setOf(KEY_WEIGHT, KEY_USE_HC_WEIGHT, KEY_HC_WEIGHT_DAY) private const val KEY_HEIGHT = "height_cm" private const val KEY_WAIST = "waist_cm" private const val KEY_HRMAX = "hr_max_override" @@ -543,6 +569,19 @@ fun SettingsScreen( onDispose { expPrefs.unregisterOnSharedPreferenceChangeListener(listener) } } + // "Use weight from Health Connect": the importer rewrites the profile weight after every sync, and + // the periodic auto-sync can land while this screen is open. Same mechanism as above, so the weight + // row and its "From Health Connect" caption follow the write instead of waiting for a re-entry. + DisposableEffect(Unit) { + val profilePrefs = context.getSharedPreferences(ProfileStore.PREFS, Context.MODE_PRIVATE) + // Held strongly for the effect's lifetime, for the same weak-listener reason as above. + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == null || key in ProfileStore.HEALTH_CONNECT_WEIGHT_KEYS) rev++ + } + profilePrefs.registerOnSharedPreferenceChangeListener(listener) + onDispose { profilePrefs.unregisterOnSharedPreferenceChangeListener(listener) } + } + var backupBusy by remember { mutableStateOf(false) } /** * #1807: a restore refused ONLY for size, held with the uri that produced it so confirming can @@ -983,6 +1022,9 @@ fun SettingsScreen( ) } SettingsRowDivider() + // With "Use weight from Health Connect" ON the synced reading owns the value, so the + // stepper is locked rather than letting a manual edit be overwritten on the next sync. + val weightFromHc = profile.useHealthConnectWeight SettingsFormRow(label = uiString(R.string.l10n_settings_screen_weight_69c0b815)) { // Imperial mode steps in whole pounds and stores the kg equivalent; metric steps in // 0.5 kg. The profile is always SI — only the entry unit changes. @@ -992,6 +1034,7 @@ fun SettingsScreen( value = "%.0f".format(lb), unit = "lb", accessibility = "Weight, ${lb.roundToInt()} pounds", + enabled = !weightFromHc, onMinus = { mutate { profile.weightKg = (lb - 1) / UnitFormatter.POUNDS_PER_KILOGRAM } }, onPlus = { mutate { profile.weightKg = (lb + 1) / UnitFormatter.POUNDS_PER_KILOGRAM } }, ) @@ -1000,11 +1043,39 @@ fun SettingsScreen( value = "%.1f".format(profile.weightKg), unit = "kg", accessibility = "Weight in kilograms", + enabled = !weightFromHc, onMinus = { mutate { profile.weightKg -= 0.5 } }, onPlus = { mutate { profile.weightKg += 0.5 } }, ) } } + if (weightFromHc) { + val syncedDay = profile.healthConnectWeightDay + Text( + text = if (syncedDay != null) { + uiString( + R.string.l10n_settings_screen_weight_from_health_connect_caption_5ca6ea7d, + ProfileWeightSync.captionDate(syncedDay), + ) + } else { + uiString(R.string.l10n_settings_screen_weight_from_health_connect_none_dd3f4884) + }, + style = NoopType.caption, + color = Palette.textTertiary, + ) + } + SettingsRowDivider() + SettingsToggleRow( + title = uiString(R.string.l10n_settings_screen_use_weight_from_health_connect_c1fe3e2a), + detail = uiString(R.string.l10n_settings_screen_use_weight_from_health_connect_detail_40238f8a), + checked = weightFromHc, + onCheckedChange = { on -> + mutate { profile.useHealthConnectWeight = on } + // Apply the stored Health Connect weight now instead of waiting for the next + // import; the same write the post-import hook does. + if (on) scope.launch { HealthConnectWeightSync.syncFromRepository(context, vm.repo); mutate {} } + }, + ) SettingsRowDivider() SettingsFormRow(label = uiString(R.string.l10n_settings_screen_height_3f608b49)) { // Imperial mode steps in whole inches and stores the cm equivalent; metric steps in cm. diff --git a/android/app/src/main/java/com/noop/ui/TodayMetricsLogic.kt b/android/app/src/main/java/com/noop/ui/TodayMetricsLogic.kt index 1c21cc4466..599c4ad95c 100644 --- a/android/app/src/main/java/com/noop/ui/TodayMetricsLogic.kt +++ b/android/app/src/main/java/com/noop/ui/TodayMetricsLogic.kt @@ -2,6 +2,7 @@ package com.noop.ui import androidx.annotation.StringRes import com.noop.R +import com.noop.analytics.ProfileWeightSync import com.noop.data.AppleDaily import com.noop.data.WorkoutRow @@ -69,12 +70,19 @@ internal data class WeightTileText(val value: String, val caption: WeightCaption * The newest body weight across the two Apple-side sources (apple-health + health-connect), or null * when neither carries one. Days are ISO `yyyy-MM-dd`, which sorts chronologically, so the lexically * greatest day with a non-null `weightKg` is the most recent, no date parsing needed. (#107) + * + * With "Use weight from Health Connect" ON, [healthConnectOnly] drops the Apple Health rows: the profile + * then holds the newest Health Connect weight, and a newer Apple Health file must not let the tile name + * a different weight than the one the analytics use. Both go through [ProfileWeightSync.newest]. */ -internal fun latestWeightKg(apple: List, healthConnect: List): Double? = - (apple + healthConnect) - .filter { it.weightKg != null } - .maxByOrNull { it.day } - ?.weightKg +internal fun latestWeightKg( + apple: List, + healthConnect: List, + healthConnectOnly: Boolean = false, +): Double? { + val rows = if (healthConnectOnly) healthConnect else apple + healthConnect + return ProfileWeightSync.newest(HealthConnectWeightSync.weightReadings(rows))?.kg +} /** * Steps for [dayKey] from the imported Apple Health / Health Connect daily aggregates, or null when diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index 7ad64b17d6..9607dd7619 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -863,11 +863,14 @@ fun TodayScreen( // The newest Apple Health / Health Connect body weight, loaded off the main thread. Null until the // load runs or when neither source carries a weight, the Weight tile then falls back to the profile. + // With "Use weight from Health Connect" ON it reads Health Connect only, the source the profile holds. var weightKg by remember { mutableStateOf(null) } - LaunchedEffect(days) { + val weightFromHealthConnect = ProfileStore.from(context).useHealthConnectWeight + LaunchedEffect(days, weightFromHealthConnect) { weightKg = latestWeightKg( viewModel.repo.appleDaily("apple-health", "0000-01-01", "9999-12-31"), viewModel.repo.appleDaily("health-connect", "0000-01-01", "9999-12-31"), + healthConnectOnly = weightFromHealthConnect, ) } diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 86a55a8c10..df276e52b8 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -1619,6 +1619,10 @@ Version %1$s verfügbar Taille (optional) Gewicht + Gewicht aus Health Connect verwenden + Nach jeder Synchronisierung übernimmt dein Profilgewicht die neueste Messung aus Health Connect. Ausschalten, um es selbst einzustellen. + Aus Health Connect · %1$s + Aus Health Connect · noch kein Gewicht Was jedes Band lesen kann und warum sich einige Merkmale unterscheiden. Was ist neu Was ist neu in NOOP %1$s diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 79c5b6344c..f825e62309 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -1419,6 +1419,10 @@ Versión %1$s está disponible Cintura (opcional) Peso + Usar el peso de Health Connect + Tras cada sincronización, el peso de tu perfil toma la lectura más reciente de Health Connect. Desactívalo para ajustarlo a mano. + De Health Connect · %1$s + De Health Connect · aún sin peso Lo que cada correa puede leer, y por qué algunas características difieren. Novedades Lo nuevo en NOOP %1$s diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index cf94b827a0..8f0ef31f84 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -1437,6 +1437,10 @@ La version %1$s est disponible Taille (facultatif) Poids + Utiliser le poids de Health Connect + Après chaque synchronisation, le poids de votre profil reprend la mesure Health Connect la plus récente. Désactivez pour le régler vous-même. + Depuis Health Connect · %1$s + Depuis Health Connect · aucun poids pour l\'instant Ce que chaque bracelet peut lire, et pourquoi certaines caractéristiques diffèrent. Nouveautés Nouveautés de NOOP %1$s diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml index 9ce736af1f..5b9afb5529 100644 --- a/android/app/src/main/res/values-it/strings.xml +++ b/android/app/src/main/res/values-it/strings.xml @@ -1693,6 +1693,10 @@ È disponibile la versione %1$s Vita (facoltativo) Peso + Usa il peso da Health Connect + Dopo ogni sincronizzazione, il peso del profilo prende l\'ultima lettura di Health Connect. Disattiva per impostarlo a mano. + Da Health Connect · %1$s + Da Health Connect · ancora nessun peso Cosa può leggere ogni fascia e perché alcune funzioni sono diverse. Novità Novità in NOOP %1$s diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 6e09e809ba..c1511697fc 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -1576,6 +1576,10 @@ Dostępna jest wersja %1$s Talia (opcjonalnie) Waga + Używaj wagi z Health Connect + Po każdej synchronizacji waga w Twoim profilu przyjmuje najnowszy odczyt z Health Connect. Wyłącz, aby ustawiać ją ręcznie. + Z Health Connect · %1$s + Z Health Connect · brak wagi Co każdy pasek może odczytać i dlaczego niektóre funkcje się różnią. Co nowego Co nowego w NOOP %1$s 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 b8e8e2a3fa..3fa2753aa0 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -1582,6 +1582,10 @@ A versão %1$s está disponível Cintura (opcional) Peso + Usar o peso do Health Connect + Após cada sincronização, o peso do teu perfil passa a ser a leitura mais recente do Health Connect. Desativa para o definires à mão. + Do Health Connect · %1$s + Do Health Connect · ainda sem peso O que cada bracelete pode ler e porque é que algumas características são diferentes. O que há de novo O que há de novo no NOOP %1$s diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index f1f627de8c..31bba6665b 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -1638,6 +1638,10 @@ Доступна версия %1$s Талия (необязательно) Вес + Брать вес из Health Connect + После каждой синхронизации вес в вашем профиле берётся из последнего измерения в Health Connect. Выключите, чтобы задавать его вручную. + Из Health Connect · %1$s + Из Health Connect · веса пока нет Что может читать каждый браслет и почему некоторые функции различаются. Что нового Что нового в NOOP %1$s diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 4d47722994..7aabe08ed1 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -1558,6 +1558,10 @@ 发现新版本:%1$s 已发布 腰围 (可选填) 体重 + 使用 Health Connect 中的体重 + 每次同步后,个人资料中的体重会采用 Health Connect 的最新读数。关闭后可手动设置。 + 来自 Health Connect · %1$s + 来自 Health Connect · 暂无体重 各个型号手环能读取哪些数据,以及为什么有些功能会因设备而异。 新版特性 NOOP %1$s 更新日志 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ad39398a6e..1f783441e2 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1724,6 +1724,10 @@ Version %1$s is available Waist (optional) Weight + Use weight from Health Connect + After every sync, your profile weight takes the newest Health Connect reading. Turn off to set it by hand. + From Health Connect · %1$s + From Health Connect · no weight yet What each strap can read, and why some features differ. What\'s new What\'s new in NOOP %1$s diff --git a/android/app/src/test/java/com/noop/analytics/ProfileWeightSyncTest.kt b/android/app/src/test/java/com/noop/analytics/ProfileWeightSyncTest.kt new file mode 100644 index 0000000000..cf9712dafa --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/ProfileWeightSyncTest.kt @@ -0,0 +1,38 @@ +package com.noop.analytics + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.Locale + +/** The resolver both the profile weight sync and the Today Weight tile read. */ +class ProfileWeightSyncTest { + + @Test + fun newest_picksTheLatestDay_notTheLastListed() { + val readings = listOf( + WeightReading("2026-09-20", 73.0), + WeightReading("2026-09-25", 72.4), + WeightReading("2026-09-22", 72.9), + ) + assertEquals(WeightReading("2026-09-25", 72.4), ProfileWeightSync.newest(readings)) + } + + @Test + fun newest_nullWhenThereIsNoReading() { + assertNull(ProfileWeightSync.newest(emptyList())) + } + + @Test + fun captionDate_followsTheGivenLocale() { + // The regression guard for the Locale.US / hardcoded-English pattern: an Italian UI must not + // read an English month. + assertEquals("25 set", ProfileWeightSync.captionDate("2026-09-25", Locale.ITALIAN)) + assertEquals("25 Sep", ProfileWeightSync.captionDate("2026-09-25", Locale.US)) + } + + @Test + fun captionDate_unparseableDayIsReturnedVerbatim() { + assertEquals("not-a-day", ProfileWeightSync.captionDate("not-a-day", Locale.US)) + } +} diff --git a/android/app/src/test/java/com/noop/ui/HealthConnectWeightSyncTest.kt b/android/app/src/test/java/com/noop/ui/HealthConnectWeightSyncTest.kt new file mode 100644 index 0000000000..e84b9b5899 --- /dev/null +++ b/android/app/src/test/java/com/noop/ui/HealthConnectWeightSyncTest.kt @@ -0,0 +1,107 @@ +package com.noop.ui + +import android.content.SharedPreferences +import com.noop.analytics.WeightReading +import com.noop.data.AppleDaily +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * "Use weight from Health Connect": the profile write behind it. No Robolectric (junit only), the real + * [ProfileStore] over an in-memory [FakeSharedPreferences]. + */ +class HealthConnectWeightSyncTest { + + private fun hcDay(day: String, kg: Double?) = AppleDaily(deviceId = "health-connect", day = day, weightKg = kg) + + @Test + fun toggleDefaultsOff_andTheSyncIsANoOp() { + val profile = ProfileStore(FakeSharedPreferences()) + profile.weightKg = 80.0 + assertFalse(profile.useHealthConnectWeight) + assertNull(HealthConnectWeightSync.apply(profile, listOf(hcDay("2026-09-25", 72.4)))) + assertEquals(80.0, profile.weightKg, 1e-4) + assertNull(profile.healthConnectWeightDay) + } + + @Test + fun toggleOn_writesTheNewestHealthConnectWeightAndItsDay() { + val profile = ProfileStore(FakeSharedPreferences()) + profile.weightKg = 80.0 + profile.useHealthConnectWeight = true + val rows = listOf(hcDay("2026-09-20", 73.0), hcDay("2026-09-25", 72.4), hcDay("2026-09-26", null)) + assertEquals(WeightReading("2026-09-25", 72.4), HealthConnectWeightSync.apply(profile, rows)) + assertEquals(72.4, profile.weightKg, 1e-4) + assertEquals("2026-09-25", profile.healthConnectWeightDay) + } + + @Test + fun toggleOn_withoutAnyHealthConnectWeight_leavesTheProfileUntouched() { + val profile = ProfileStore(FakeSharedPreferences()) + profile.weightKg = 80.0 + profile.useHealthConnectWeight = true + assertNull(HealthConnectWeightSync.apply(profile, listOf(hcDay("2026-09-25", null)))) + assertEquals(80.0, profile.weightKg, 1e-4) + assertNull(profile.healthConnectWeightDay) + } + + @Test + fun toggleOff_keepsTheLastSyncedWeightForManualEditing() { + val profile = ProfileStore(FakeSharedPreferences()) + profile.useHealthConnectWeight = true + HealthConnectWeightSync.apply(profile, listOf(hcDay("2026-09-25", 72.4))) + profile.useHealthConnectWeight = false + assertNull(HealthConnectWeightSync.apply(profile, listOf(hcDay("2026-09-27", 71.0)))) + assertEquals(72.4, profile.weightKg, 1e-4) + } + + @Test + fun theNewKeysStayOutOfTheBackup() { + val profile = ProfileStore(FakeSharedPreferences()) + profile.useHealthConnectWeight = true + HealthConnectWeightSync.apply(profile, listOf(hcDay("2026-09-25", 72.4))) + val snapshot = profile.backupSnapshot() + assertEquals(72.4, snapshot["profile.weightKg"] as Double, 1e-4) + assertFalse(snapshot.keys.any { it.contains("health", ignoreCase = true) }) + } + + // In-memory SharedPreferences reproducing the read/write contract ProfileStore relies on. + private class FakeSharedPreferences : SharedPreferences { + private val map = HashMap() + override fun getInt(key: String, defValue: Int): Int = map[key] as? Int ?: defValue + override fun getLong(key: String, defValue: Long): Long = map[key] as? Long ?: defValue + override fun getFloat(key: String, defValue: Float): Float = map[key] as? Float ?: defValue + override fun getBoolean(key: String, defValue: Boolean): Boolean = map[key] as? Boolean ?: defValue + override fun getString(key: String, defValue: String?): String? = map[key] as? String ?: defValue + @Suppress("UNCHECKED_CAST") + override fun getStringSet(key: String, defValues: MutableSet?): MutableSet? = + map[key] as? MutableSet ?: defValues + override fun getAll(): MutableMap = HashMap(map) + override fun contains(key: String): Boolean = map.containsKey(key) + override fun registerOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + override fun unregisterOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + override fun edit(): SharedPreferences.Editor = FakeEditor() + + private inner class FakeEditor : SharedPreferences.Editor { + private val pending = HashMap() + private val removed = HashSet() + override fun putString(key: String, value: String?): SharedPreferences.Editor { pending[key] = value; return this } + override fun putStringSet(key: String, values: MutableSet?): SharedPreferences.Editor { pending[key] = values; return this } + override fun putInt(key: String, value: Int): SharedPreferences.Editor { pending[key] = value; return this } + override fun putLong(key: String, value: Long): SharedPreferences.Editor { pending[key] = value; return this } + override fun putFloat(key: String, value: Float): SharedPreferences.Editor { pending[key] = value; return this } + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor { pending[key] = value; return this } + override fun remove(key: String): SharedPreferences.Editor { removed.add(key); return this } + override fun clear(): SharedPreferences.Editor { map.clear(); return this } + override fun commit(): Boolean { flush(); return true } + override fun apply() { flush() } + private fun flush() { + for (k in removed) map.remove(k) + map.putAll(pending) + pending.clear(); removed.clear() + } + } + } +} diff --git a/android/app/src/test/java/com/noop/ui/TodayMetricTilesTest.kt b/android/app/src/test/java/com/noop/ui/TodayMetricTilesTest.kt index 30a859c462..14a88c9d11 100644 --- a/android/app/src/test/java/com/noop/ui/TodayMetricTilesTest.kt +++ b/android/app/src/test/java/com/noop/ui/TodayMetricTilesTest.kt @@ -60,6 +60,22 @@ class TodayMetricTilesTest { assertEquals(77.0, latestWeightKg(apple, healthConnect)!!, 1e-9) } + @Test + fun latestWeight_healthConnectOnly_ignoresANewerAppleHealthReading() { + // "Use weight from Health Connect" ON: the profile holds the newest HC weight, so a newer Apple + // Health file must not make the tile name a different weight than the analytics use. + val apple = listOf(appleDay("2026-01-09", 90.0)) + val healthConnect = listOf(AppleDaily(deviceId = "health-connect", day = "2026-01-06", weightKg = 77.0)) + assertEquals(77.0, latestWeightKg(apple, healthConnect, healthConnectOnly = true)!!, 1e-9) + assertEquals(90.0, latestWeightKg(apple, healthConnect)!!, 1e-9) + } + + @Test + fun latestWeight_healthConnectOnly_nullWithoutAHealthConnectWeight() { + val apple = listOf(appleDay("2026-01-09", 90.0)) + assertNull(latestWeightKg(apple, emptyList(), healthConnectOnly = true)) + } + // MARK: weightTile @Test From 5da5060a558a55c59d93a121165a3e628a649153 Mon Sep 17 00:00:00 2001 From: paradix86 <2883806+paradix86@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:36:18 +0000 Subject: [PATCH 2/2] parity: dispose ProfileWeightSync as Android-only; note day granularity ProfileWeightSync.kt sits under the governed com/noop/analytics root, so the new file and its newest/captionDate functions entered the parity inventory one-sided. Record all three as platform_specific (Apple platforms have no Health Connect, so there is no Swift counterpart) and refresh the derived twin-map authority. Also document that ProfileWeightSync.newest resolves by day: two readings on the same day are separated only by input order. --- Tools/parity_dispositions.json | 24 +++++++++++++++++++ Tools/parity_twin_map.json | 8 +++---- .../com/noop/analytics/ProfileWeightSync.kt | 5 ++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 76c82ab661..fab146f350 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -267,6 +267,30 @@ "platform": "kotlin", "rationale": "This Kotlin extension dispatches a computed window to repository persistence. The corresponding persistComputedScores dispatch is inline in the excluded Swift app-layer analyzeRecent; this records its architectural role.", "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "platform_specific", + "kind": "add-unpaired-file", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/ProfileWeightSync.kt", + "identity_sha256": "66dd123a53a3c929025a41accf93278dad5acc505c0a731791e09818421ff838", + "platform": "kotlin", + "rationale": "Resolver file for the Android-only 'Use weight from Health Connect' profile sync. Apple platforms have no Health Connect, so the opt-in 'Use weight from Health Connect' profile sync exists only on Android and there is no Swift counterpart to pair with, now or later." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/ProfileWeightSync.kt::newest/1#1", + "identity_sha256": "64f21dac4e072f7dbe397260765ac71a0b9998556eede445c6ae7284b0363088", + "platform": "kotlin", + "rationale": "Picks the newest imported Health Connect weight for both the profile sync and the Today Weight tile. Apple platforms have no Health Connect, so the opt-in 'Use weight from Health Connect' profile sync exists only on Android and there is no Swift counterpart to pair with, now or later." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/ProfileWeightSync.kt::captionDate/2#1", + "identity_sha256": "28fa51ec52e1a418a0c8bd500cceb13c529cf8dc7e8bbe290003fe97602e6dff", + "platform": "kotlin", + "rationale": "Formats the reading's day for the Settings caption of the Health Connect weight sync. Apple platforms have no Health Connect, so the opt-in 'Use weight from Health Connect' profile sync exists only on Android and there is no Swift counterpart to pair with, now or later." } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 37807516a9..0ba15a08b5 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -18,16 +18,16 @@ ] }, "authority": { - "files": {"count": 507, "sha256": "efc6950759de76273adccde8b6457f5e8577bdaf6ed9c6538cde79b0eaf0c612"}, - "functions": {"count": 4518, "sha256": "f424a4e32aceb96b06b2dc790e858bb55b2548fb326c1be1cfb85c6308d013aa"}, + "files": {"count": 508, "sha256": "1524b35bf2905f5541a3557cd1b078278ae8c5f03975eef0d0a78bf8f4f4d1b5"}, + "functions": {"count": 4520, "sha256": "2928c6ad9b6f534259de836c9f2cc240c8435c68465fcd7e2f7bf23a569af2fb"}, "properties": {"count": 488, "sha256": "77d60fe0f6218252233b9e60da8bf5135968e49be3c6d2027d0973ea20ddf792"}, "constants": {"count": 1985, "sha256": "0c67e7c378db2c0518339f073b37967e30cec0d5b586c1408841ca5e7977f9b4"}, "file_pairs": {"count": 79, "sha256": "02ee127080cf87c98925a60b30b6e87d25e7a82ef6cf43b6251fe24c20600dd1"}, "function_pairs": {"count": 217, "sha256": "c31664b757a428dcadbf56068d92c681397dc2f378e5e6d6ea65be6e70c3a381"}, "property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"}, "constant_pairs": {"count": 694, "sha256": "ae11bdde04d49f9dfe83170f0aa362e6bf18d3fdc381c1bdf5bbc256caa400ab"}, - "unpaired_files": {"count": 371, "sha256": "d9e682eadcdd5b1279a69a45799fd68b4589f98c83cfb7e15d3016ad890a98c0"}, - "unpaired_functions": {"count": 4097, "sha256": "e54745434cdd990e42445672ec7ae137bfe1111def4f701948fa1ae7398e5871"}, + "unpaired_files": {"count": 372, "sha256": "37aa705af1be255d3d18adfe912b93bcfeaa3760e1513412aad81388a4c1410b"}, + "unpaired_functions": {"count": 4099, "sha256": "0c2152bbd8664256ef65f2e76676395bf3fb72d9c003be3cc4189e20270195ef"}, "unpaired_properties": {"count": 162, "sha256": "dabff97ba9bce0aebdc8ef84b9c4f107ce574020e8b0455d8f9214082948246b"}, "unpaired_constants": {"count": 597, "sha256": "578c607cc05b1cd1f867c206c9fab70237ac40707cd49d4e0c5cc66b400184c7"} } diff --git a/android/app/src/main/java/com/noop/analytics/ProfileWeightSync.kt b/android/app/src/main/java/com/noop/analytics/ProfileWeightSync.kt index fdecafebdf..ed4b3b2677 100644 --- a/android/app/src/main/java/com/noop/analytics/ProfileWeightSync.kt +++ b/android/app/src/main/java/com/noop/analytics/ProfileWeightSync.kt @@ -20,6 +20,11 @@ object ProfileWeightSync { /** * The newest reading, or null when there is none. Days are ISO `yyyy-MM-dd`, which sorts * chronologically, so the lexically greatest day is the most recent and no date parsing is needed. + * + * Resolution is day-granular, not timestamp-granular: two readings on the same day (a morning and + * an evening weigh-in) are separated only by input order, since `maxByOrNull` keeps the first + * maximal element, so the pick between them is not necessarily the later one. Anything that needs + * the latest weigh-in within a day must carry the reading's time and resolve on that instead. */ fun newest(readings: List): WeightReading? = readings.maxByOrNull { it.day }