mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
feat: build My Fitness from the whole training log, not just Health Connect
My Fitness was a Health Connect viewer: the dashboard was empty without the permission, and it did not count workouts the user had logged in Amethyst's own composer. The app asked you to record a workout and then ignored it. The log is now built from both sources Amethyst can learn from — Health Connect sessions, and the user's own published kind 1301 events (their manual entries, and anything posted from another NIP-101e client). Neither source alone is the truth: Health Connect cannot see a workout typed by hand or one posted elsewhere, and cannot look past 30 days; the published events carry none of the device detail. The consequence that matters: My Fitness now works with no health permissions at all. Health Connect became an enhancement rather than a gate — a banner over a working dashboard instead of a wall in front of an empty one. That is also the honest form of the Play declaration's argument, which the previous build could not make: Health Connect enriches a tracking feature that exists on its own rather than being that feature. Where a workout appears in both — the usual case once someone shares one their watch recorded — the Health Connect copy wins: it keeps the metrics the post dropped and a recorded start time rather than a publish timestamp. Matching is on activity type plus a 15-minute start window, wide because a published event often carries only the minute the user typed. Matching on time alone would swallow a genuine second session of a different activity, so the type must agree. A published workout with no start tag falls back to created_at. That is the publish time, not the workout time, which is close enough for the day-level buckets (streaks, active days, the week split) and is the only signal a manual entry has. 17 new tests: the dedupe boundary in both directions, same-time-different- activity, ordering, and the mapper's unit conversions (miles and feet land as metres — otherwise every total is out by 1.6x) plus its zero-is-absent rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019egdJyBHnrATZHjs86up8f
This commit is contained in:
@@ -36,6 +36,8 @@ Amethyst's **My Fitness** screen (Workouts → the chart icon) summarises your o
|
||||
|
||||
This is what the health permissions are for. The summary is computed on your phone and shown to you; nothing is sent anywhere to produce it, and you never have to post anything to use it.
|
||||
|
||||
My Fitness also counts the workouts you have logged in Amethyst itself, so it works whether or not you connect Health Connect. Connecting adds the sessions your watch recorded and the details a hand-typed workout does not carry — heart rate, steps and climb.
|
||||
|
||||
Separately, you may choose to publish one workout as a Nostr post (a NIP-101e kind 1301 event) so the people who follow you can see it. That takes a deliberate tap on "Share this workout", shows you the pre-filled post, and waits for you to confirm. It is never automatic.
|
||||
|
||||
The feature is optional and off until you grant the permissions. Amethyst asks for them only when you open My Fitness or the New Workout composer — never on first launch.
|
||||
|
||||
+16
-1
@@ -23,8 +23,18 @@ package com.vitorpamplona.amethyst.service.workouts.health
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
|
||||
|
||||
/** Where a workout in the training log came from. */
|
||||
enum class WorkoutOrigin {
|
||||
/** Recorded by a watch or fitness app and read from Health Connect. */
|
||||
HEALTH_CONNECT,
|
||||
|
||||
/** A kind 1301 the user published themselves — typed into Amethyst, or posted from another client. */
|
||||
PUBLISHED,
|
||||
}
|
||||
|
||||
/**
|
||||
* A finished workout read from Health Connect and mapped to the fields Amethyst
|
||||
* A finished workout in the user's training log: read from Health Connect, or
|
||||
* recovered from a kind 1301 they published. Carries the fields Amethyst
|
||||
* can publish as a NIP-101e kind 1301 event. Platform-neutral and free of any
|
||||
* Health Connect types so it can feed the navigation route and the suggestion
|
||||
* UI directly.
|
||||
@@ -49,6 +59,11 @@ data class DetectedWorkout(
|
||||
val elevationGainMeters: Double?,
|
||||
/** Human-readable name of the app/device that wrote the record (e.g. "Samsung Health"). */
|
||||
val source: String,
|
||||
/**
|
||||
* Which store this came from. Defaults to Health Connect because that is where the type
|
||||
* originated and where every constructor but the published-event mapper still builds from.
|
||||
*/
|
||||
val origin: WorkoutOrigin = WorkoutOrigin.HEALTH_CONNECT,
|
||||
/**
|
||||
* How many Health Connect sessions this workout represents. 1 for a raw
|
||||
* session; higher when [WorkoutMerger] combined several close-by same-type
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.workouts.health
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* The user's training log: everything they have done, from both places Amethyst can learn about
|
||||
* it.
|
||||
*
|
||||
* Health Connect knows what their watch recorded. It does not know about a workout they typed
|
||||
* into Amethyst by hand, or one they posted from another NIP-101e client, and it cannot see
|
||||
* further back than 30 days. Their own kind 1301 events know all of those and none of the
|
||||
* device detail. Neither source alone is the truth, so the dashboard reads both.
|
||||
*
|
||||
* The practical consequence is that My Fitness works with no health permissions at all — a user
|
||||
* who never connects Health Connect still gets a summary of what they have logged.
|
||||
*/
|
||||
object TrainingLog {
|
||||
/**
|
||||
* How far apart two records of the same activity can start and still be judged the same
|
||||
* workout. Generous on purpose: a published event often carries only the minute the user
|
||||
* typed, or the time the post went out, while Health Connect has the exact second.
|
||||
*/
|
||||
const val DEDUPE_TOLERANCE_SECONDS = 900L
|
||||
|
||||
/**
|
||||
* Combines both sources into one log, newest first.
|
||||
*
|
||||
* Where the same workout appears in both — the usual case once a user shares one that came
|
||||
* from their watch — the Health Connect copy wins: it carries the metrics the published
|
||||
* event may have dropped (heart rate, steps, climb), and its start time is the recorded one
|
||||
* rather than a publish timestamp.
|
||||
*/
|
||||
fun merge(
|
||||
healthConnect: List<DetectedWorkout>,
|
||||
published: List<DetectedWorkout>,
|
||||
): List<DetectedWorkout> {
|
||||
val deduped =
|
||||
published.filterNot { candidate ->
|
||||
healthConnect.any { it.isProbablySameWorkoutAs(candidate) }
|
||||
}
|
||||
|
||||
return (healthConnect + deduped).sortedByDescending { it.startTimeEpochSeconds }
|
||||
}
|
||||
|
||||
private fun DetectedWorkout.isProbablySameWorkoutAs(other: DetectedWorkout): Boolean =
|
||||
exercise == other.exercise &&
|
||||
abs(startTimeEpochSeconds - other.startTimeEpochSeconds) <= DEDUPE_TOLERANCE_SECONDS
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a published workout back into the training log, or null when it carries nothing a
|
||||
* summary can use — no recognisable activity, or no duration.
|
||||
*
|
||||
* Start time prefers the explicit `start` tag and falls back to the event's own `created_at`.
|
||||
* That fallback is the publish time, not the workout time: a manual entry has no better signal,
|
||||
* and for day-level bucketing (streaks, active days, the week split) it is close enough, since
|
||||
* people post a workout the same day they do it.
|
||||
*/
|
||||
fun WorkoutRecordEvent.toDetectedWorkout(): DetectedWorkout? {
|
||||
val activity = activityType() ?: return null
|
||||
|
||||
val duration = durationSeconds() ?: return null
|
||||
if (duration <= 0) return null
|
||||
|
||||
return DetectedWorkout(
|
||||
id = id,
|
||||
exercise = activity,
|
||||
title = title()?.takeIf { it.isNotBlank() },
|
||||
startTimeEpochSeconds = workoutStartTime() ?: workoutStart() ?: createdAt,
|
||||
durationSeconds = duration,
|
||||
distanceMeters = distance()?.toMeters()?.takeIf { it > 0 },
|
||||
calories = calories()?.takeIf { it > 0 },
|
||||
avgHeartRate = avgHeartRate()?.takeIf { it > 0 },
|
||||
maxHeartRate = maxHeartRate()?.takeIf { it > 0 },
|
||||
steps = steps()?.takeIf { it > 0 },
|
||||
elevationGainMeters = elevationGain()?.toMeters()?.takeIf { it > 0 },
|
||||
source = workoutSource() ?: "",
|
||||
origin = WorkoutOrigin.PUBLISHED,
|
||||
)
|
||||
}
|
||||
+58
-24
@@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.commons.resources.my_fitness_best_most_steps
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_bests
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_by_activity
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_calories
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_connect_banner
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_connect_button
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_connect_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_connect_title
|
||||
@@ -82,7 +83,6 @@ import com.vitorpamplona.amethyst.commons.resources.my_fitness_streak
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_this_week
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_time
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_unavailable
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_unit_bpm
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_unit_kcal
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_vs_last_week
|
||||
@@ -120,6 +120,10 @@ fun MyFitnessScreen(
|
||||
val context = LocalContext.current
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// Which account's workouts to summarise. Re-runs on an account switch, which resets the
|
||||
// dashboard to Loading rather than showing the previous user's numbers.
|
||||
viewModel.init(accountViewModel.userProfile().pubkeyHex)
|
||||
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(PermissionController.createRequestPermissionResultContract()) {
|
||||
viewModel.refresh(context)
|
||||
@@ -139,34 +143,34 @@ fun MyFitnessScreen(
|
||||
when (val current = state) {
|
||||
MyFitnessViewModel.State.Loading -> CenteredBox { CircularProgressIndicator() }
|
||||
|
||||
MyFitnessViewModel.State.Unavailable ->
|
||||
CenteredBox {
|
||||
Text(
|
||||
text = stringRes(Res.string.my_fitness_unavailable),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
)
|
||||
}
|
||||
|
||||
MyFitnessViewModel.State.NeedsPermission ->
|
||||
ConnectPrompt(
|
||||
onDetails = { context.startActivity(Intent(context, HealthConnectRationaleActivity::class.java)) },
|
||||
onConnect = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) },
|
||||
)
|
||||
|
||||
is MyFitnessViewModel.State.Ready ->
|
||||
if (current.report.isEmpty) {
|
||||
CenteredBox {
|
||||
Text(
|
||||
text = stringRes(Res.string.my_fitness_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
// Nothing logged yet. Offering Health Connect is the useful thing to do
|
||||
// when it could fill the screen; otherwise just say the log is empty.
|
||||
if (current.healthConnect == MyFitnessViewModel.HealthConnectStatus.AVAILABLE) {
|
||||
ConnectPrompt(
|
||||
onDetails = { context.startActivity(Intent(context, HealthConnectRationaleActivity::class.java)) },
|
||||
onConnect = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) },
|
||||
)
|
||||
} else {
|
||||
CenteredBox {
|
||||
Text(
|
||||
text = stringRes(Res.string.my_fitness_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Dashboard(current.report) { workout, label ->
|
||||
Dashboard(
|
||||
report = current.report,
|
||||
// Only offered when it would actually add something: a device with no
|
||||
// provider gets no banner to act on.
|
||||
showConnectBanner = current.healthConnect == MyFitnessViewModel.HealthConnectStatus.AVAILABLE,
|
||||
onDetails = { context.startActivity(Intent(context, HealthConnectRationaleActivity::class.java)) },
|
||||
onConnect = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) },
|
||||
) { workout, label ->
|
||||
nav.nav(workout.toNewWorkoutRoute(label))
|
||||
}
|
||||
}
|
||||
@@ -183,6 +187,32 @@ private fun CenteredBox(content: @Composable () -> Unit) {
|
||||
) { content() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown above a dashboard that is already working, when Health Connect could add device-recorded
|
||||
* workouts to it. Deliberately a strip rather than a blocking card: the summary below it is real,
|
||||
* and this only offers to make it richer.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConnectBanner(
|
||||
onDetails: () -> Unit,
|
||||
onConnect: () -> Unit,
|
||||
) {
|
||||
OutlinedCard(shape = RoundedCornerShape(14.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(stringRes(Res.string.my_fitness_connect_title), style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
text = stringRes(Res.string.my_fitness_connect_banner),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
||||
TextButton(onClick = onDetails) { Text(stringRes(Res.string.workout_suggestion_connect_details)) }
|
||||
TextButton(onClick = onConnect) { Text(stringRes(Res.string.my_fitness_connect_button)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectPrompt(
|
||||
onDetails: () -> Unit,
|
||||
@@ -219,6 +249,9 @@ private fun ConnectPrompt(
|
||||
@Composable
|
||||
private fun Dashboard(
|
||||
report: WorkoutStats.Report,
|
||||
showConnectBanner: Boolean,
|
||||
onDetails: () -> Unit,
|
||||
onConnect: () -> Unit,
|
||||
onShare: (DetectedWorkout, String) -> Unit,
|
||||
) {
|
||||
val miles = remember { prefersMiles() }
|
||||
@@ -227,6 +260,7 @@ private fun Dashboard(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
if (showConnectBanner) ConnectBanner(onDetails = onDetails, onConnect = onConnect)
|
||||
ThisWeekCard(report, miles)
|
||||
ConsistencyRow(report)
|
||||
WindowTotalsCard(report, miles)
|
||||
|
||||
+88
-26
@@ -25,38 +25,61 @@ import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout
|
||||
import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager
|
||||
import com.vitorpamplona.amethyst.service.workouts.health.TrainingLog
|
||||
import com.vitorpamplona.amethyst.service.workouts.health.WorkoutStats
|
||||
import com.vitorpamplona.amethyst.service.workouts.health.toDetectedWorkout
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* State holder for the My Fitness dashboard: the user's own training over the last
|
||||
* [WorkoutStats.WINDOW_DAYS], read from Health Connect and summarised by [WorkoutStats].
|
||||
* [WorkoutStats.WINDOW_DAYS], summarised by [WorkoutStats].
|
||||
*
|
||||
* Nothing here publishes or touches the network. The dashboard is the user looking at their
|
||||
* own numbers; sharing one of them is a separate, deliberate action from the workout list.
|
||||
* The log is built from both sources Amethyst has — Health Connect, and the user's own published
|
||||
* kind 1301 events (see [TrainingLog]) — so the dashboard is useful before any health permission
|
||||
* is granted and stays useful if one is revoked. Health Connect adds device detail; it is not a
|
||||
* precondition.
|
||||
*
|
||||
* Nothing here publishes or touches the network. The dashboard is the user looking at their own
|
||||
* numbers; sharing one is a separate, deliberate action from the workout list.
|
||||
*/
|
||||
@Stable
|
||||
class MyFitnessViewModel : ViewModel() {
|
||||
/** Whether the richer Health Connect source is switched on, and whether it even could be. */
|
||||
enum class HealthConnectStatus {
|
||||
/** Granted and contributing to the log. */
|
||||
CONNECTED,
|
||||
|
||||
/** A provider is installed but Amethyst has no permissions — worth offering. */
|
||||
AVAILABLE,
|
||||
|
||||
/** No provider on this device; there is nothing to offer. */
|
||||
UNAVAILABLE,
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed interface State {
|
||||
/** First load, or a reload after a permission change. */
|
||||
data object Loading : State
|
||||
|
||||
/** No Health Connect provider on this device — nothing to offer. */
|
||||
data object Unavailable : State
|
||||
|
||||
/** Provider present, permissions not granted yet. */
|
||||
data object NeedsPermission : State
|
||||
|
||||
/** Granted and summarised. [WorkoutStats.Report.isEmpty] covers "nothing recorded yet". */
|
||||
/**
|
||||
* Summarised. [WorkoutStats.Report.isEmpty] covers "nothing logged yet", which is a
|
||||
* normal state rather than an error — a new user has published nothing and may not have
|
||||
* connected Health Connect.
|
||||
*/
|
||||
data class Ready(
|
||||
val report: WorkoutStats.Report,
|
||||
val healthConnect: HealthConnectStatus,
|
||||
) : State
|
||||
}
|
||||
|
||||
@@ -65,28 +88,67 @@ class MyFitnessViewModel : ViewModel() {
|
||||
|
||||
private var manager: HealthConnectManager? = null
|
||||
|
||||
/** The pubkey whose workouts this dashboard summarises. Set by the screen before refreshing. */
|
||||
private var pubkeyHex: String? = null
|
||||
|
||||
fun init(pubkeyHex: String) {
|
||||
if (this.pubkeyHex != pubkeyHex) {
|
||||
this.pubkeyHex = pubkeyHex
|
||||
_state.value = State.Loading
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the dashboard. Safe to call on every resume: it re-checks permissions first,
|
||||
* so revoking access in Health Connect drops the screen back to its prompt rather than
|
||||
* Rebuilds the dashboard. Safe to call on every resume: it re-checks permissions as well as
|
||||
* data, so revoking access in Health Connect drops those workouts out of the log rather than
|
||||
* leaving stale numbers on display.
|
||||
*/
|
||||
fun refresh(context: Context) {
|
||||
viewModelScope.launch {
|
||||
if (!HealthConnectManager.isAvailable(context)) {
|
||||
_state.value = State.Unavailable
|
||||
return@launch
|
||||
}
|
||||
|
||||
val hc = manager ?: HealthConnectManager(context.applicationContext).also { manager = it }
|
||||
|
||||
if (!hc.hasAllPermissions()) {
|
||||
_state.value = State.NeedsPermission
|
||||
return@launch
|
||||
}
|
||||
|
||||
val now = Instant.now()
|
||||
val workouts = hc.readWorkouts(now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)), now)
|
||||
_state.value = State.Ready(WorkoutStats.report(workouts, now))
|
||||
val since = now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS))
|
||||
|
||||
val status = healthConnectStatus(context)
|
||||
val fromHealthConnect =
|
||||
if (status == HealthConnectStatus.CONNECTED) {
|
||||
manager?.readWorkouts(since, now).orEmpty()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val fromRelays = publishedWorkouts(since.epochSecond)
|
||||
|
||||
_state.value =
|
||||
State.Ready(
|
||||
report = WorkoutStats.report(TrainingLog.merge(fromHealthConnect, fromRelays), now),
|
||||
healthConnect = status,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun healthConnectStatus(context: Context): HealthConnectStatus {
|
||||
if (!HealthConnectManager.isAvailable(context)) return HealthConnectStatus.UNAVAILABLE
|
||||
|
||||
val hc = manager ?: HealthConnectManager(context.applicationContext).also { manager = it }
|
||||
return if (hc.hasAllPermissions()) HealthConnectStatus.CONNECTED else HealthConnectStatus.AVAILABLE
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's own kind 1301 events from the local cache, newer than [sinceEpochSeconds].
|
||||
*
|
||||
* Scans off the main thread: LocalCache holds every event the session has seen, and this
|
||||
* walks all of them.
|
||||
*/
|
||||
private suspend fun publishedWorkouts(sinceEpochSeconds: Long): List<DetectedWorkout> {
|
||||
val mine = pubkeyHex ?: return emptyList()
|
||||
|
||||
return withContext(Dispatchers.Default) {
|
||||
LocalCache.notes
|
||||
.filterIntoSet { _, note ->
|
||||
val event = note.event
|
||||
event is WorkoutRecordEvent && event.pubKey == mine
|
||||
}.mapNotNull { (it.event as WorkoutRecordEvent).toDetectedWorkout() }
|
||||
.filter { it.startTimeEpochSeconds >= sinceEpochSeconds }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.workouts.health
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.AvgHeartRateTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.CaloriesTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DistanceTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DurationTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ElevationGainTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.StepsTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutStartTimeTag
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class PublishedWorkoutMappingTest {
|
||||
private val createdAt = 1_800_000_000L
|
||||
|
||||
private fun event(vararg tags: Array<String>) =
|
||||
WorkoutRecordEvent(
|
||||
id = "eventid",
|
||||
pubKey = "pubkey",
|
||||
createdAt = createdAt,
|
||||
tags = arrayOf(*tags),
|
||||
content = "",
|
||||
sig = "sig",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a full workout round-trips into the training log`() {
|
||||
val workout =
|
||||
event(
|
||||
ExerciseTag.assemble(ExerciseType.RUNNING),
|
||||
TitleTag.assemble("Morning Run"),
|
||||
DurationTag.assemble(1800),
|
||||
DistanceTag.assemble(5.0, DistanceTag.KILOMETERS),
|
||||
CaloriesTag.assemble(380),
|
||||
AvgHeartRateTag.assemble(150),
|
||||
StepsTag.assemble(6000),
|
||||
WorkoutStartTimeTag.assemble(createdAt - 3600),
|
||||
).toDetectedWorkout()
|
||||
|
||||
assertNotNull(workout)
|
||||
assertEquals(ExerciseType.RUNNING, workout!!.exercise)
|
||||
assertEquals("Morning Run", workout.title)
|
||||
assertEquals(1800, workout.durationSeconds)
|
||||
assertEquals(5000.0, workout.distanceMeters!!, 0.001)
|
||||
assertEquals(380, workout.calories)
|
||||
assertEquals(150, workout.avgHeartRate)
|
||||
assertEquals(6000, workout.steps)
|
||||
assertEquals(createdAt - 3600, workout.startTimeEpochSeconds)
|
||||
assertEquals(WorkoutOrigin.PUBLISHED, workout.origin)
|
||||
}
|
||||
|
||||
/** Miles on the wire must land as metres in the log, or every total is wrong by 1.6x. */
|
||||
@Test
|
||||
fun `an imperial distance is converted to metres`() {
|
||||
val workout =
|
||||
event(
|
||||
ExerciseTag.assemble(ExerciseType.RUNNING),
|
||||
DurationTag.assemble(1800),
|
||||
DistanceTag.assemble(3.0, DistanceTag.MILES),
|
||||
).toDetectedWorkout()
|
||||
|
||||
assertEquals(4828.032, workout!!.distanceMeters!!, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an elevation in feet is converted to metres`() {
|
||||
val workout =
|
||||
event(
|
||||
ExerciseTag.assemble(ExerciseType.HIKING),
|
||||
DurationTag.assemble(3600),
|
||||
ElevationGainTag.assemble(1000.0, "ft"),
|
||||
).toDetectedWorkout()
|
||||
|
||||
assertEquals(304.8, workout!!.elevationGainMeters!!, 0.01)
|
||||
}
|
||||
|
||||
/**
|
||||
* A workout typed in by hand carries no start time. Falling back to created_at keeps it in
|
||||
* the right day bucket for streaks and the week split.
|
||||
*/
|
||||
@Test
|
||||
fun `a manual entry without a start time falls back to the publish time`() {
|
||||
val workout =
|
||||
event(
|
||||
ExerciseTag.assemble(ExerciseType.YOGA),
|
||||
DurationTag.assemble(600),
|
||||
).toDetectedWorkout()
|
||||
|
||||
assertEquals(createdAt, workout!!.startTimeEpochSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event with no recognisable activity is skipped`() {
|
||||
assertNull(event(DurationTag.assemble(1800)).toDetectedWorkout())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event with no duration is skipped`() {
|
||||
assertNull(event(ExerciseTag.assemble(ExerciseType.RUNNING)).toDetectedWorkout())
|
||||
}
|
||||
|
||||
/** Zeroes on the wire are absences, not measurements — they must not drag totals or bests. */
|
||||
@Test
|
||||
fun `zero metrics are treated as absent`() {
|
||||
val workout =
|
||||
event(
|
||||
ExerciseTag.assemble(ExerciseType.STRENGTH),
|
||||
DurationTag.assemble(2400),
|
||||
CaloriesTag.assemble(0),
|
||||
StepsTag.assemble(0),
|
||||
AvgHeartRateTag.assemble(0),
|
||||
).toDetectedWorkout()
|
||||
|
||||
assertNull(workout!!.calories)
|
||||
assertNull(workout.steps)
|
||||
assertNull(workout.avgHeartRate)
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.workouts.health
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TrainingLogTest {
|
||||
private val noon = 1_800_000_000L
|
||||
|
||||
private fun workout(
|
||||
id: String,
|
||||
origin: WorkoutOrigin,
|
||||
startTimeEpochSeconds: Long,
|
||||
exercise: ExerciseType = ExerciseType.RUNNING,
|
||||
distanceMeters: Double? = null,
|
||||
avgHeartRate: Int? = null,
|
||||
) = DetectedWorkout(
|
||||
id = id,
|
||||
exercise = exercise,
|
||||
title = null,
|
||||
startTimeEpochSeconds = startTimeEpochSeconds,
|
||||
durationSeconds = 1800,
|
||||
distanceMeters = distanceMeters,
|
||||
calories = null,
|
||||
avgHeartRate = avgHeartRate,
|
||||
maxHeartRate = null,
|
||||
steps = null,
|
||||
elevationGainMeters = null,
|
||||
source = "test",
|
||||
origin = origin,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `with no health connect the log is just what the user published`() {
|
||||
val published = listOf(workout("a", WorkoutOrigin.PUBLISHED, noon))
|
||||
|
||||
val merged = TrainingLog.merge(emptyList(), published)
|
||||
|
||||
assertEquals(1, merged.size)
|
||||
assertEquals("a", merged[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with no published workouts the log is just health connect`() {
|
||||
val hc = listOf(workout("a", WorkoutOrigin.HEALTH_CONNECT, noon))
|
||||
|
||||
assertEquals(1, TrainingLog.merge(hc, emptyList()).size)
|
||||
}
|
||||
|
||||
/** The common case: the user shared a workout their watch recorded. It must not count twice. */
|
||||
@Test
|
||||
fun `a published copy of a health connect workout is dropped`() {
|
||||
val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon, avgHeartRate = 150))
|
||||
val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon + 60))
|
||||
|
||||
val merged = TrainingLog.merge(hc, published)
|
||||
|
||||
assertEquals(1, merged.size)
|
||||
assertEquals("hc", merged[0].id)
|
||||
// The Health Connect copy wins because it carries the metrics the post dropped.
|
||||
assertEquals(150, merged[0].avgHeartRate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a published workout just outside the tolerance is kept as its own`() {
|
||||
val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon))
|
||||
val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon + TrainingLog.DEDUPE_TOLERANCE_SECONDS + 1))
|
||||
|
||||
assertEquals(2, TrainingLog.merge(hc, published).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the tolerance boundary itself counts as the same workout`() {
|
||||
val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon))
|
||||
val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon + TrainingLog.DEDUPE_TOLERANCE_SECONDS))
|
||||
|
||||
assertEquals(1, TrainingLog.merge(hc, published).size)
|
||||
}
|
||||
|
||||
/** Deduping on time alone would swallow a genuine second session. */
|
||||
@Test
|
||||
fun `a different activity at the same time is not a duplicate`() {
|
||||
val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon, exercise = ExerciseType.RUNNING))
|
||||
val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon, exercise = ExerciseType.STRENGTH))
|
||||
|
||||
assertEquals(2, TrainingLog.merge(hc, published).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dedupe also applies when the published copy is slightly earlier`() {
|
||||
val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon))
|
||||
val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon - 300))
|
||||
|
||||
assertEquals(1, TrainingLog.merge(hc, published).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the merged log is ordered newest first`() {
|
||||
val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon - 10_000))
|
||||
val published =
|
||||
listOf(
|
||||
workout("older", WorkoutOrigin.PUBLISHED, noon - 50_000),
|
||||
workout("newest", WorkoutOrigin.PUBLISHED, noon),
|
||||
)
|
||||
|
||||
val merged = TrainingLog.merge(hc, published)
|
||||
|
||||
assertEquals(listOf("newest", "hc", "older"), merged.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one health connect workout does not swallow several unrelated published ones`() {
|
||||
val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon))
|
||||
val published =
|
||||
listOf(
|
||||
workout("dup", WorkoutOrigin.PUBLISHED, noon + 30),
|
||||
workout("yesterday", WorkoutOrigin.PUBLISHED, noon - 86_400),
|
||||
workout("tomorrow", WorkoutOrigin.PUBLISHED, noon + 86_400),
|
||||
)
|
||||
|
||||
val merged = TrainingLog.merge(hc, published)
|
||||
|
||||
assertEquals(3, merged.size)
|
||||
assertTrue(merged.none { it.id == "dup" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty log merges to empty`() {
|
||||
assertTrue(TrainingLog.merge(emptyList(), emptyList()).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -708,10 +708,11 @@
|
||||
<string name="my_fitness_best_most_steps">Most steps</string>
|
||||
<string name="my_fitness_best_highest_heart_rate">Highest heart rate</string>
|
||||
<string name="my_fitness_recent">Recent workouts</string>
|
||||
<string name="my_fitness_empty">No workouts recorded in the last 4 weeks. Once your watch or fitness app saves one, it shows up here.</string>
|
||||
<string name="my_fitness_empty">Nothing logged in the last 4 weeks. Workouts you post, and any your watch records, show up here.</string>
|
||||
<string name="my_fitness_unavailable">Health Connect is not available on this device, so Amethyst has no workouts to summarise.</string>
|
||||
<string name="my_fitness_connect_title">Track your training</string>
|
||||
<string name="my_fitness_connect_message">Connect Health Connect to see your weekly totals, week-over-week trend, best efforts and training streak. Everything stays on your phone.</string>
|
||||
<string name="my_fitness_connect_banner">Connect Health Connect to add the workouts your watch records, with heart rate, steps and climb.</string>
|
||||
<string name="my_fitness_connect_button">Connect</string>
|
||||
<string name="my_fitness_share">Share this workout</string>
|
||||
<string name="my_fitness_unit_bpm">bpm</string>
|
||||
|
||||
@@ -24,9 +24,18 @@ publishes anything. Publishing a workout is now one optional action on a row of
|
||||
app, the test to apply is: every data type listed is rendered back to the user as their own
|
||||
statistic, on a screen that has no posting requirement.
|
||||
|
||||
**The dashboard does not depend on Health Connect.** It builds the training log from two sources:
|
||||
Health Connect, and the user's own published kind 1301 workout events. A user who never grants a
|
||||
health permission still gets the full summary of everything they have logged — Health Connect adds
|
||||
the device detail (heart rate, steps, climb) and the workouts they never posted. This matters for
|
||||
the declaration's honesty: Health Connect *enriches* a tracking feature that exists on its own
|
||||
rather than *being* the feature.
|
||||
|
||||
Related code:
|
||||
|
||||
- `service/workouts/health/WorkoutStats.kt` — all dashboard arithmetic; pure, unit-tested.
|
||||
- `service/workouts/health/TrainingLog.kt` — merges Health Connect with the user's own published
|
||||
workouts, deduping the ones that appear in both.
|
||||
- `ui/screen/loggedIn/workouts/fitness/MyFitnessScreen.kt` — the dashboard.
|
||||
- `service/workouts/health/HealthConnectManager.kt` — the only place the app touches Health Connect.
|
||||
- `ui/screen/loggedIn/workouts/health/HealthConnectRationaleActivity.kt` — the in-app rationale screen.
|
||||
@@ -39,14 +48,18 @@ Related code:
|
||||
> Amethyst is a social client for Nostr, an open decentralized social protocol. It also includes
|
||||
> a fitness feature, **My Fitness**, which is what uses Health Connect.
|
||||
>
|
||||
> My Fitness is a personal training dashboard. It reads the workouts the user's watch or fitness
|
||||
> app has already saved to Health Connect and turns them into a picture of how that person is
|
||||
> My Fitness is a personal training dashboard. It builds a log of the user's own workouts — from
|
||||
> the workouts their watch or fitness app has saved to Health Connect, and from the workout
|
||||
> records they have logged in Amethyst itself — and turns it into a picture of how that person is
|
||||
> training: how much they did this week and whether that is up or down on last week, how their
|
||||
> time splits across running, cycling, walking, swimming and the gym, their best efforts, how
|
||||
> many days they trained, and their current streak of consecutive active days.
|
||||
>
|
||||
> The purpose is to help the user track, monitor, analyze and improve their own physical
|
||||
> fitness. The numbers are shown to the person who recorded them. No part of the dashboard
|
||||
> fitness. Health Connect is not a precondition for it: the dashboard works from the user's own
|
||||
> logged workouts alone, and Health Connect is what lets it also count the sessions their watch
|
||||
> recorded and show the device metrics — heart rate, steps and elevation — that a hand-entered
|
||||
> workout does not carry. The numbers are shown to the person who recorded them. No part of the dashboard
|
||||
> requires posting anything, and nothing is transmitted anywhere to produce it — the summary is
|
||||
> computed on the device from Health Connect data and displayed.
|
||||
>
|
||||
@@ -62,7 +75,10 @@ Related code:
|
||||
> 2. Open the navigation drawer (hamburger, top-left), and under **Feeds** tap **Workouts**.
|
||||
> 3. In the top bar, tap the **chart icon** to open **My Fitness**.
|
||||
> 4. The screen explains what will be read and offers **What Amethyst reads** (the full rationale
|
||||
> screen) and **Connect**. Tap Connect and grant the permissions.
|
||||
> screen) and **Connect**. Tap Connect and grant the permissions. (If the account has already
|
||||
> logged workouts in Amethyst, the dashboard is already populated from those and the Health
|
||||
> Connect offer appears as a banner above it instead — the feature does not gate on the
|
||||
> permission.)
|
||||
> 5. The dashboard appears: "This week" totals with the change against last week; the streak,
|
||||
> active-days and workout-count tiles; the four-week weekly average; the per-activity
|
||||
> breakdown; best efforts; and the recent-workout list.
|
||||
|
||||
Reference in New Issue
Block a user