From 3397c4198548a103b464615fc0b901b39fbdb922 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 15 Sep 2026 14:09:31 +0000
Subject: [PATCH] =?UTF-8?q?feat:=20My=20Fitness=20=E2=80=94=20a=20personal?=
=?UTF-8?q?=20training=20dashboard=20over=20Health=20Connect?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Google rejected the Health Connect declaration a second time, this time as
"use of permission is not a permitted/valid use case". That was correct. The
policy admits six use cases; the nearest is "Fitness, wellness and coaching —
apps designed to help users track, monitor, analyze, manage, and improve their
physical fitness". Amethyst did none of those. Its Workouts section was a feed
of other people's kind 1301 events plus a composer, so the honest description
of the integration was "read health data in order to publish it", which is not
an approved use case and reads as the prohibited "publicly displaying or
socially sharing sensitive data".
So the app now does the thing the permissions are for. My Fitness (Workouts →
chart icon) summarises the user's own training back to them: this week against
last week, the four-week weekly average, where the time goes by activity, best
efforts, active days and a consecutive-day streak. It is computed on device,
requires no account interaction, sends nothing, and never asks the user to
post. Publishing becomes one optional per-workout action on a dashboard row.
Every permission now earns itself against that screen rather than against the
composer: exercise drives counts, time, breakdown, active days and streak;
distance, calories, steps and elevation drive weekly totals, trends and bests;
heart rate drives the duration-weighted effort average and the max.
WorkoutStats holds all the arithmetic as pure Kotlin so it is testable without
Health Connect, a device, or Compose — 13 tests cover window trimming, the
seven-day split, absent-metric handling, duration weighting, ordering, streak
edges (a rest day today does not break a live streak; a missed day does) and
the weekly average.
The window is 28 days, not 30: Health Connect serves 30 days without
READ_HEALTH_DATA_HISTORY, which Amethyst does not request, and four whole weeks
keeps the "this week vs last week" comparison inside that limit.
The declaration, PRIVACY.md and the store listing are rewritten to lead with
tracking and describe sharing as the secondary, per-item, user-confirmed action
it now is.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_019egdJyBHnrATZHjs86up8f
---
PRIVACY.md | 32 +-
.../workouts/health/HealthConnectManager.kt | 8 +-
.../service/workouts/health/WorkoutStats.kt | 265 ++++++++
.../amethyst/ui/navigation/AppNavigation.kt | 2 +
.../amethyst/ui/navigation/routes/Routes.kt | 3 +
.../topbars/UserDrawerSearchTopBar.kt | 5 +
.../loggedIn/workouts/WorkoutsTopBar.kt | 24 +-
.../workouts/fitness/MyFitnessFormat.kt | 79 +++
.../workouts/fitness/MyFitnessScreen.kt | 591 ++++++++++++++++++
.../workouts/fitness/MyFitnessViewModel.kt | 92 +++
.../suggestion/DetectedWorkoutCarousel.kt | 2 +-
.../workouts/health/WorkoutStatsTest.kt | 249 ++++++++
.../composeResources/values/strings.xml | 37 ++
docs/health-connect-play-declaration.md | 254 ++++----
.../android/en-US/full_description.txt | 38 +-
15 files changed, 1529 insertions(+), 152 deletions(-)
create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutStats.kt
create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/fitness/MyFitnessFormat.kt
create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/fitness/MyFitnessScreen.kt
create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/fitness/MyFitnessViewModel.kt
create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutStatsTest.kt
diff --git a/PRIVACY.md b/PRIVACY.md
index 42a65123c3..ab3c669159 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -3,7 +3,7 @@
**App:** Amethyst (Android Nostr client)
**Publisher:** Vitor Pamplona
**Contact:** amethyst@vitorpamplona.com
-**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,37 @@ 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 (Workouts → the chart icon) 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.
+
+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
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt
index 8ffd1397d7..5c9c9befef 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt
@@ -128,12 +128,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 {
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 +150,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
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutStats.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutStats.kt
new file mode 100644
index 0000000000..eac9ea8ff0
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutStats.kt
@@ -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.service.workouts.health
+
+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,
+ val bests: List,
+ val activeDays: Int,
+ val currentStreakDays: Int,
+ val workouts: List,
+ ) {
+ 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,
+ 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): 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): List =
+ workouts
+ .groupBy { it.exercise }
+ .map { (exercise, list) -> ActivityTotals(exercise, total(list)) }
+ .sortedWith(
+ compareByDescending { 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): List =
+ 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,
+ zone: ZoneId,
+ ): Set = 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,
+ 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()
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
index b02470bd75..8f58c62abb 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
@@ -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 { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEndArgs { PicturesScreen(accountViewModel, nav, it.attachments, it.message) }
composableFromEnd { WorkoutsScreen(accountViewModel, nav) }
+ composableFromEnd { MyFitnessScreen(accountViewModel, nav) }
composableFromEnd { GitRepositoriesScreen(accountViewModel, nav) }
composableFromEnd { HighlightsScreen(accountViewModel, nav) }
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
index 02039f5dba..821385270f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
@@ -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()
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt
index f74d25ca16..e834ee9e99 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.navigation.topbars
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
@@ -66,6 +67,7 @@ fun UserDrawerSearchTopBar(
accountViewModel: AccountViewModel,
nav: INav,
baseFilter: SearchQuery?,
+ extraActions: @Composable RowScope.() -> Unit = {},
content: @Composable () -> Unit,
) {
ShorterTopAppBar(
@@ -80,6 +82,9 @@ fun UserDrawerSearchTopBar(
},
navigationIcon = { TopBarNavigationIcon(accountViewModel, nav) },
actions = {
+ // Screen-specific actions sit left of search, which stays the rightmost affordance
+ // everywhere it appears.
+ extraActions()
IconButton(onClick = { nav.nav(searchRouteFor(baseFilter)) }) {
SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText)
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsTopBar.kt
index d92d706cf1..3b172486c6 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsTopBar.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsTopBar.kt
@@ -20,22 +20,30 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.resources.Res
+import com.vitorpamplona.amethyst.commons.resources.my_fitness_open
import com.vitorpamplona.amethyst.commons.resources.select_list_to_filter
import com.vitorpamplona.amethyst.commons.search.SearchSeed
import com.vitorpamplona.amethyst.commons.search.asSearchQuery
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
+import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
+import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
+import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
@Composable
@@ -51,7 +59,21 @@ fun WorkoutsTopBar(
val me = accountViewModel.userProfile().pubkeyHex
val seed = remember(list, me) { SearchSeed.merge(SearchSeed.ofKinds(WorkoutRecordEvent.KIND), list.asSearchQuery(me)) }
- UserDrawerSearchTopBar(accountViewModel, nav, seed) {
+ UserDrawerSearchTopBar(
+ accountViewModel,
+ nav,
+ seed,
+ extraActions = {
+ IconButton(onClick = { nav.nav(Route.MyFitness) }) {
+ Icon(
+ symbol = MaterialSymbols.AutoMirrored.ShowChart,
+ contentDescription = stringRes(Res.string.my_fitness_open),
+ modifier = Size22Modifier,
+ tint = MaterialTheme.colorScheme.placeholderText,
+ )
+ }
+ },
+ ) {
WorkoutsTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/fitness/MyFitnessFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/fitness/MyFitnessFormat.kt
new file mode 100644
index 0000000000..576b2c7d38
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/fitness/MyFitnessFormat.kt
@@ -0,0 +1,79 @@
+/*
+ * 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_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. */
+internal fun formatDuration(totalSeconds: Long): String {
+ if (totalSeconds <= 0) return "0m"
+
+ val hours = totalSeconds / 3600
+ val minutes = (totalSeconds % 3600) / 60
+
+ return when {
+ hours > 0 -> "${hours}h ${minutes}m"
+ minutes > 0 -> "${minutes}m"
+ else -> "${totalSeconds}s"
+ }
+}
+
+/** 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)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/fitness/MyFitnessScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/fitness/MyFitnessScreen.kt
new file mode 100644
index 0000000000..f31f9aa8a5
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/fitness/MyFitnessScreen.kt
@@ -0,0 +1,591 @@
+/*
+ * 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.Scaffold
+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.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_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_unavailable
+import com.vitorpamplona.amethyst.commons.resources.my_fitness_unit_bpm
+import com.vitorpamplona.amethyst.commons.resources.my_fitness_unit_kcal
+import com.vitorpamplona.amethyst.commons.resources.my_fitness_vs_last_week
+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.DetectedWorkout
+import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager
+import com.vitorpamplona.amethyst.service.workouts.health.WorkoutStats
+import com.vitorpamplona.amethyst.ui.navigation.navs.INav
+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.
+ */
+@Composable
+fun MyFitnessScreen(
+ accountViewModel: AccountViewModel,
+ nav: INav,
+) {
+ val viewModel: MyFitnessViewModel = viewModel()
+ val context = LocalContext.current
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ 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 {}
+ }
+
+ Scaffold(
+ topBar = { TopBarWithBackButton(stringRes(Res.string.my_fitness_title), nav) },
+ ) { padding ->
+ Surface(modifier = Modifier.padding(padding)) {
+ when (val current = state) {
+ MyFitnessViewModel.State.Loading -> CenteredBox { CircularProgressIndicator() }
+
+ MyFitnessViewModel.State.Unavailable ->
+ CenteredBox {
+ Text(
+ text = stringRes(Res.string.my_fitness_unavailable),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 32.dp),
+ )
+ }
+
+ MyFitnessViewModel.State.NeedsPermission ->
+ ConnectPrompt(
+ onDetails = { context.startActivity(Intent(context, HealthConnectRationaleActivity::class.java)) },
+ onConnect = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) },
+ )
+
+ is MyFitnessViewModel.State.Ready ->
+ if (current.report.isEmpty) {
+ CenteredBox {
+ Text(
+ text = stringRes(Res.string.my_fitness_empty),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 32.dp),
+ )
+ }
+ } else {
+ Dashboard(current.report) { workout, label ->
+ nav.nav(workout.toNewWorkoutRoute(label))
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CenteredBox(content: @Composable () -> Unit) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) { content() }
+}
+
+@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,
+ onShare: (DetectedWorkout, String) -> Unit,
+) {
+ val miles = remember { prefersMiles() }
+
+ Column(
+ modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(18.dp),
+ ) {
+ 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)) {
+ StatGrid(
+ listOf(
+ StatCell(
+ stringRes(Res.string.my_fitness_workouts),
+ report.thisWeek.workoutCount.toString(),
+ null,
+ WorkoutStats.percentChange(report.thisWeek.workoutCount.toDouble(), report.previousWeek.workoutCount.toDouble()),
+ ),
+ StatCell(
+ stringRes(Res.string.my_fitness_time),
+ formatDuration(report.thisWeek.durationSeconds),
+ null,
+ WorkoutStats.percentChange(report.thisWeek.durationSeconds.toDouble(), report.previousWeek.durationSeconds.toDouble()),
+ ),
+ StatCell(
+ stringRes(Res.string.my_fitness_distance),
+ formatDistanceValue(report.thisWeek.distanceMeters, miles),
+ distanceUnit(miles),
+ WorkoutStats.percentChange(report.thisWeek.distanceMeters, report.previousWeek.distanceMeters),
+ ),
+ 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()),
+ ),
+ ),
+ )
+ }
+}
+
+@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))
+ 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()
+ 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),
+ )
+ 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()
+ 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) {
+ 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
+ },
+ )
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/fitness/MyFitnessViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/fitness/MyFitnessViewModel.kt
new file mode 100644
index 0000000000..6246ded88b
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/fitness/MyFitnessViewModel.kt
@@ -0,0 +1,92 @@
+/*
+ * 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.service.workouts.health.HealthConnectManager
+import com.vitorpamplona.amethyst.service.workouts.health.WorkoutStats
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+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], read from Health Connect and summarised by [WorkoutStats].
+ *
+ * Nothing here publishes or touches the network. The dashboard is the user looking at their
+ * own numbers; sharing one of them is a separate, deliberate action from the workout list.
+ */
+@Stable
+class MyFitnessViewModel : ViewModel() {
+ @Immutable
+ sealed interface State {
+ /** First load, or a reload after a permission change. */
+ data object Loading : State
+
+ /** No Health Connect provider on this device — nothing to offer. */
+ data object Unavailable : State
+
+ /** Provider present, permissions not granted yet. */
+ data object NeedsPermission : State
+
+ /** Granted and summarised. [WorkoutStats.Report.isEmpty] covers "nothing recorded yet". */
+ data class Ready(
+ val report: WorkoutStats.Report,
+ ) : State
+ }
+
+ private val _state = MutableStateFlow(State.Loading)
+ val state: StateFlow = _state.asStateFlow()
+
+ private var manager: HealthConnectManager? = null
+
+ /**
+ * Refreshes the dashboard. Safe to call on every resume: it re-checks permissions first,
+ * so revoking access in Health Connect drops the screen back to its prompt rather than
+ * leaving stale numbers on display.
+ */
+ fun refresh(context: Context) {
+ viewModelScope.launch {
+ if (!HealthConnectManager.isAvailable(context)) {
+ _state.value = State.Unavailable
+ return@launch
+ }
+
+ val hc = manager ?: HealthConnectManager(context.applicationContext).also { manager = it }
+
+ if (!hc.hasAllPermissions()) {
+ _state.value = State.NeedsPermission
+ return@launch
+ }
+
+ val now = Instant.now()
+ val workouts = hc.readWorkouts(now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)), now)
+ _state.value = State.Ready(WorkoutStats.report(workouts, now))
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt
index 6a0a88fd85..58528ea2d0 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt
@@ -114,7 +114,7 @@ fun DetectedWorkoutCarousel(
workouts =
if (ok) {
val since = Instant.now().minus(Duration.ofDays(HealthConnectManager.LOOKBACK_DAYS))
- manager.readNewWorkouts(since).sortedByDescending { it.startTimeEpochSeconds }
+ manager.readWorkouts(since).sortedByDescending { it.startTimeEpochSeconds }
} else {
emptyList()
}
diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutStatsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutStatsTest.kt
new file mode 100644
index 0000000000..b8f728ebf3
--- /dev/null
+++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutStatsTest.kt
@@ -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.service.workouts.health
+
+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))
+ }
+}
diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml
index 6cee50140c..019c73f205 100644
--- a/commons/src/commonMain/composeResources/values/strings.xml
+++ b/commons/src/commonMain/composeResources/values/strings.xml
@@ -683,6 +683,43 @@
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.
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.
Read the full privacy policy
+ My Fitness
+ My Fitness
+ Last 4 weeks
+ Health Connect serves the last 30 days, so Amethyst summarises four whole weeks. Nothing here leaves your phone.
+ This week
+ vs last week
+ Weekly average
+ Workouts
+ Active days
+ Day streak
+ Time
+ Distance
+ Calories
+ Steps
+ Climb
+ Avg HR
+ Max HR
+ By activity
+ Best efforts
+ Longest distance
+ Longest workout
+ Biggest climb
+ Most steps
+ Highest heart rate
+ Recent workouts
+ No workouts recorded in the last 4 weeks. Once your watch or fitness app saves one, it shows up here.
+ Health Connect is not available on this device, so Amethyst has no workouts to summarise.
+ Track your training
+ Connect Health Connect to see your weekly totals, week-over-week trend, best efforts and training streak. Everything stays on your phone.
+ Connect
+ Share this workout
+ bpm
+ kcal
+ km
+ mi
+ m
+ ft
No nSites found yet.
Reload
The in-app browser needs Android 11 or newer.
diff --git a/docs/health-connect-play-declaration.md b/docs/health-connect-play-declaration.md
index 5bc2ed8b12..09fc252b6b 100644
--- a/docs/health-connect-play-declaration.md
+++ b/docs/health-connect-play-declaration.md
@@ -1,168 +1,190 @@
# 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 reached from the Workouts screen 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.
+
+Related code:
+
+- `service/workouts/health/WorkoutStats.kt` — all dashboard arithmetic; pure, unit-tested.
+- `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 reads the workouts the user's watch or fitness
+> app has already saved to Health Connect and turns them into a picture of how that person is
+> 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. 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), and under **Feeds** tap **Workouts**.
+> 3. In the top bar, tap the **chart icon** to open **My Fitness**.
+> 4. The screen explains what will be read and offers **What Amethyst reads** (the full rationale
+> screen) and **Connect**. Tap Connect and grant the permissions.
+> 5. The dashboard appears: "This week" totals with the change against last week; the streak,
+> active-days and workout-count tiles; the four-week weekly average; the per-activity
+> breakdown; best efforts; and the recent-workout list.
+> 6. Everything above is the tracking feature. To see the optional sharing path, tap **Share this
+> workout** on any row — it opens a pre-filled composer that the user must confirm.
>
-> 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.
+> Note for a fresh test device: the dashboard needs Health Connect to actually hold finished
+> exercise sessions from the last four weeks, written by some fitness app or watch. With an empty
+> Health Connect database it correctly reports that nothing has been recorded.
## 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)".
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index 5f11d74d80..acab602da2 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -13,23 +13,29 @@ therefore it works.
Browse dedicated feeds: articles, pictures, short videos, live streams, podcasts, music, communities,
classifieds, calendars and more.
Send and receive Bitcoin Lightning zaps.
-Share your workouts — see below.
+Track your training and share a workout — see below.
-Workouts
-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
-Workouts, then the + button to write one.
-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.
-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
+
My Fitness — track your training
+Amethyst includes a personal training dashboard. Open the left drawer, tap Workouts, then
+the chart icon in the top bar. With your permission it reads the workouts 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) and shows you:
+
+- How much you trained this week, and whether that is up or down on last week.
+- Your four-week weekly average for time, distance, steps and climb.
+- Where your time goes — running, cycling, walking, hiking, swimming, rowing, the gym.
+- Your best efforts: longest distance, longest workout, biggest climb, most steps, highest heart rate.
+- How many days you trained, and your current streak of consecutive active days.
+
+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.
+Sharing a workout (optional)
+If you want to tell your followers about a particular session, tap Share this workout 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.
+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
github.com/vitorpamplona/amethyst.
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.