mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #4129 from vitorpamplona/claude/focused-gates-w9dtcw
feat: My Fitness — a personal training dashboard, to bring Health Connect inside an approved use case
This commit is contained in:
+20
-14
@@ -3,7 +3,7 @@
|
||||
**App:** Amethyst (Android Nostr client)<br>
|
||||
**Publisher:** Vitor Pamplona<br>
|
||||
**Contact:** amethyst@vitorpamplona.com<br>
|
||||
**Last updated:** 2026-09-12
|
||||
**Last updated:** 2026-09-15
|
||||
|
||||
Amethyst is free, open-source software (MIT License — see `LICENSE`). It is not a service. There is no Amethyst server, no Amethyst account, and the developer has no access to data stored on your device.
|
||||
|
||||
@@ -32,33 +32,39 @@ Configuration, cached events, keys, drafts, and other operational data live in t
|
||||
|
||||
### Health and fitness data (Health Connect)
|
||||
|
||||
Amethyst's **Workouts** section lets you publish a summary of a finished workout to the Nostr relays you choose (a NIP-101e kind 1301 event), so the people who follow you can see it. To save you typing the numbers in by hand, Amethyst can read the workout your watch or fitness app already saved to **Android Health Connect** and pre-fill the post.
|
||||
Amethyst's **My Fitness** screen (drawer → You → My Fitness) summarises your own training for you: how much you did this week against last week, how your time splits across activities, your best efforts, how many days you trained, and your current streak. It builds that picture from the workouts your watch or fitness app has already saved to **Android Health Connect**.
|
||||
|
||||
The feature is optional and off until you grant the permissions. Amethyst asks for them only when you open the New Workout composer — never on first launch.
|
||||
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.
|
||||
|
||||
**What Amethyst reads, and what each type is for:**
|
||||
|
||||
| Health Connect data type | Permission | What it is used for |
|
||||
| --- | --- | --- |
|
||||
| ExerciseSession | `READ_EXERCISE` | The workout itself: activity type, start time and duration — the title, date and duration of the post. |
|
||||
| Distance | `READ_DISTANCE` | The distance of the run, ride, walk or swim. |
|
||||
| ActiveCaloriesBurned | `READ_ACTIVE_CALORIES_BURNED` | The energy the workout burned. |
|
||||
| TotalCaloriesBurned | `READ_TOTAL_CALORIES_BURNED` | Fallback energy figure for sources that only record total energy. |
|
||||
| HeartRate | `READ_HEART_RATE` | Average and maximum heart rate over the workout — how hard the effort was. |
|
||||
| Steps | `READ_STEPS` | The step count of a run, walk or hike. |
|
||||
| ElevationGained | `READ_ELEVATION_GAINED` | How much you climbed. |
|
||||
| ExerciseSession | `READ_EXERCISE` | The workout itself: activity type, start and end. Drives your workout count, training time, per-activity breakdown, active days and streak. |
|
||||
| Distance | `READ_DISTANCE` | Weekly distance, the change against last week, your weekly average, distance per activity, and your longest distance. |
|
||||
| ActiveCaloriesBurned | `READ_ACTIVE_CALORIES_BURNED` | Weekly energy burned and its week-over-week change. |
|
||||
| TotalCaloriesBurned | `READ_TOTAL_CALORIES_BURNED` | Fallback for the same figure, for watches and apps that only record total energy. |
|
||||
| HeartRate | `READ_HEART_RATE` | Average and maximum heart rate per workout, your duration-weighted average for the period, and your highest heart rate. |
|
||||
| Steps | `READ_STEPS` | Your weekly step average and your highest step count. |
|
||||
| ElevationGained | `READ_ELEVATION_GAINED` | Your weekly climb average and your biggest climb. |
|
||||
|
||||
Health Connect groups a few data types under one permission: `READ_EXERCISE` also covers CyclingPedalingCadence and `READ_STEPS` also covers StepsCadence. Amethyst does not read, store, or publish cadence — those types come attached to the permissions above and are never requested separately.
|
||||
|
||||
**Limits on this access:**
|
||||
|
||||
- **Read-only.** Amethyst never writes to Health Connect.
|
||||
- **Foreground only.** Reads happen only while the New Workout composer is on screen. Amethyst does not request `READ_HEALTH_DATA_IN_BACKGROUND` and has no background health worker.
|
||||
- **Last 7 days only.** Only sessions that finished in the previous 7 days are offered. Amethyst does not request `READ_HEALTH_DATA_HISTORY`.
|
||||
- **Foreground only.** Reads happen only while the My Fitness screen or the New Workout composer is on screen. Amethyst does not request `READ_HEALTH_DATA_IN_BACKGROUND` and has no background health worker.
|
||||
- **Last four weeks only.** Amethyst reads a rolling 28-day window and cannot see anything older. It does not request `READ_HEALTH_DATA_HISTORY`.
|
||||
- **No location.** Amethyst does not request `READ_EXERCISE_ROUTE`, so it never receives the GPS track of a workout.
|
||||
- **Nothing is uploaded automatically.** Health data stays on your device until you pick a suggestion, review the pre-filled post, and publish it yourself. The developer runs no server; a published post goes to the Nostr relays you configured, and those numbers then become public like any other post you make.
|
||||
- **Nothing is uploaded automatically.** Health data stays on your device. The My Fitness summary is computed locally and never transmitted. A workout only leaves your phone if you tap "Share this workout", review the pre-filled post, and publish it yourself — one workout at a time. The developer runs no server; a published post goes to the Nostr relays you configured, and those numbers then become public like any other post you make.
|
||||
- **No other use.** Health data is never used for advertising, analytics, profiling, or sale, and is never shared with third parties. It is not used to determine your eligibility for insurance, credit, or employment, and is not transferred to any such party.
|
||||
- **Revocable.** Turn the feature off under Settings → Compose → "Suggest workouts to share", or revoke the permissions in Health Connect at any time. Amethyst keeps the workout suggestions it has already shown only in memory; revoking access stops all reads immediately.
|
||||
- **Revocable.** Revoke the permissions in Health Connect at any time — My Fitness immediately drops back to its prompt — or turn the composer suggestions off under Settings → Compose Settings → "Suggest workouts to share". Amethyst keeps the summary and the suggestions only in memory; revoking access stops all reads immediately.
|
||||
|
||||
### What relays can see
|
||||
|
||||
|
||||
+5
-4
@@ -34,6 +34,7 @@ import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
|
||||
import androidx.health.connect.client.request.AggregateRequest
|
||||
import androidx.health.connect.client.request.ReadRecordsRequest
|
||||
import androidx.health.connect.client.time.TimeRangeFilter
|
||||
import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -128,12 +129,12 @@ class HealthConnectManager(
|
||||
* are skipped. Returns an empty list (never throws) if Health Connect is
|
||||
* unavailable or a read fails.
|
||||
*/
|
||||
suspend fun readNewWorkouts(
|
||||
suspend fun readWorkouts(
|
||||
since: Instant,
|
||||
now: Instant = Instant.now(),
|
||||
): List<DetectedWorkout> {
|
||||
if (!isAvailable(context)) {
|
||||
Log.i(TAG) { "readNewWorkouts: Health Connect unavailable (status=${HealthConnectClient.getSdkStatus(context)})" }
|
||||
Log.i(TAG) { "readWorkouts: Health Connect unavailable (status=${HealthConnectClient.getSdkStatus(context)})" }
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
@@ -150,12 +151,12 @@ class HealthConnectManager(
|
||||
timeRangeFilter = TimeRangeFilter.between(since, now),
|
||||
),
|
||||
)
|
||||
Log.i(TAG) { "readNewWorkouts: ${response.records.size} exercise session(s) in window $since .. $now" }
|
||||
Log.i(TAG) { "readWorkouts: ${response.records.size} exercise session(s) in window $since .. $now" }
|
||||
val mapped = response.records.mapNotNull { mapSession(it) }
|
||||
// Fold split-up sessions of the same activity (a long run broken around
|
||||
// breaks) into one suggestion so the composer offers the whole effort.
|
||||
val merged = WorkoutMerger.mergeCloseWorkouts(mapped)
|
||||
Log.i(TAG) { "readNewWorkouts: mapped ${mapped.size} -> ${merged.size} workout(s) after type/duration filtering and merging" }
|
||||
Log.i(TAG) { "readWorkouts: mapped ${mapped.size} -> ${merged.size} workout(s) after type/duration filtering and merging" }
|
||||
merged
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.amethyst.commons.fitness.DetectedWorkout
|
||||
import com.vitorpamplona.amethyst.commons.fitness.toDetectedWorkout
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* The workouts [pubkeyHex] has published, as a live view of the cache.
|
||||
*
|
||||
* Shared by the My Fitness dashboard, which counts them, and the New Workout carousel, which uses
|
||||
* them to avoid offering a workout the user already shared. Both need the same view of "what have
|
||||
* I already posted", and two answers to that question would mean the carousel offering something
|
||||
* the dashboard knows is a duplicate.
|
||||
*
|
||||
* [LocalCache.observeEvents] rather than a scan: it is indexed by kind and author, so it neither
|
||||
* walks every note in the cache nor needs re-running on a timer — the dashboard updates itself
|
||||
* when a relay delivers a workout, including the one the user just published.
|
||||
*
|
||||
* Reports only what the cache holds; it issues no REQ of its own.
|
||||
*/
|
||||
fun publishedWorkoutsOf(pubkeyHex: String): Flow<List<DetectedWorkout>> =
|
||||
LocalCache
|
||||
.observeEvents<WorkoutRecordEvent>(
|
||||
Filter(kinds = listOf(WorkoutRecordEvent.KIND), authors = listOf(pubkeyHex)),
|
||||
).map { events -> events.mapNotNull { it.toDetectedWorkout() } }
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.workouts.health
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.call_calling
|
||||
import com.vitorpamplona.amethyst.commons.resources.call_duration_hours_minutes_seconds
|
||||
import com.vitorpamplona.amethyst.commons.resources.call_duration_minutes_seconds
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
@@ -400,10 +402,28 @@ fun GroupCallNames(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elapsed call time as a clock: `05:23`, and `1:05:23` once the call passes an hour.
|
||||
*
|
||||
* Hours are not folded into the minutes field — the previous version rendered a 65-minute call
|
||||
* as "65:12".
|
||||
*
|
||||
* The pattern comes from a string resource rather than a literal so the separator and field
|
||||
* order stay the translator's, and so the numbers are formatted with the locale Compose is
|
||||
* configured with. `String.format` without an explicit locale follows the JVM-wide default,
|
||||
* which is not the same thing in an app that has its own language picker.
|
||||
*/
|
||||
@Composable
|
||||
fun formatDuration(seconds: Long): String {
|
||||
val mins = seconds / 60
|
||||
val hours = seconds / 3600
|
||||
val mins = (seconds % 3600) / 60
|
||||
val secs = seconds % 60
|
||||
return "%02d:%02d".format(mins, secs)
|
||||
|
||||
return if (hours > 0) {
|
||||
stringRes(Res.string.call_duration_hours_minutes_seconds, hours, mins, secs)
|
||||
} else {
|
||||
stringRes(Res.string.call_duration_minutes_seconds, mins, secs)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -316,6 +316,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.wizard.CashuWalletWi
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.WebBookmarksScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.NewWorkoutScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.fitness.MyFitnessScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
|
||||
import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
@@ -467,6 +468,7 @@ fun BuildNavigation(
|
||||
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Pictures> { PicturesScreen(accountViewModel, nav, it.attachments, it.message) }
|
||||
composableFromEnd<Route.Workouts> { WorkoutsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.MyFitness> { MyFitnessScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.GitRepositories> { GitRepositoriesScreen(accountViewModel, nav) }
|
||||
|
||||
composableFromEnd<Route.Highlights> { HighlightsScreen(accountViewModel, nav) }
|
||||
|
||||
+9
@@ -40,6 +40,7 @@ enum class NavBarItem {
|
||||
DISCOVER,
|
||||
NOTIFICATIONS,
|
||||
PROFILE,
|
||||
MY_FITNESS,
|
||||
MY_LISTS,
|
||||
BOOKMARKS,
|
||||
WEB_BOOKMARKS,
|
||||
@@ -255,6 +256,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
icon = MaterialSymbols.DirectionsRun,
|
||||
resolveRoute = { Route.Workouts },
|
||||
),
|
||||
NavBarItem.MY_FITNESS to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.MY_FITNESS,
|
||||
labelRes = R.string.my_fitness,
|
||||
icon = MaterialSymbols.AutoMirrored.ShowChart,
|
||||
resolveRoute = { Route.MyFitness },
|
||||
),
|
||||
NavBarItem.GIT_REPOSITORIES to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.GIT_REPOSITORIES,
|
||||
@@ -505,6 +513,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
MaterialSymbols.AccountCircle,
|
||||
listOf(
|
||||
NavBarItem.PROFILE,
|
||||
NavBarItem.MY_FITNESS,
|
||||
NavBarItem.MY_LISTS,
|
||||
NavBarItem.BOOKMARKS,
|
||||
NavBarItem.WEB_BOOKMARKS,
|
||||
|
||||
+1
@@ -107,6 +107,7 @@ private val DrawerNavigateItems: List<NavBarItem> =
|
||||
private val DrawerYouItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.PROFILE,
|
||||
NavBarItem.MY_FITNESS,
|
||||
NavBarItem.MY_LISTS,
|
||||
NavBarItem.BOOKMARKS,
|
||||
NavBarItem.WEB_BOOKMARKS,
|
||||
|
||||
@@ -116,6 +116,9 @@ sealed class Route {
|
||||
|
||||
@Serializable object Workouts : Route()
|
||||
|
||||
/** The signed-in user's own training summary, read from Health Connect. Not a feed. */
|
||||
@Serializable object MyFitness : Route()
|
||||
|
||||
@Serializable object GitRepositories : Route()
|
||||
|
||||
@Serializable object Highlights : Route()
|
||||
|
||||
@@ -337,6 +337,24 @@ class TopNavFilterState(
|
||||
)
|
||||
}
|
||||
|
||||
private val _workoutRoutes =
|
||||
combineTransform(
|
||||
livePeopleListsFlow,
|
||||
liveInterestFlows,
|
||||
) { peopleLists, interests ->
|
||||
checkNotInMainThread()
|
||||
emit(
|
||||
listOf(
|
||||
// Workout records can be narrowed by author, hashtag and geohash, so this
|
||||
// mirrors the kind3 catalog plus "Mine" — the user's own training.
|
||||
listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow),
|
||||
peopleLists,
|
||||
interests,
|
||||
listOf(muteListFollow),
|
||||
).flatten().toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
private val _highlightsRoutes =
|
||||
combineTransform(
|
||||
livePeopleListsFlow,
|
||||
@@ -488,6 +506,11 @@ class TopNavFilterState(
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow))
|
||||
|
||||
val workoutRoutes =
|
||||
_workoutRoutes
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow))
|
||||
|
||||
val highlightsRoutes =
|
||||
_highlightsRoutes
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
+4
@@ -117,6 +117,10 @@ private fun PreloadFor(
|
||||
// Favorite apps is a device-local launcher grid — nothing to preload from relays.
|
||||
NavBarItem.FAVORITE_APPS -> {}
|
||||
|
||||
// My Fitness summarises the user's own Health Connect data on the device. It issues no
|
||||
// REQs, so there is nothing to warm up from relays.
|
||||
NavBarItem.MY_FITNESS -> {}
|
||||
|
||||
NavBarItem.CALENDARS,
|
||||
NavBarItem.CALENDAR_COLLECTIONS,
|
||||
-> CalendarsFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ private fun WorkoutsTopNavFilterBar(
|
||||
accountViewModel: AccountViewModel,
|
||||
onChange: (FeedDefinition) -> Unit,
|
||||
) {
|
||||
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
|
||||
val allLists by followListsModel.workoutRoutes.collectAsStateWithLifecycle()
|
||||
|
||||
FeedFilterSpinner(
|
||||
placeholderCode = listName,
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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.ui.screen.loggedIn.workouts.fitness
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_duration_hours_minutes
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_duration_minutes
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_duration_seconds
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_unit_ft
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_unit_km
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_unit_m
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_unit_mi
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.phonePrefersMiles
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DistanceTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.Elevation
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* Display helpers for the My Fitness dashboard. The unit choice follows the phone's own
|
||||
* measurement preference, the same way the workout composer does, so a user never sees
|
||||
* kilometres on a screen where the rest of the app shows them miles.
|
||||
*/
|
||||
internal fun prefersMiles(): Boolean = phonePrefersMiles()
|
||||
|
||||
/**
|
||||
* `7h 12m` / `42m` / `45s` — a total, so hours run past 24 rather than wrapping.
|
||||
*
|
||||
* The unit suffixes come from string resources: "h"/"m"/"s" are English abbreviations, and the
|
||||
* order of the two parts is not universal either, so both belong to the translator rather than
|
||||
* to this function.
|
||||
*/
|
||||
@Composable
|
||||
internal fun formatDuration(totalSeconds: Long): String {
|
||||
if (totalSeconds <= 0) return stringRes(Res.string.my_fitness_duration_minutes, 0)
|
||||
|
||||
val hours = totalSeconds / 3600
|
||||
val minutes = (totalSeconds % 3600) / 60
|
||||
|
||||
return when {
|
||||
hours > 0 -> stringRes(Res.string.my_fitness_duration_hours_minutes, hours, minutes)
|
||||
minutes > 0 -> stringRes(Res.string.my_fitness_duration_minutes, minutes)
|
||||
else -> stringRes(Res.string.my_fitness_duration_seconds, totalSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
/** Distance in the user's unit, to one decimal — the bare number, paired with [distanceUnit]. */
|
||||
internal fun formatDistanceValue(
|
||||
meters: Double,
|
||||
miles: Boolean,
|
||||
): String {
|
||||
val value = if (miles) meters / DistanceTag.METERS_PER_MILE else meters / 1000.0
|
||||
return ((value * 10).roundToLong() / 10.0).toString()
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun distanceUnit(miles: Boolean): String = if (miles) stringRes(Res.string.my_fitness_unit_mi) else stringRes(Res.string.my_fitness_unit_km)
|
||||
|
||||
/** Climb reads better as a whole number of metres or feet than as a decimal. */
|
||||
internal fun formatElevationValue(
|
||||
meters: Double,
|
||||
miles: Boolean,
|
||||
): String {
|
||||
val value = if (miles) meters / Elevation.METERS_PER_FOOT else meters
|
||||
return value.roundToInt().toString()
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun elevationUnit(miles: Boolean): String = if (miles) stringRes(Res.string.my_fitness_unit_ft) else stringRes(Res.string.my_fitness_unit_m)
|
||||
+662
@@ -0,0 +1,662 @@
|
||||
/*
|
||||
* 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.ui.screen.loggedIn.workouts.fitness
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.health.connect.client.PermissionController
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout
|
||||
import com.vitorpamplona.amethyst.commons.fitness.WorkoutStats
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_active_days
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_avg_heart_rate
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_best_biggest_climb
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_best_highest_heart_rate
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_best_longest_distance
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_best_longest_duration
|
||||
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
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_distance
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_elevation
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_empty
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_max_heart_rate
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_recent
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_share
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_steps
|
||||
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_unit_bpm
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_unit_kcal
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_vs_last_week
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_weekly_average
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_window
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_window_note
|
||||
import com.vitorpamplona.amethyst.commons.resources.my_fitness_workouts
|
||||
import com.vitorpamplona.amethyst.commons.resources.workout_suggestion_connect_details
|
||||
import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.health.HealthConnectRationaleActivity
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.labelRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.suggestion.toNewWorkoutRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.symbol
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
/**
|
||||
* The user's own training, summarised: how much they did this week against last, what they
|
||||
* spent the time on, their best efforts, and how many days in a row they have shown up.
|
||||
*
|
||||
* This is the reason Amethyst reads Health Connect at all — the numbers are for the person who
|
||||
* recorded them. Publishing one as a note is an optional action from the workout list, never a
|
||||
* precondition for seeing any of this.
|
||||
*
|
||||
* Reached from the drawer, or from a bottom-bar slot the user pinned.
|
||||
*/
|
||||
@Composable
|
||||
fun MyFitnessScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val viewModel: MyFitnessViewModel = viewModel()
|
||||
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)
|
||||
}
|
||||
|
||||
// Re-checks permissions as well as data, so revoking access in Health Connect drops the
|
||||
// screen back to its prompt instead of leaving stale numbers up.
|
||||
LifecycleResumeEffect(Unit) {
|
||||
viewModel.refresh(context)
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
val openRationale = { context.startActivity(Intent(context, HealthConnectRationaleActivity::class.java)) }
|
||||
val requestPermissions = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) }
|
||||
|
||||
// DisappearingScaffold + AppBottomBar, like every other pinnable destination: My Fitness can
|
||||
// be pinned to the bottom bar, and a bare Scaffold would make that bar vanish on arrival.
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = { TopBarWithBackButton(stringRes(Res.string.my_fitness_title), nav) },
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.MyFitness, nav, accountViewModel) { route ->
|
||||
if (route != Route.MyFitness) nav.navBottomBar(route)
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) { padding ->
|
||||
Surface(modifier = Modifier.padding(padding)) {
|
||||
when (val current = state) {
|
||||
MyFitnessViewModel.State.Loading -> CenteredBox { CircularProgressIndicator() }
|
||||
|
||||
is MyFitnessViewModel.State.Ready ->
|
||||
if (current.report.isEmpty) {
|
||||
// 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 = openRationale, onConnect = requestPermissions)
|
||||
} 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(
|
||||
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 = openRationale,
|
||||
onConnect = requestPermissions,
|
||||
) { workout, label ->
|
||||
nav.nav(workout.toNewWorkoutRoute(label))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredBox(content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { 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,
|
||||
onConnect: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer, modifier = Modifier.size(56.dp)) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.DirectionsRun,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(30.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(stringRes(Res.string.my_fitness_connect_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = stringRes(Res.string.my_fitness_connect_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = onDetails) { Text(stringRes(Res.string.workout_suggestion_connect_details)) }
|
||||
Button(onClick = onConnect) { Text(stringRes(Res.string.my_fitness_connect_button)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Dashboard(
|
||||
report: WorkoutStats.Report,
|
||||
showConnectBanner: Boolean,
|
||||
onDetails: () -> Unit,
|
||||
onConnect: () -> Unit,
|
||||
onShare: (DetectedWorkout, String) -> Unit,
|
||||
) {
|
||||
val miles = remember { prefersMiles() }
|
||||
|
||||
Column(
|
||||
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)
|
||||
ActivityBreakdown(report, miles)
|
||||
BestEfforts(report, miles)
|
||||
RecentWorkouts(report, miles, onShare)
|
||||
|
||||
Text(
|
||||
text = stringRes(Res.string.my_fitness_window_note),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThisWeekCard(
|
||||
report: WorkoutStats.Report,
|
||||
miles: Boolean,
|
||||
) {
|
||||
SectionCard(stringRes(Res.string.my_fitness_this_week)) {
|
||||
// Workouts and time are always worth a cell. Distance and calories are not: a week of
|
||||
// strength work has neither, and a column of zeroes reads as missing data rather than
|
||||
// as "this activity doesn't have that metric". Last week still counts, so a drop to
|
||||
// zero keeps its cell and shows the fall.
|
||||
val cells =
|
||||
buildList {
|
||||
add(
|
||||
StatCell(
|
||||
stringRes(Res.string.my_fitness_workouts),
|
||||
report.thisWeek.workoutCount.toString(),
|
||||
null,
|
||||
WorkoutStats.percentChange(report.thisWeek.workoutCount.toDouble(), report.previousWeek.workoutCount.toDouble()),
|
||||
),
|
||||
)
|
||||
add(
|
||||
StatCell(
|
||||
stringRes(Res.string.my_fitness_time),
|
||||
formatDuration(report.thisWeek.durationSeconds),
|
||||
null,
|
||||
WorkoutStats.percentChange(report.thisWeek.durationSeconds.toDouble(), report.previousWeek.durationSeconds.toDouble()),
|
||||
),
|
||||
)
|
||||
if (report.thisWeek.distanceMeters > 0 || report.previousWeek.distanceMeters > 0) {
|
||||
add(
|
||||
StatCell(
|
||||
stringRes(Res.string.my_fitness_distance),
|
||||
formatDistanceValue(report.thisWeek.distanceMeters, miles),
|
||||
distanceUnit(miles),
|
||||
WorkoutStats.percentChange(report.thisWeek.distanceMeters, report.previousWeek.distanceMeters),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (report.thisWeek.calories > 0 || report.previousWeek.calories > 0) {
|
||||
add(
|
||||
StatCell(
|
||||
stringRes(Res.string.my_fitness_calories),
|
||||
report.thisWeek.calories.toString(),
|
||||
stringRes(Res.string.my_fitness_unit_kcal),
|
||||
WorkoutStats.percentChange(report.thisWeek.calories.toDouble(), report.previousWeek.calories.toDouble()),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
StatGrid(cells)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConsistencyRow(report: WorkoutStats.Report) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
HighlightTile(report.currentStreakDays.toString(), stringRes(Res.string.my_fitness_streak), Modifier.weight(1f))
|
||||
HighlightTile(report.activeDays.toString(), stringRes(Res.string.my_fitness_active_days), Modifier.weight(1f))
|
||||
HighlightTile(report.windowTotals.workoutCount.toString(), stringRes(Res.string.my_fitness_workouts), Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WindowTotalsCard(
|
||||
report: WorkoutStats.Report,
|
||||
miles: Boolean,
|
||||
) {
|
||||
SectionCard("${stringRes(Res.string.my_fitness_window)} · ${stringRes(Res.string.my_fitness_weekly_average)}") {
|
||||
val cells =
|
||||
buildList {
|
||||
add(StatCell(stringRes(Res.string.my_fitness_time), formatDuration(report.weeklyAverage.durationSeconds), null, null))
|
||||
if (report.windowTotals.distanceMeters > 0) {
|
||||
add(
|
||||
StatCell(
|
||||
stringRes(Res.string.my_fitness_distance),
|
||||
formatDistanceValue(report.weeklyAverage.distanceMeters, miles),
|
||||
distanceUnit(miles),
|
||||
null,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (report.windowTotals.steps > 0) {
|
||||
add(StatCell(stringRes(Res.string.my_fitness_steps), report.weeklyAverage.steps.toString(), null, null))
|
||||
}
|
||||
if (report.windowTotals.elevationGainMeters > 0) {
|
||||
add(
|
||||
StatCell(
|
||||
stringRes(Res.string.my_fitness_elevation),
|
||||
formatElevationValue(report.weeklyAverage.elevationGainMeters, miles),
|
||||
elevationUnit(miles),
|
||||
null,
|
||||
),
|
||||
)
|
||||
}
|
||||
report.windowTotals.avgHeartRate?.let {
|
||||
add(StatCell(stringRes(Res.string.my_fitness_avg_heart_rate), it.toString(), stringRes(Res.string.my_fitness_unit_bpm), null))
|
||||
}
|
||||
report.windowTotals.maxHeartRate?.let {
|
||||
add(StatCell(stringRes(Res.string.my_fitness_max_heart_rate), it.toString(), stringRes(Res.string.my_fitness_unit_bpm), null))
|
||||
}
|
||||
}
|
||||
StatGrid(cells)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActivityBreakdown(
|
||||
report: WorkoutStats.Report,
|
||||
miles: Boolean,
|
||||
) {
|
||||
if (report.byActivity.isEmpty()) return
|
||||
|
||||
SectionCard(stringRes(Res.string.my_fitness_by_activity)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
report.byActivity.forEach { activity ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Icon(
|
||||
symbol = activity.exercise.symbol(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(activity.exercise.labelRes()),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = activitySummary(activity, miles),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun activitySummary(
|
||||
activity: WorkoutStats.ActivityTotals,
|
||||
miles: Boolean,
|
||||
): String {
|
||||
val parts = mutableListOf<String>()
|
||||
parts.add("${activity.totals.workoutCount}×")
|
||||
parts.add(formatDuration(activity.totals.durationSeconds))
|
||||
if (activity.totals.distanceMeters > 0) {
|
||||
parts.add("${formatDistanceValue(activity.totals.distanceMeters, miles)} ${distanceUnit(miles)}")
|
||||
}
|
||||
return parts.joinToString(" · ")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BestEfforts(
|
||||
report: WorkoutStats.Report,
|
||||
miles: Boolean,
|
||||
) {
|
||||
if (report.bests.isEmpty()) return
|
||||
|
||||
SectionCard(stringRes(Res.string.my_fitness_bests)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
report.bests.forEach { best ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
text = bestLabel(best.kind),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = bestValue(best, miles),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun bestLabel(kind: WorkoutStats.BestKind): String =
|
||||
when (kind) {
|
||||
WorkoutStats.BestKind.LONGEST_DISTANCE -> stringRes(Res.string.my_fitness_best_longest_distance)
|
||||
WorkoutStats.BestKind.LONGEST_DURATION -> stringRes(Res.string.my_fitness_best_longest_duration)
|
||||
WorkoutStats.BestKind.BIGGEST_CLIMB -> stringRes(Res.string.my_fitness_best_biggest_climb)
|
||||
WorkoutStats.BestKind.MOST_STEPS -> stringRes(Res.string.my_fitness_best_most_steps)
|
||||
WorkoutStats.BestKind.HIGHEST_HEART_RATE -> stringRes(Res.string.my_fitness_best_highest_heart_rate)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun bestValue(
|
||||
best: WorkoutStats.Best,
|
||||
miles: Boolean,
|
||||
): String =
|
||||
when (best.kind) {
|
||||
WorkoutStats.BestKind.LONGEST_DISTANCE ->
|
||||
"${formatDistanceValue(best.workout.distanceMeters ?: 0.0, miles)} ${distanceUnit(miles)}"
|
||||
WorkoutStats.BestKind.LONGEST_DURATION -> formatDuration(best.workout.durationSeconds)
|
||||
WorkoutStats.BestKind.BIGGEST_CLIMB ->
|
||||
"${formatElevationValue(best.workout.elevationGainMeters ?: 0.0, miles)} ${elevationUnit(miles)}"
|
||||
WorkoutStats.BestKind.MOST_STEPS -> (best.workout.steps ?: 0).toString()
|
||||
WorkoutStats.BestKind.HIGHEST_HEART_RATE ->
|
||||
"${best.workout.maxHeartRate ?: 0} ${stringRes(Res.string.my_fitness_unit_bpm)}"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecentWorkouts(
|
||||
report: WorkoutStats.Report,
|
||||
miles: Boolean,
|
||||
onShare: (DetectedWorkout, String) -> Unit,
|
||||
) {
|
||||
SectionCard(stringRes(Res.string.my_fitness_recent)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
report.workouts.take(RECENT_LIMIT).forEach { workout ->
|
||||
// Resolved here rather than in the click lambda: displayLabel reads a string
|
||||
// resource, which only composition can do.
|
||||
val label = workout.displayLabel()
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Icon(
|
||||
symbol = workout.exercise.symbol(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Only what is still unpublished can be shared. Offering to share a
|
||||
// workout that is already posted would publish a second kind 1301 for
|
||||
// the same effort — whether it came back from a relay, or is the
|
||||
// Health Connect copy of one the user shared earlier.
|
||||
if (!workout.alreadyPublished) {
|
||||
TextButton(onClick = { onShare(workout, label) }) {
|
||||
Text(stringRes(Res.string.my_fitness_share), style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = workoutSummary(workout, miles),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val RECENT_LIMIT = 15
|
||||
|
||||
@Composable
|
||||
private fun DetectedWorkout.displayLabel(): String = title?.takeIf { it.isNotBlank() } ?: stringRes(exercise.labelRes())
|
||||
|
||||
@Composable
|
||||
private fun workoutSummary(
|
||||
workout: DetectedWorkout,
|
||||
miles: Boolean,
|
||||
): String {
|
||||
val parts = mutableListOf<String>()
|
||||
parts.add(formatDuration(workout.durationSeconds))
|
||||
workout.distanceMeters?.takeIf { it > 0 }?.let { parts.add("${formatDistanceValue(it, miles)} ${distanceUnit(miles)}") }
|
||||
workout.calories?.takeIf { it > 0 }?.let { parts.add("$it ${stringRes(Res.string.my_fitness_unit_kcal)}") }
|
||||
workout.avgHeartRate?.takeIf { it > 0 }?.let { parts.add("$it ${stringRes(Res.string.my_fitness_unit_bpm)}") }
|
||||
workout.elevationGainMeters?.takeIf { it > 0 }?.let { parts.add("${formatElevationValue(it, miles)} ${elevationUnit(miles)}") }
|
||||
return parts.joinToString(" · ")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(
|
||||
title: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
OutlinedCard(shape = RoundedCornerShape(14.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Box(modifier = Modifier.padding(14.dp)) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HighlightTile(
|
||||
value: String,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
OutlinedCard(shape = RoundedCornerShape(14.dp), modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = value, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class StatCell(
|
||||
val label: String,
|
||||
val value: String,
|
||||
val unit: String?,
|
||||
val percentChange: Int?,
|
||||
)
|
||||
|
||||
/** Two-column grid of stats — a plain Column of Rows so it nests inside the scrolling page. */
|
||||
@Composable
|
||||
private fun StatGrid(cells: List<StatCell>) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
cells.chunked(2).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
row.forEach { cell ->
|
||||
StatCellView(cell, Modifier.weight(1f))
|
||||
}
|
||||
// Keeps a lone trailing cell at half width instead of stretching it across.
|
||||
if (row.size == 1) Box(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatCellView(
|
||||
cell: StatCell,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = cell.value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
cell.unit?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = cell.label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
cell.percentChange?.let { change ->
|
||||
Text(
|
||||
text = "${if (change >= 0) "+" else ""}$change% ${stringRes(Res.string.my_fitness_vs_last_week)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color =
|
||||
if (change >= 0) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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.ui.screen.loggedIn.workouts.fitness
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout
|
||||
import com.vitorpamplona.amethyst.commons.fitness.TrainingLog
|
||||
import com.vitorpamplona.amethyst.commons.fitness.WorkoutStats
|
||||
import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager
|
||||
import com.vitorpamplona.amethyst.service.workouts.health.publishedWorkoutsOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
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], summarised by [WorkoutStats].
|
||||
*
|
||||
* 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, before Health Connect has been checked. */
|
||||
data object Loading : State
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
private val pubkeyHex = MutableStateFlow<String?>(null)
|
||||
|
||||
/**
|
||||
* Health Connect's contribution. A push source: the platform has no change feed we can
|
||||
* observe, so [refresh] re-reads it when the screen resumes or a permission is granted.
|
||||
*/
|
||||
private val fromHealthConnect = MutableStateFlow<List<DetectedWorkout>>(emptyList())
|
||||
|
||||
/** Null until the first [refresh] resolves, which is what keeps the screen on [State.Loading]. */
|
||||
private val healthConnectStatus = MutableStateFlow<HealthConnectStatus?>(null)
|
||||
|
||||
private var manager: HealthConnectManager? = null
|
||||
|
||||
/**
|
||||
* The user's published workouts, live. Re-subscribes on an account switch; a workout arriving
|
||||
* from a relay — or the one the user just posted — lands here without a refresh.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val fromRelays: Flow<List<DetectedWorkout>> =
|
||||
pubkeyHex.flatMapLatest { me ->
|
||||
if (me == null) flowOf(emptyList()) else publishedWorkoutsOf(me)
|
||||
}
|
||||
|
||||
val state: StateFlow<State> =
|
||||
combine(fromHealthConnect, fromRelays, healthConnectStatus) { healthConnect, published, status ->
|
||||
if (status == null) {
|
||||
State.Loading
|
||||
} else {
|
||||
val now = Instant.now()
|
||||
val since = now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)).epochSecond
|
||||
|
||||
State.Ready(
|
||||
report =
|
||||
WorkoutStats.report(
|
||||
TrainingLog.merge(healthConnect, published.filter { it.startTimeEpochSeconds >= since }),
|
||||
now,
|
||||
),
|
||||
healthConnect = status,
|
||||
)
|
||||
}
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), State.Loading)
|
||||
|
||||
/** The account whose workouts this dashboard summarises. */
|
||||
fun init(pubkeyHex: String) {
|
||||
this.pubkeyHex.value = pubkeyHex
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reads Health Connect. Safe to call on every resume: it re-checks permissions as well as
|
||||
* data, so revoking access drops those workouts out of the log rather than leaving stale
|
||||
* numbers on display. The published side needs no refresh — it is observed.
|
||||
*/
|
||||
fun refresh(context: Context) {
|
||||
viewModelScope.launch {
|
||||
val status = healthConnectStatus(context)
|
||||
|
||||
fromHealthConnect.value =
|
||||
if (status == HealthConnectStatus.CONNECTED) {
|
||||
val now = Instant.now()
|
||||
manager?.readWorkouts(now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)), now).orEmpty()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
healthConnectStatus.value = 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
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -57,6 +57,9 @@ import androidx.health.connect.client.PermissionController
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout
|
||||
import com.vitorpamplona.amethyst.commons.fitness.TrainingLog
|
||||
import com.vitorpamplona.amethyst.commons.fitness.WorkoutOrigin
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
@@ -67,14 +70,15 @@ import com.vitorpamplona.amethyst.commons.resources.workout_suggestion_connect_m
|
||||
import com.vitorpamplona.amethyst.commons.resources.workout_suggestion_connect_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.workout_suggestion_distance_km
|
||||
import com.vitorpamplona.amethyst.model.BooleanType
|
||||
import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout
|
||||
import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager
|
||||
import com.vitorpamplona.amethyst.service.workouts.health.publishedWorkoutsOf
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.health.HealthConnectRationaleActivity
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.labelRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.symbol
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
@@ -108,13 +112,21 @@ fun DetectedWorkoutCarousel(
|
||||
var granted by remember { mutableStateOf<Boolean?>(null) }
|
||||
var workouts by remember { mutableStateOf<List<DetectedWorkout>>(emptyList()) }
|
||||
|
||||
val myPubkey = accountViewModel.userProfile().pubkeyHex
|
||||
|
||||
val reload: suspend () -> Unit = {
|
||||
val ok = manager.hasAllPermissions()
|
||||
granted = ok
|
||||
workouts =
|
||||
if (ok) {
|
||||
val since = Instant.now().minus(Duration.ofDays(HealthConnectManager.LOOKBACK_DAYS))
|
||||
manager.readNewWorkouts(since).sortedByDescending { it.startTimeEpochSeconds }
|
||||
// Offering a workout the user already shared would publish a second kind 1301 for
|
||||
// the same effort. merge() flags the Health Connect copies that match something
|
||||
// already posted, so drop those; what is left is genuinely unshared.
|
||||
TrainingLog
|
||||
.merge(manager.readWorkouts(since), publishedWorkoutsOf(myPubkey).first())
|
||||
.filter { it.origin == WorkoutOrigin.HEALTH_CONNECT && !it.alreadyPublished }
|
||||
.sortedByDescending { it.startTimeEpochSeconds }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.suggestion
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout
|
||||
import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SourceTag
|
||||
|
||||
|
||||
@@ -2771,4 +2771,8 @@
|
||||
rationale screen. Needs to be an Android resource (not a commons Compose resource)
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<string name="health_connect_rationale_activity_label">Health Connect and Amethyst</string>
|
||||
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<string name="my_fitness">My Fitness</string>
|
||||
</resources>
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.workouts.health
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts
|
||||
|
||||
import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout
|
||||
import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.suggestion.toNewWorkoutRoute
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
|
||||
|
||||
+27
-2
@@ -18,13 +18,23 @@
|
||||
* 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
|
||||
package com.vitorpamplona.amethyst.commons.fitness
|
||||
|
||||
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,21 @@ 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,
|
||||
/**
|
||||
* Whether a kind 1301 for this workout already exists.
|
||||
*
|
||||
* Not the same question as [origin]: a workout read from Health Connect is published the
|
||||
* moment the user shares it, and [TrainingLog.merge] keeps the richer Health Connect copy
|
||||
* rather than the published one — so the survivor is still [WorkoutOrigin.HEALTH_CONNECT]
|
||||
* while a kind 1301 for it is already out there. Anything offering to publish a workout
|
||||
* must read this, not the origin, or it offers to post the same effort twice.
|
||||
*/
|
||||
val alreadyPublished: Boolean = false,
|
||||
/**
|
||||
* How many Health Connect sessions this workout represents. 1 for a raw
|
||||
* session; higher when [WorkoutMerger] combined several close-by same-type
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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.commons.fitness
|
||||
|
||||
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.
|
||||
*
|
||||
* The winner keeps the loser's one piece of information: that a kind 1301 for this workout
|
||||
* exists. Dropping the published copy would otherwise lose that fact, and the survivor —
|
||||
* still flagged as Health Connect data — would read as never posted. See
|
||||
* [DetectedWorkout.alreadyPublished].
|
||||
*/
|
||||
fun merge(
|
||||
healthConnect: List<DetectedWorkout>,
|
||||
published: List<DetectedWorkout>,
|
||||
): List<DetectedWorkout> {
|
||||
val flagged =
|
||||
healthConnect.map { recorded ->
|
||||
if (published.any { recorded.isProbablySameWorkoutAs(it) }) {
|
||||
recorded.copy(alreadyPublished = true)
|
||||
} else {
|
||||
recorded
|
||||
}
|
||||
}
|
||||
|
||||
val deduped =
|
||||
published.filterNot { candidate ->
|
||||
healthConnect.any { it.isProbablySameWorkoutAs(candidate) }
|
||||
}
|
||||
|
||||
return (flagged + 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
|
||||
|
||||
// effectiveDurationSeconds, not durationSeconds: a POWR-dialect record carries `start`/`end`
|
||||
// and no `duration` tag, so the raw accessor returns null and the workout would vanish from
|
||||
// the log entirely. WorkoutDisplay already reads it through the same helper.
|
||||
val duration = effectiveDurationSeconds() ?: 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,
|
||||
// It came back from a relay, so by definition it is already out there.
|
||||
alreadyPublished = true,
|
||||
)
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* 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.commons.fitness
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Turns a flat list of [DetectedWorkout] into the figures the My Fitness screen shows the
|
||||
* user about their own training: period totals, week-over-week movement, a per-activity
|
||||
* breakdown, bests, and how consistently they have been showing up.
|
||||
*
|
||||
* Pure and platform-free on purpose — every number the screen displays is derived here, so
|
||||
* the arithmetic can be tested without Health Connect, a device, or Compose.
|
||||
*
|
||||
* Everything is scoped to [WINDOW_DAYS]. Health Connect only serves the most recent 30 days
|
||||
* unless an app also holds `READ_HEALTH_DATA_HISTORY`, which Amethyst deliberately does not
|
||||
* request; four whole weeks is the largest honest window inside that limit, and it makes the
|
||||
* "this week vs last week" comparison land inside it too.
|
||||
*/
|
||||
object WorkoutStats {
|
||||
/** Four whole weeks — see the class note on the 30-day Health Connect limit. */
|
||||
const val WINDOW_DAYS = 28L
|
||||
|
||||
/** Days in the recent-comparison period. */
|
||||
const val WEEK_DAYS = 7L
|
||||
|
||||
/**
|
||||
* Totals over some set of workouts. Sums are plain sums; [avgHeartRate] is
|
||||
* duration-weighted so a two-hour ride outweighs a ten-minute walk.
|
||||
*/
|
||||
@Immutable
|
||||
data class Totals(
|
||||
val workoutCount: Int = 0,
|
||||
val durationSeconds: Long = 0,
|
||||
val distanceMeters: Double = 0.0,
|
||||
val calories: Int = 0,
|
||||
val steps: Int = 0,
|
||||
val elevationGainMeters: Double = 0.0,
|
||||
val avgHeartRate: Int? = null,
|
||||
val maxHeartRate: Int? = null,
|
||||
) {
|
||||
val isEmpty: Boolean get() = workoutCount == 0
|
||||
}
|
||||
|
||||
/** One activity's slice of the window, e.g. "Running: 5 workouts, 42 km". */
|
||||
@Immutable
|
||||
data class ActivityTotals(
|
||||
val exercise: ExerciseType,
|
||||
val totals: Totals,
|
||||
)
|
||||
|
||||
/** A single best effort in the window, with the workout that set it. */
|
||||
@Immutable
|
||||
data class Best(
|
||||
val kind: BestKind,
|
||||
val workout: DetectedWorkout,
|
||||
)
|
||||
|
||||
enum class BestKind { LONGEST_DISTANCE, LONGEST_DURATION, BIGGEST_CLIMB, MOST_STEPS, HIGHEST_HEART_RATE }
|
||||
|
||||
/** Everything the My Fitness screen renders. */
|
||||
@Immutable
|
||||
data class Report(
|
||||
val windowTotals: Totals,
|
||||
val thisWeek: Totals,
|
||||
val previousWeek: Totals,
|
||||
val weeklyAverage: Totals,
|
||||
val byActivity: List<ActivityTotals>,
|
||||
val bests: List<Best>,
|
||||
val activeDays: Int,
|
||||
val currentStreakDays: Int,
|
||||
val workouts: List<DetectedWorkout>,
|
||||
) {
|
||||
val isEmpty: Boolean get() = windowTotals.isEmpty
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the report from [workouts] — which the caller should already have limited to
|
||||
* [WINDOW_DAYS] — as of [now]. [zone] decides day boundaries for the streak and
|
||||
* active-day counts, so a workout at 23:30 counts for that day, not the next.
|
||||
*/
|
||||
fun report(
|
||||
workouts: List<DetectedWorkout>,
|
||||
now: Instant = Instant.now(),
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
): Report {
|
||||
val windowStart = now.epochSecond - WINDOW_DAYS * SECONDS_PER_DAY
|
||||
val weekStart = now.epochSecond - WEEK_DAYS * SECONDS_PER_DAY
|
||||
val previousWeekStart = weekStart - WEEK_DAYS * SECONDS_PER_DAY
|
||||
|
||||
val inWindow =
|
||||
workouts
|
||||
.filter { it.startTimeEpochSeconds >= windowStart }
|
||||
.sortedByDescending { it.startTimeEpochSeconds }
|
||||
|
||||
val thisWeek = inWindow.filter { it.startTimeEpochSeconds >= weekStart }
|
||||
val previousWeek = inWindow.filter { it.startTimeEpochSeconds in previousWeekStart until weekStart }
|
||||
|
||||
val windowTotals = total(inWindow)
|
||||
|
||||
return Report(
|
||||
windowTotals = windowTotals,
|
||||
thisWeek = total(thisWeek),
|
||||
previousWeek = total(previousWeek),
|
||||
weeklyAverage = perWeek(windowTotals),
|
||||
byActivity = byActivity(inWindow),
|
||||
bests = bests(inWindow),
|
||||
activeDays = activeDays(inWindow, zone).size,
|
||||
currentStreakDays = streak(inWindow, now, zone),
|
||||
workouts = inWindow,
|
||||
)
|
||||
}
|
||||
|
||||
private const val SECONDS_PER_DAY = 86_400L
|
||||
|
||||
/** Sums a set of workouts. Absent metrics contribute nothing rather than zero-ing the total. */
|
||||
fun total(workouts: List<DetectedWorkout>): Totals {
|
||||
if (workouts.isEmpty()) return Totals()
|
||||
|
||||
val withHeartRate = workouts.filter { it.avgHeartRate != null }
|
||||
val heartRateWeight = withHeartRate.sumOf { it.durationSeconds }
|
||||
|
||||
return Totals(
|
||||
workoutCount = workouts.size,
|
||||
durationSeconds = workouts.sumOf { it.durationSeconds },
|
||||
distanceMeters = workouts.sumOf { it.distanceMeters ?: 0.0 },
|
||||
calories = workouts.sumOf { it.calories ?: 0 },
|
||||
steps = workouts.sumOf { it.steps ?: 0 },
|
||||
elevationGainMeters = workouts.sumOf { it.elevationGainMeters ?: 0.0 },
|
||||
avgHeartRate =
|
||||
when {
|
||||
withHeartRate.isEmpty() -> null
|
||||
heartRateWeight > 0 ->
|
||||
withHeartRate
|
||||
.sumOf { it.avgHeartRate!!.toDouble() * it.durationSeconds }
|
||||
.div(heartRateWeight)
|
||||
.roundToInt()
|
||||
else -> withHeartRate.map { it.avgHeartRate!! }.average().roundToInt()
|
||||
},
|
||||
maxHeartRate = workouts.mapNotNull { it.maxHeartRate }.maxOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
/** The window's totals expressed per week, for "your weekly average" lines. */
|
||||
private fun perWeek(totals: Totals): Totals {
|
||||
val weeks = WINDOW_DAYS.toDouble() / WEEK_DAYS
|
||||
return Totals(
|
||||
workoutCount = (totals.workoutCount / weeks).roundToInt(),
|
||||
durationSeconds = (totals.durationSeconds / weeks).toLong(),
|
||||
distanceMeters = totals.distanceMeters / weeks,
|
||||
calories = (totals.calories / weeks).roundToInt(),
|
||||
steps = (totals.steps / weeks).roundToInt(),
|
||||
elevationGainMeters = totals.elevationGainMeters / weeks,
|
||||
// Averages don't divide: the mean heart rate of a week is the mean of the window.
|
||||
avgHeartRate = totals.avgHeartRate,
|
||||
maxHeartRate = totals.maxHeartRate,
|
||||
)
|
||||
}
|
||||
|
||||
/** Per-activity totals, busiest first (by time spent, then by count). */
|
||||
private fun byActivity(workouts: List<DetectedWorkout>): List<ActivityTotals> =
|
||||
workouts
|
||||
.groupBy { it.exercise }
|
||||
.map { (exercise, list) -> ActivityTotals(exercise, total(list)) }
|
||||
.sortedWith(
|
||||
compareByDescending<ActivityTotals> { it.totals.durationSeconds }
|
||||
.thenByDescending { it.totals.workoutCount },
|
||||
)
|
||||
|
||||
/**
|
||||
* The standout efforts of the window. A best is only reported when the metric is present
|
||||
* and positive, so a user whose watch records no elevation never sees an empty "biggest
|
||||
* climb" card.
|
||||
*/
|
||||
private fun bests(workouts: List<DetectedWorkout>): List<Best> =
|
||||
listOfNotNull(
|
||||
workouts
|
||||
.filter { (it.distanceMeters ?: 0.0) > 0 }
|
||||
.maxByOrNull { it.distanceMeters!! }
|
||||
?.let { Best(BestKind.LONGEST_DISTANCE, it) },
|
||||
workouts
|
||||
.filter { it.durationSeconds > 0 }
|
||||
.maxByOrNull { it.durationSeconds }
|
||||
?.let { Best(BestKind.LONGEST_DURATION, it) },
|
||||
workouts
|
||||
.filter { (it.elevationGainMeters ?: 0.0) > 0 }
|
||||
.maxByOrNull { it.elevationGainMeters!! }
|
||||
?.let { Best(BestKind.BIGGEST_CLIMB, it) },
|
||||
workouts
|
||||
.filter { (it.steps ?: 0) > 0 }
|
||||
.maxByOrNull { it.steps!! }
|
||||
?.let { Best(BestKind.MOST_STEPS, it) },
|
||||
workouts
|
||||
.filter { (it.maxHeartRate ?: 0) > 0 }
|
||||
.maxByOrNull { it.maxHeartRate!! }
|
||||
?.let { Best(BestKind.HIGHEST_HEART_RATE, it) },
|
||||
)
|
||||
|
||||
/** The distinct local dates on which the user trained. */
|
||||
private fun activeDays(
|
||||
workouts: List<DetectedWorkout>,
|
||||
zone: ZoneId,
|
||||
): Set<LocalDate> = workouts.mapTo(HashSet()) { it.localDate(zone) }
|
||||
|
||||
/**
|
||||
* Consecutive days up to today on which the user trained. A rest day today does not break
|
||||
* a streak that is otherwise alive — the count then runs back from yesterday — so the
|
||||
* number only resets once a whole day has genuinely been missed.
|
||||
*/
|
||||
private fun streak(
|
||||
workouts: List<DetectedWorkout>,
|
||||
now: Instant,
|
||||
zone: ZoneId,
|
||||
): Int {
|
||||
val days = activeDays(workouts, zone)
|
||||
if (days.isEmpty()) return 0
|
||||
|
||||
val today = now.atZone(zone).toLocalDate()
|
||||
var cursor = if (today in days) today else today.minusDays(1)
|
||||
|
||||
var streak = 0
|
||||
while (cursor in days) {
|
||||
streak++
|
||||
cursor = cursor.minusDays(1)
|
||||
}
|
||||
return streak
|
||||
}
|
||||
|
||||
private fun DetectedWorkout.localDate(zone: ZoneId): LocalDate = Instant.ofEpochSecond(startTimeEpochSeconds).atZone(zone).toLocalDate()
|
||||
|
||||
/**
|
||||
* Percentage change from [previous] to [current], or null when there is no previous value
|
||||
* to compare against (an arrow up from zero says nothing).
|
||||
*/
|
||||
fun percentChange(
|
||||
current: Double,
|
||||
previous: Double,
|
||||
): Int? {
|
||||
if (previous <= 0.0) return null
|
||||
return (((current - previous) / previous) * 100).roundToInt()
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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.commons.fitness
|
||||
|
||||
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.WorkoutEndTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutStartTag
|
||||
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.Assert.assertTrue
|
||||
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)
|
||||
// Recovered from a relay, so the dashboard must not offer to publish it again.
|
||||
assertTrue(workout.alreadyPublished)
|
||||
}
|
||||
|
||||
/** 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())
|
||||
}
|
||||
|
||||
/**
|
||||
* POWR-dialect records carry `start`/`end` and no `duration` tag. Reading the raw duration
|
||||
* accessor drops them from the log entirely.
|
||||
*/
|
||||
@Test
|
||||
fun `a POWR record with start and end but no duration tag is kept`() {
|
||||
val workout =
|
||||
event(
|
||||
ExerciseTag.assemble(ExerciseType.CYCLING),
|
||||
WorkoutStartTag.assemble(createdAt - 3600),
|
||||
WorkoutEndTag.assemble(createdAt - 387),
|
||||
).toDetectedWorkout()
|
||||
|
||||
assertNotNull(workout)
|
||||
assertEquals(3213, workout!!.durationSeconds)
|
||||
assertEquals(createdAt - 3600, workout.startTimeEpochSeconds)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* 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.commons.fitness
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
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,
|
||||
// Matches the real mappers: anything recovered from a relay is by definition published.
|
||||
alreadyPublished = origin == WorkoutOrigin.PUBLISHED,
|
||||
)
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
/**
|
||||
* The survivor is Health Connect data, so its origin cannot answer "has this been posted?".
|
||||
* Without the flag the dashboard offers to share it again and the user posts a second
|
||||
* kind 1301 for the same effort.
|
||||
*/
|
||||
@Test
|
||||
fun `the surviving copy remembers that the workout was already published`() {
|
||||
val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon))
|
||||
val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon + 60))
|
||||
|
||||
val merged = TrainingLog.merge(hc, published)
|
||||
|
||||
assertEquals(1, merged.size)
|
||||
assertEquals(WorkoutOrigin.HEALTH_CONNECT, merged[0].origin)
|
||||
assertTrue(merged[0].alreadyPublished)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a health connect workout that was never shared is not marked published`() {
|
||||
val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon))
|
||||
val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon + TrainingLog.DEDUPE_TOLERANCE_SECONDS + 1))
|
||||
|
||||
val merged = TrainingLog.merge(hc, published)
|
||||
|
||||
assertEquals(2, merged.size)
|
||||
assertFalse(merged.first { it.id == "hc" }.alreadyPublished)
|
||||
assertTrue(merged.first { it.id == "pub" }.alreadyPublished)
|
||||
}
|
||||
|
||||
/** Only the matching session is flagged; an unrelated one in the same log is left alone. */
|
||||
@Test
|
||||
fun `flagging one workout does not flag the rest of the log`() {
|
||||
val hc =
|
||||
listOf(
|
||||
workout("shared", WorkoutOrigin.HEALTH_CONNECT, noon),
|
||||
workout("private", WorkoutOrigin.HEALTH_CONNECT, noon - 86_400),
|
||||
)
|
||||
val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon))
|
||||
|
||||
val merged = TrainingLog.merge(hc, published)
|
||||
|
||||
assertEquals(2, merged.size)
|
||||
assertTrue(merged.first { it.id == "shared" }.alreadyPublished)
|
||||
assertFalse(merged.first { it.id == "private" }.alreadyPublished)
|
||||
}
|
||||
|
||||
@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())
|
||||
}
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* 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.commons.fitness
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
class WorkoutStatsTest {
|
||||
private val zone: ZoneId = ZoneId.of("UTC")
|
||||
|
||||
/** Noon UTC so a test never straddles a day boundary by accident. */
|
||||
private val now: Instant = ZonedDateTime.of(2026, 3, 15, 12, 0, 0, 0, zone).toInstant()
|
||||
|
||||
private var nextId = 0
|
||||
|
||||
private fun workout(
|
||||
daysAgo: Long,
|
||||
exercise: ExerciseType = ExerciseType.RUNNING,
|
||||
durationSeconds: Long = 1800,
|
||||
distanceMeters: Double? = null,
|
||||
calories: Int? = null,
|
||||
avgHeartRate: Int? = null,
|
||||
maxHeartRate: Int? = null,
|
||||
steps: Int? = null,
|
||||
elevationGainMeters: Double? = null,
|
||||
) = DetectedWorkout(
|
||||
id = "w${nextId++}",
|
||||
exercise = exercise,
|
||||
title = null,
|
||||
startTimeEpochSeconds = now.epochSecond - daysAgo * 86_400L,
|
||||
durationSeconds = durationSeconds,
|
||||
distanceMeters = distanceMeters,
|
||||
calories = calories,
|
||||
avgHeartRate = avgHeartRate,
|
||||
maxHeartRate = maxHeartRate,
|
||||
steps = steps,
|
||||
elevationGainMeters = elevationGainMeters,
|
||||
source = "Samsung Health",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `an empty history reports empty rather than zeroes everywhere`() {
|
||||
val report = WorkoutStats.report(emptyList(), now, zone)
|
||||
|
||||
assertTrue(report.isEmpty)
|
||||
assertEquals(0, report.activeDays)
|
||||
assertEquals(0, report.currentStreakDays)
|
||||
assertTrue(report.bests.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `workouts older than the window are excluded`() {
|
||||
val report =
|
||||
WorkoutStats.report(
|
||||
listOf(workout(daysAgo = 3), workout(daysAgo = 40, distanceMeters = 99_000.0)),
|
||||
now,
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(1, report.windowTotals.workoutCount)
|
||||
assertEquals(0.0, report.windowTotals.distanceMeters, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `this week and last week are split at the seven day boundary`() {
|
||||
val report =
|
||||
WorkoutStats.report(
|
||||
listOf(
|
||||
workout(daysAgo = 1, distanceMeters = 5000.0),
|
||||
workout(daysAgo = 6, distanceMeters = 3000.0),
|
||||
workout(daysAgo = 8, distanceMeters = 10_000.0),
|
||||
workout(daysAgo = 20, distanceMeters = 1000.0),
|
||||
),
|
||||
now,
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(2, report.thisWeek.workoutCount)
|
||||
assertEquals(8000.0, report.thisWeek.distanceMeters, 0.001)
|
||||
assertEquals(1, report.previousWeek.workoutCount)
|
||||
assertEquals(10_000.0, report.previousWeek.distanceMeters, 0.001)
|
||||
assertEquals(4, report.windowTotals.workoutCount)
|
||||
}
|
||||
|
||||
/** A missing metric must not be counted as a zero that drags an average down. */
|
||||
@Test
|
||||
fun `absent metrics contribute nothing to totals`() {
|
||||
val report =
|
||||
WorkoutStats.report(
|
||||
listOf(
|
||||
workout(daysAgo = 1, distanceMeters = 5000.0, calories = 300, steps = 6000),
|
||||
workout(daysAgo = 2),
|
||||
),
|
||||
now,
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(5000.0, report.windowTotals.distanceMeters, 0.001)
|
||||
assertEquals(300, report.windowTotals.calories)
|
||||
assertEquals(6000, report.windowTotals.steps)
|
||||
assertNull(report.windowTotals.avgHeartRate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `average heart rate is duration weighted across the window`() {
|
||||
val report =
|
||||
WorkoutStats.report(
|
||||
listOf(
|
||||
workout(daysAgo = 1, durationSeconds = 3600, avgHeartRate = 160),
|
||||
workout(daysAgo = 2, durationSeconds = 600, avgHeartRate = 100),
|
||||
),
|
||||
now,
|
||||
zone,
|
||||
)
|
||||
|
||||
// (160*3600 + 100*600) / 4200 = 151.4 -> 151
|
||||
assertEquals(151, report.windowTotals.avgHeartRate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the activity breakdown is ordered by time spent`() {
|
||||
val report =
|
||||
WorkoutStats.report(
|
||||
listOf(
|
||||
workout(daysAgo = 1, exercise = ExerciseType.RUNNING, durationSeconds = 600),
|
||||
workout(daysAgo = 2, exercise = ExerciseType.CYCLING, durationSeconds = 7200),
|
||||
workout(daysAgo = 3, exercise = ExerciseType.RUNNING, durationSeconds = 600),
|
||||
),
|
||||
now,
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(ExerciseType.CYCLING, report.byActivity[0].exercise)
|
||||
assertEquals(1, report.byActivity[0].totals.workoutCount)
|
||||
assertEquals(ExerciseType.RUNNING, report.byActivity[1].exercise)
|
||||
assertEquals(2, report.byActivity[1].totals.workoutCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a best is only reported for metrics that are actually present`() {
|
||||
val report =
|
||||
WorkoutStats.report(
|
||||
listOf(workout(daysAgo = 1, distanceMeters = 5000.0)),
|
||||
now,
|
||||
zone,
|
||||
)
|
||||
|
||||
val kinds = report.bests.map { it.kind }
|
||||
assertTrue(WorkoutStats.BestKind.LONGEST_DISTANCE in kinds)
|
||||
assertTrue(WorkoutStats.BestKind.LONGEST_DURATION in kinds)
|
||||
assertTrue(WorkoutStats.BestKind.BIGGEST_CLIMB !in kinds)
|
||||
assertTrue(WorkoutStats.BestKind.MOST_STEPS !in kinds)
|
||||
assertTrue(WorkoutStats.BestKind.HIGHEST_HEART_RATE !in kinds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two workouts on the same day count as one active day`() {
|
||||
val report =
|
||||
WorkoutStats.report(
|
||||
listOf(workout(daysAgo = 1), workout(daysAgo = 1), workout(daysAgo = 3)),
|
||||
now,
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(2, report.activeDays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the streak counts consecutive days back from today`() {
|
||||
val report =
|
||||
WorkoutStats.report(
|
||||
listOf(workout(daysAgo = 0), workout(daysAgo = 1), workout(daysAgo = 2), workout(daysAgo = 5)),
|
||||
now,
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(3, report.currentStreakDays)
|
||||
}
|
||||
|
||||
/** Resting today shouldn't wipe out a streak that is still alive. */
|
||||
@Test
|
||||
fun `a rest day today keeps yesterday's streak alive`() {
|
||||
val report =
|
||||
WorkoutStats.report(
|
||||
listOf(workout(daysAgo = 1), workout(daysAgo = 2)),
|
||||
now,
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(2, report.currentStreakDays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing a whole day resets the streak`() {
|
||||
val report =
|
||||
WorkoutStats.report(
|
||||
listOf(workout(daysAgo = 2), workout(daysAgo = 3)),
|
||||
now,
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(0, report.currentStreakDays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the weekly average divides the four week window by four`() {
|
||||
val report =
|
||||
WorkoutStats.report(
|
||||
List(8) { workout(daysAgo = (it * 3).toLong(), distanceMeters = 5000.0) },
|
||||
now,
|
||||
zone,
|
||||
)
|
||||
|
||||
assertEquals(8, report.windowTotals.workoutCount)
|
||||
assertEquals(2, report.weeklyAverage.workoutCount)
|
||||
assertEquals(10_000.0, report.weeklyAverage.distanceMeters, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `percent change is null when there is nothing to compare against`() {
|
||||
assertNull(WorkoutStats.percentChange(current = 10.0, previous = 0.0))
|
||||
assertEquals(50, WorkoutStats.percentChange(current = 15.0, previous = 10.0))
|
||||
assertEquals(-25, WorkoutStats.percentChange(current = 7.5, previous = 10.0))
|
||||
}
|
||||
}
|
||||
@@ -684,6 +684,47 @@
|
||||
<string name="health_connect_rationale_limit_publish">Nothing leaves your phone until you tap a suggestion and publish the post yourself. Amethyst has no server: the post goes to the Nostr relays you configured.</string>
|
||||
<string name="health_connect_rationale_limit_optional">The whole feature is optional. Turn it off under Settings → Compose, or revoke the permissions in Health Connect at any time — the rest of Amethyst keeps working.</string>
|
||||
<string name="health_connect_rationale_privacy_policy">Read the full privacy policy</string>
|
||||
<string name="my_fitness_title">My Fitness</string>
|
||||
<string name="my_fitness_window">Last 4 weeks</string>
|
||||
<string name="my_fitness_window_note">The last four whole weeks of your training: the workouts you have published, plus anything Health Connect records on this phone. Amethyst works the summary out on the device and publishes none of it.</string>
|
||||
<string name="my_fitness_this_week">This week</string>
|
||||
<string name="my_fitness_vs_last_week">vs last week</string>
|
||||
<string name="my_fitness_weekly_average">Weekly average</string>
|
||||
<string name="my_fitness_workouts">Workouts</string>
|
||||
<string name="my_fitness_active_days">Active days</string>
|
||||
<string name="my_fitness_streak">Day streak</string>
|
||||
<string name="my_fitness_time">Time</string>
|
||||
<!-- Workout duration totals. %1$d is hours, %2$d is minutes; reorder and re-abbreviate
|
||||
freely — these are English abbreviations for hour/minute/second. -->
|
||||
<string name="my_fitness_duration_hours_minutes">%1$dh %2$dm</string>
|
||||
<string name="my_fitness_duration_minutes">%1$dm</string>
|
||||
<string name="my_fitness_duration_seconds">%1$ds</string>
|
||||
<string name="my_fitness_distance">Distance</string>
|
||||
<string name="my_fitness_calories">Calories</string>
|
||||
<string name="my_fitness_steps">Steps</string>
|
||||
<string name="my_fitness_elevation">Climb</string>
|
||||
<string name="my_fitness_avg_heart_rate">Avg HR</string>
|
||||
<string name="my_fitness_max_heart_rate">Max HR</string>
|
||||
<string name="my_fitness_by_activity">By activity</string>
|
||||
<string name="my_fitness_bests">Best efforts</string>
|
||||
<string name="my_fitness_best_longest_distance">Longest distance</string>
|
||||
<string name="my_fitness_best_longest_duration">Longest workout</string>
|
||||
<string name="my_fitness_best_biggest_climb">Biggest climb</string>
|
||||
<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">Nothing logged in the last 4 weeks. Workouts you post, and any your watch records, show up here.</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>
|
||||
<string name="my_fitness_unit_kcal">kcal</string>
|
||||
<string name="my_fitness_unit_km">km</string>
|
||||
<string name="my_fitness_unit_mi">mi</string>
|
||||
<string name="my_fitness_unit_m">m</string>
|
||||
<string name="my_fitness_unit_ft">ft</string>
|
||||
<string name="nsite_none_found">No nSites found yet.</string>
|
||||
<string name="browser_reload">Reload</string>
|
||||
<string name="browser_unsupported">The in-app browser needs Android 11 or newer.</string>
|
||||
@@ -1255,6 +1296,11 @@
|
||||
<string name="call_calling">Calling\u2026</string>
|
||||
<string name="call_connecting">Connecting\u2026</string>
|
||||
<string name="call_ended">Call ended</string>
|
||||
<!-- Elapsed call time. %1$d is minutes and %2$d seconds in the short form; in the long
|
||||
form %1$d is hours, %2$d minutes and %3$d seconds. Separator and field order are the
|
||||
translator's to change. -->
|
||||
<string name="call_duration_minutes_seconds">%1$02d:%2$02d</string>
|
||||
<string name="call_duration_hours_minutes_seconds">%1$d:%2$02d:%3$02d</string>
|
||||
<string name="call_dismiss">Dismiss</string>
|
||||
<string name="call_switch_camera">Switch camera</string>
|
||||
<string name="call_voice">Voice call</string>
|
||||
|
||||
@@ -1,168 +1,217 @@
|
||||
# Health Connect — Play Console declaration
|
||||
|
||||
Source text for the Health Connect permissions declaration in Play Console, written against what
|
||||
`HealthConnectManager` actually does. Keep this file and the declaration in sync: Google re-reviews
|
||||
the declaration on every Health Connect permission change.
|
||||
Source text for the Health Connect permissions declaration in Play Console. Keep this file and
|
||||
the declaration in sync: Google re-reviews the declaration on every Health Connect permission
|
||||
change.
|
||||
|
||||
Related code and copy:
|
||||
**Approved use case claimed: "Fitness, wellness and coaching"** — *"apps designed to help users
|
||||
track, monitor, analyze, manage, and improve their physical fitness."*
|
||||
|
||||
- `amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt` — the only place the app touches Health Connect.
|
||||
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt` — the only UI that calls it.
|
||||
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/health/HealthConnectRationaleActivity.kt` — the in-app rationale screen Health Connect links to.
|
||||
- `PRIVACY.md` § "Health and fitness data (Health Connect)" — the public policy the declaration points at.
|
||||
## Why the earlier declaration was rejected, and what changed
|
||||
|
||||
The first submission described the feature as "read finished workouts so the user can post them
|
||||
to their social network." That is not one of the six approved use cases, and it reads as the
|
||||
prohibited "publicly displaying or socially sharing sensitive data." It was rejected as *"Use of
|
||||
permission is not a permitted/valid use case."*
|
||||
|
||||
The app changed, not just the wording. Amethyst now has **My Fitness**, a personal training
|
||||
dashboard that summarises the user's own Health Connect workouts for them: weekly totals,
|
||||
week-over-week movement, a per-activity breakdown, best efforts, active days and a training
|
||||
streak. It is its own destination in the navigation drawer, under "You", and works fully whether
|
||||
or not the user ever publishes anything. Publishing a workout is now one optional action on a row of that dashboard.
|
||||
|
||||
**The permissions serve the dashboard.** If the declaration below is ever re-read against the
|
||||
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:
|
||||
|
||||
- `commons/.../commons/fitness/WorkoutStats.kt` — all dashboard arithmetic; pure, unit-tested.
|
||||
- `commons/.../commons/fitness/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.
|
||||
- `PRIVACY.md` § "Health and fitness data (Health Connect)".
|
||||
|
||||
---
|
||||
|
||||
## 1. App functionality
|
||||
|
||||
> Amethyst is a social media client for the Nostr protocol — an open, decentralized social network.
|
||||
> Users post updates and their followers read them. There is no Amethyst server and no Amethyst
|
||||
> account: posts are signed on the device and sent to the public relay servers the user chooses.
|
||||
> 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.
|
||||
>
|
||||
> One of the things users post is a workout summary. Amethyst's Workouts section publishes a
|
||||
> structured workout post (a NIP-101e "kind 1301" event) — the activity, when it happened, how long
|
||||
> it lasted, and the metrics that describe the effort — so the people who follow the user can see
|
||||
> what they did, congratulate them, and compare with their own. This is the fitness equivalent of
|
||||
> sharing a run on Strava or a ride on Garmin Connect, except the post goes to the user's own
|
||||
> chosen relays instead of a company's servers.
|
||||
> 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.
|
||||
>
|
||||
> Health Connect is used for exactly one thing: to fill that post in. Without it the user has to
|
||||
> retype numbers their watch already recorded — activity, duration, distance, calories, heart rate,
|
||||
> steps, climb — which is slow and error-prone enough that most people simply do not post. With it,
|
||||
> the composer shows the workouts that finished in the last 7 days as one-tap cards; tapping one
|
||||
> pre-fills the form, and the user then edits and decides whether to publish.
|
||||
> The purpose is to help the user track, monitor, analyze and improve their own physical
|
||||
> 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.
|
||||
>
|
||||
> Separately, and entirely optionally, a user who wants to tell their followers about a
|
||||
> particular workout can tap "Share this workout" on a row of that dashboard. That opens a
|
||||
> composer pre-filled with the workout's figures, which the user reviews and chooses to publish
|
||||
> or discard. Sharing is a user-initiated action on top of the tracking feature, with per-post
|
||||
> review and consent; it is never automatic, and the tracking feature is fully usable without it.
|
||||
|
||||
## 2. Reviewer walkthrough
|
||||
|
||||
> 1. Install and open Amethyst, and sign in (a new key can be generated in-app).
|
||||
> 2. Open the navigation drawer (hamburger, top-left) and, under the **Feeds** section, tap
|
||||
> **Workouts**.
|
||||
> 3. Tap the **+** button.
|
||||
> 4. At the top of the composer is a card titled **"Share your workouts"**. Tap **What Amethyst
|
||||
> reads** to see the in-app rationale screen listing each data type and its purpose, then
|
||||
> **Connect** to trigger the Health Connect permission request.
|
||||
> 5. Grant the permissions. The card is replaced by a horizontal list of the workouts Health Connect
|
||||
> holds from the last 7 days, labelled **"From Health Connect"**.
|
||||
> 6. Tap any workout. The composer below is pre-filled with its activity type, title, duration,
|
||||
> distance, calories, heart rate, steps and elevation gain, ready to edit and publish.
|
||||
> 1. Install and open Amethyst, and sign in (a key can be generated in-app).
|
||||
> 2. Open the navigation drawer (hamburger, top-left). **My Fitness** is the second entry under
|
||||
> **You**, the first section — directly below Profile. Tap it.
|
||||
> 3. The screen explains what will be read and offers **What Amethyst reads** (the full rationale
|
||||
> 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.)
|
||||
> 4. 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.
|
||||
> 5. Everything above is the tracking feature. To see the optional sharing path, tap **Share this
|
||||
> workout** on a row in Recent workouts — it opens a pre-filled composer that the user must
|
||||
> confirm. The button appears only on workouts that have not been published yet, so a workout
|
||||
> is never offered for sharing twice.
|
||||
>
|
||||
> Note for testing on an emulator or a fresh device: the carousel only appears once Health Connect
|
||||
> actually holds a finished exercise session from the last 7 days, written by some fitness app or
|
||||
> watch. With an empty Health Connect database the composer correctly shows nothing.
|
||||
> My Fitness can also be pinned to the bottom bar, under Settings, like any other destination.
|
||||
>
|
||||
> Notes for testing:
|
||||
>
|
||||
> - The dashboard summarises two sources: workouts read from Health Connect, and workout records
|
||||
> the account has published from Amethyst. On a fresh device with an empty Health Connect
|
||||
> database and an account that has never logged a workout, it correctly reports that nothing has
|
||||
> been recorded — there is no data to summarise, which is not a failure of the feature.
|
||||
> - To see it populated, either grant Health Connect access on a device whose fitness app or watch
|
||||
> has saved sessions in the last four weeks, or log one in the app: drawer → **Feeds** →
|
||||
> **Workouts** → **+**. A workout logged that way appears in the dashboard without any health
|
||||
> permission at all.
|
||||
|
||||
## 3. Per-permission justification
|
||||
|
||||
Paste one row per permission into the corresponding field.
|
||||
Each permission maps to a statistic the dashboard shows the user about their own training.
|
||||
|
||||
### READ_EXERCISE — ExerciseSession (and CyclingPedalingCadence)
|
||||
|
||||
> This is the workout itself and the anchor for everything else. Amethyst reads the exercise
|
||||
> session's activity type, start time and end time, and turns them into the post's activity, date
|
||||
> and duration: "Running, 42:15, yesterday". The session's time window is also what every other
|
||||
> metric below is aggregated over, so without this permission the feature cannot exist at all — the
|
||||
> app would have no notion of "a workout" to attach numbers to. The session's own title, when the
|
||||
> source app set one, becomes the suggested post title.
|
||||
> The workout itself, and the spine of the whole dashboard. Amethyst reads each session's
|
||||
> activity type, start time and end time to produce: the count of workouts this week versus last
|
||||
> week, total training time, the per-activity breakdown ("Cycling: 3×, 4h 10m"), the number of
|
||||
> active days, and the consecutive-day training streak. Every other metric below is aggregated
|
||||
> over the session's time window, so without this permission there is no notion of "a workout" to
|
||||
> attach any statistic to and the feature cannot exist.
|
||||
>
|
||||
> Benefit to the user: the workout their watch recorded appears as a one-tap suggestion instead of a
|
||||
> blank form.
|
||||
> Benefit to the user: they can see how consistently and how much they are actually training,
|
||||
> which is the basic question a training summary answers.
|
||||
>
|
||||
> Note on CyclingPedalingCadence: Health Connect grants that data type under the same
|
||||
> READ_EXERCISE permission. Amethyst does not read, store, or publish cadence — it reads only
|
||||
> ExerciseSessionRecord. There is no separate permission available to request one without the other.
|
||||
> CyclingPedalingCadence is granted by Health Connect under this same permission. Amethyst does
|
||||
> not read, store, or display cadence — it reads only ExerciseSessionRecord. There is no separate
|
||||
> permission that grants one without the other.
|
||||
|
||||
### READ_DISTANCE — Distance
|
||||
|
||||
> Amethyst aggregates the distance recorded over the workout's time window and fills it into the
|
||||
> distance field of the post. Distance is the single most important number in a running, cycling,
|
||||
> walking, hiking, rowing or swimming post — "5.2 km" is what the post is about, and a shared
|
||||
> workout without it is largely meaningless to the people reading it.
|
||||
> Aggregated per workout and summed into the dashboard's distance figures: distance this week,
|
||||
> the percentage change against last week, the four-week weekly average, distance per activity,
|
||||
> and the "Longest distance" best effort.
|
||||
>
|
||||
> Benefit to the user: they do not have to look up and retype the distance their watch already
|
||||
> measured, and the figure published is the accurate recorded one rather than a remembered estimate.
|
||||
> Benefit to the user: distance is the primary training-load measure for running, cycling,
|
||||
> walking, hiking, rowing and swimming. Seeing this week's total against last week's is how a
|
||||
> user knows whether they are building up or falling off.
|
||||
|
||||
### READ_ACTIVE_CALORIES_BURNED — ActiveCaloriesBurned
|
||||
|
||||
> Amethyst aggregates active calories over the workout's time window and fills in the post's energy
|
||||
> field. Active calories (energy burned by the activity, excluding resting metabolism) are the
|
||||
> correct figure for describing a workout, and the one other fitness apps and Nostr fitness clients
|
||||
> publish, so using it keeps Amethyst's posts comparable with theirs.
|
||||
> Aggregated per workout and summed into the dashboard's energy figures: calories this week and
|
||||
> the change against last week. Active calories — energy burned by the activity, excluding
|
||||
> resting metabolism — are the correct measure of a workout's cost.
|
||||
>
|
||||
> Benefit to the user: the effort figure in their post is the one their device computed, and it is
|
||||
> filled in automatically.
|
||||
> Benefit to the user: a view of training energy expenditure over time, and whether it is rising
|
||||
> or falling week to week.
|
||||
|
||||
### READ_TOTAL_CALORIES_BURNED — TotalCaloriesBurned
|
||||
|
||||
> Fallback for the field above. Not every source writes ActiveCaloriesBurned — several popular
|
||||
> watches and fitness apps record only total energy for a session. When active calories are absent,
|
||||
> Amethyst uses total calories for the same field so the energy figure is not simply blank for those
|
||||
> users. When active calories are present they are always preferred, because total calories include
|
||||
> basal burn and would over-report the workout.
|
||||
> Fallback for the same figure. Several widely used watches and fitness apps record only total
|
||||
> energy for a session and never write ActiveCaloriesBurned. Where active calories are missing,
|
||||
> Amethyst uses total calories so the energy statistics are not simply blank for those users;
|
||||
> where active calories exist they are always preferred, because total calories include basal
|
||||
> burn and would overstate the workout.
|
||||
>
|
||||
> Benefit to the user: the feature works consistently regardless of which watch or fitness app they
|
||||
> use, instead of silently dropping a metric for a subset of devices.
|
||||
> Benefit to the user: the dashboard reports energy consistently regardless of which watch or app
|
||||
> they use, instead of silently omitting the metric for a subset of devices.
|
||||
|
||||
### READ_HEART_RATE — HeartRate
|
||||
|
||||
> Amethyst aggregates the average and maximum heart rate over the workout's time window and fills in
|
||||
> the post's two heart-rate fields. Heart rate is the standard measure of how hard an effort was and
|
||||
> is what makes two workouts of the same distance comparable — an easy recovery run and a hard
|
||||
> tempo run look identical without it. It is a headline field of the NIP-101e workout post format
|
||||
> Amethyst publishes.
|
||||
> Aggregated per workout into the average and maximum heart rate, then combined across the window
|
||||
> into a duration-weighted average heart rate (so a two-hour ride weighs more than a ten-minute
|
||||
> walk), a maximum for the period, the per-workout effort shown on each row of the recent list,
|
||||
> and the "Highest heart rate" best effort.
|
||||
>
|
||||
> Benefit to the user: their followers can see how hard the session actually was, not just how far
|
||||
> it went, without the user transcribing two more numbers by hand.
|
||||
> Benefit to the user: heart rate is how a user distinguishes an easy week from a hard one at the
|
||||
> same distance, and the standard signal for whether they are training too hard or too easily. It
|
||||
> is the single most informative intensity measure the dashboard can show.
|
||||
|
||||
### READ_STEPS — Steps (and StepsCadence)
|
||||
|
||||
> Amethyst aggregates the step count over the workout's time window and fills in the post's steps
|
||||
> field. For walking, running and hiking posts the step count is a primary metric — for a walk it is
|
||||
> often the metric the user cares about most — and it is one of the fields of the workout post
|
||||
> format.
|
||||
> Aggregated per workout and summed into the weekly step average and the "Most steps" best
|
||||
> effort.
|
||||
>
|
||||
> Benefit to the user: walk, run and hike posts carry the step count automatically.
|
||||
> Benefit to the user: for walking and hiking — and for many users the majority of their activity
|
||||
> — step count is the metric they actually track, and a weekly average is how they judge whether
|
||||
> they are keeping it up.
|
||||
>
|
||||
> Note on StepsCadence: Health Connect grants that data type under the same READ_STEPS permission.
|
||||
> Amethyst does not read, store, or publish cadence — it reads only the aggregated step count.
|
||||
> There is no separate permission available to request one without the other.
|
||||
> StepsCadence is granted by Health Connect under this same permission. Amethyst does not read,
|
||||
> store, or display cadence — it reads only the aggregated step count. There is no separate
|
||||
> permission that grants one without the other.
|
||||
|
||||
### READ_ELEVATION_GAINED — ElevationGained
|
||||
|
||||
> Amethyst aggregates elevation gained over the workout's time window and fills in the post's climb
|
||||
> field. Elevation is what distinguishes a flat ride or run from a hilly one — 30 km with 800 m of
|
||||
> climbing is a completely different effort from 30 km on the flat — and it is the defining metric
|
||||
> of a hiking post. It is one of the fields of the workout post format.
|
||||
> Aggregated per workout and summed into the weekly climb average and the "Biggest climb" best
|
||||
> effort, and shown per workout in the recent list.
|
||||
>
|
||||
> Benefit to the user: hill and trail workouts are described accurately in the post rather than
|
||||
> looking like flat ones.
|
||||
> Benefit to the user: elevation is what separates a flat week from a hard hilly one at identical
|
||||
> distance and time. Without it, a user training on hills sees no difference between a punishing
|
||||
> week and an easy one, and the dashboard misrepresents their training load.
|
||||
|
||||
## 4. Scope and data handling (state this alongside the table above)
|
||||
## 4. Scope and data handling
|
||||
|
||||
> - **Read-only.** Amethyst holds no write permissions and never writes to Health Connect.
|
||||
> - **Foreground only.** Reads happen only while the New Workout composer is on screen, in direct
|
||||
> response to the user opening it. Amethyst does not request READ_HEALTH_DATA_IN_BACKGROUND and
|
||||
> has no background worker, service or job that touches health data.
|
||||
> - **Last 7 days only.** Only sessions finishing in the previous 7 days are read. Amethyst does not
|
||||
> request READ_HEALTH_DATA_HISTORY.
|
||||
> - **No location.** Amethyst does not request READ_EXERCISE_ROUTE and never receives the GPS track
|
||||
> of a workout.
|
||||
> - **No transmission without an explicit user action.** Health data is used to populate an on-screen
|
||||
> form. Nothing leaves the device unless the user taps a suggestion, reviews the pre-filled post,
|
||||
> and publishes it — at which point the post goes to the Nostr relays that user configured. The
|
||||
> developer operates no server, so no health data is ever received, stored or processed by the
|
||||
> developer or any third party on the developer's behalf.
|
||||
> - **No secondary use.** Health data is never used for advertising, analytics, profiling, marketing
|
||||
> or sale, is never shared with data brokers or information-resellers, and is never used for
|
||||
> determining eligibility for insurance, credit or employment.
|
||||
> - **No persistence beyond the session.** Suggestions are held in memory while the composer is open.
|
||||
> - **Read-only.** Amethyst holds no Health Connect write permissions and never writes to it.
|
||||
> - **Foreground only.** Reads happen only while the user has the My Fitness dashboard or the
|
||||
> workout composer on screen. Amethyst does not request READ_HEALTH_DATA_IN_BACKGROUND and has
|
||||
> no background worker, service or job that touches health data.
|
||||
> - **Last four weeks only.** The dashboard reads a rolling 28-day window. Amethyst does not
|
||||
> request READ_HEALTH_DATA_HISTORY and cannot see anything older.
|
||||
> - **No location.** Amethyst does not request READ_EXERCISE_ROUTE and never receives the GPS
|
||||
> track of a workout.
|
||||
> - **No transmission to produce the feature.** The dashboard is computed on the device and
|
||||
> displayed. Nothing is uploaded to render it. The developer operates no server, so no health
|
||||
> data is ever received, stored or processed by the developer or any third party acting for the
|
||||
> developer.
|
||||
> - **Sharing is separate, explicit and per-item.** A user may choose to publish one workout as a
|
||||
> post. That takes a deliberate tap, shows the user exactly what will be published, and requires
|
||||
> them to confirm. It is never automatic, never bulk, and never a condition of using the
|
||||
> tracking feature. Published posts go to the Nostr relays that user configured.
|
||||
> - **No secondary use.** Health data is never used for advertising, analytics, profiling,
|
||||
> marketing or sale, never shared with data brokers, and never used to determine eligibility for
|
||||
> insurance, credit or employment.
|
||||
> - **No persistence beyond the session.** The summary is held in memory while the screen is open.
|
||||
> The only thing stored is whatever the user chose to publish, as an ordinary post.
|
||||
> - **Revocable, and optional.** The user can turn the feature off at Settings → Compose Settings →
|
||||
> "Suggest workouts to share", or revoke the permissions in Health Connect, at any time; the rest
|
||||
> of the app is unaffected.
|
||||
> - **In-app rationale.** Amethyst handles both ACTION_SHOW_PERMISSIONS_RATIONALE (Android 13 and
|
||||
> - **Revocable and optional.** The user can revoke the permissions in Health Connect at any time —
|
||||
> the dashboard immediately returns to its prompt — or turn the composer suggestions off under
|
||||
> Settings → Compose Settings. The rest of the app is unaffected.
|
||||
> - **In-app rationale.** Amethyst handles ACTION_SHOW_PERMISSIONS_RATIONALE (Android 13 and
|
||||
> below) and ACTION_VIEW_PERMISSION_USAGE + CATEGORY_HEALTH_PERMISSIONS (Android 14+), showing a
|
||||
> screen that lists each data type, its purpose, and a link to the full privacy policy. The same
|
||||
> screen is reachable in-app from the "What Amethyst reads" link on the Connect card, before the
|
||||
> permission request.
|
||||
> - **Privacy policy:** https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md — see the
|
||||
> section "Health and fitness data (Health Connect)", which lists every data type, its purpose, and
|
||||
> each of the limits above.
|
||||
> screen listing each data type, its purpose and a link to the privacy policy. The same screen
|
||||
> is reachable from "What Amethyst reads" before any permission is requested.
|
||||
> - **Privacy policy:** https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md — see
|
||||
> "Health and fitness data (Health Connect)".
|
||||
|
||||
@@ -13,23 +13,30 @@ therefore it works.</p>
|
||||
<li>Browse dedicated feeds: articles, pictures, short videos, live streams, podcasts, music, communities,
|
||||
classifieds, calendars and more.</li>
|
||||
<li>Send and receive Bitcoin Lightning zaps.</li>
|
||||
<li>Share your workouts — see below.</li>
|
||||
<li>Track your training and share a workout — see below.</li>
|
||||
</ul>
|
||||
<p><b>Workouts</b></p>
|
||||
<p>Amethyst has a Workouts section where you can publish a summary of a run, ride, walk, hike, swim, row
|
||||
or gym session so the people who follow you can see what you did. Open the left drawer, tap
|
||||
<i>Workouts</i>, then the + button to write one.</p>
|
||||
<p>Typing every number by hand is tedious, so Amethyst can pre-fill the post from a workout your watch or
|
||||
fitness app already saved to Android Health Connect (Samsung Health, Google Fit, Fitbit, Garmin Connect,
|
||||
Strava and others all write there). With your permission, Amethyst reads finished workouts from the last
|
||||
7 days and offers them as one-tap suggestions: pick one and the activity, duration, distance, calories,
|
||||
heart rate, steps and elevation gain are filled in for you. You review the post and decide whether to
|
||||
publish it.</p>
|
||||
<p>This is optional and read-only. Amethyst asks for Health Connect permission only when you open the New
|
||||
Workout composer, never reads in the background, never writes to Health Connect, never asks for your
|
||||
exercise route or location, and never uploads anything until you publish a post yourself. You can switch
|
||||
it off under Settings → Compose Settings, or revoke access in Health Connect at any time. Full
|
||||
details are in the privacy policy at
|
||||
<p><b>My Fitness — track your training</b></p>
|
||||
<p>Amethyst includes a personal training dashboard. Open the left drawer and tap <i>My Fitness</i>,
|
||||
under <i>You</i>. It summarises the workouts you have logged in Amethyst, and — with your
|
||||
permission — the ones your watch or fitness app has already saved to Android Health Connect
|
||||
(Samsung Health, Google Fit, Fitbit, Garmin Connect, Strava and others all write there). It shows
|
||||
you:</p>
|
||||
<ul>
|
||||
<li>How much you trained this week, and whether that is up or down on last week.</li>
|
||||
<li>Your four-week weekly average for time, distance, steps and climb.</li>
|
||||
<li>Where your time goes — running, cycling, walking, hiking, swimming, rowing, the gym.</li>
|
||||
<li>Your best efforts: longest distance, longest workout, biggest climb, most steps, highest heart rate.</li>
|
||||
<li>How many days you trained, and your current streak of consecutive active days.</li>
|
||||
</ul>
|
||||
<p>All of it is computed on your phone and shown to you. Nothing is uploaded to produce it, and you
|
||||
never have to post anything to use it.</p>
|
||||
<p><b>Sharing a workout (optional)</b></p>
|
||||
<p>If you want to tell your followers about a particular session, tap <i>Share this workout</i> on any
|
||||
row. Amethyst opens a post pre-filled with that workout's figures; you review it and decide whether to
|
||||
publish. One workout at a time, always with your confirmation, never automatic.</p>
|
||||
<p>The Health Connect integration is read-only. Amethyst never writes to Health Connect, never reads in
|
||||
the background, never asks for your exercise route or location, and only ever looks at the last four
|
||||
weeks. You can revoke access in Health Connect at any time. Full details are in the privacy policy at
|
||||
<a href='https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md' target='_blank' rel='nofollow noopener'>github.com/vitorpamplona/amethyst</a>.</p>
|
||||
<p>Amethyst is free and open source (MIT). There is no Amethyst server and no Amethyst account: your
|
||||
posts go straight to the Nostr relays you configure.</p>
|
||||
|
||||
Reference in New Issue
Block a user