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 new file mode 100644 index 0000000000..ed4b3b2677 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/ProfileWeightSync.kt @@ -0,0 +1,39 @@ +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. + * + * 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 } + + /** + * "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