Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Tools/parity_dispositions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
}
8 changes: 4 additions & 4 deletions Tools/parity_twin_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
}
Expand Down
39 changes: 39 additions & 0 deletions android/app/src/main/java/com/noop/analytics/ProfileWeightSync.kt
Original file line number Diff line number Diff line change
@@ -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>): 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)
}
4 changes: 3 additions & 1 deletion android/app/src/main/java/com/noop/data/BackupSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Kind> = linkedMapOf(
"profile.age" to Kind.INT,
Expand Down
15 changes: 13 additions & 2 deletions android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 11 additions & 5 deletions android/app/src/main/java/com/noop/ui/Components.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
) {
Expand Down
43 changes: 43 additions & 0 deletions android/app/src/main/java/com/noop/ui/HealthConnectWeightSync.kt
Original file line number Diff line number Diff line change
@@ -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<AppleDaily>): 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<AppleDaily>): List<WeightReading> =
rows.mapNotNull { row -> row.weightKg?.let { WeightReading(row.day, it) } }
}
Loading
Loading