From 3397c4198548a103b464615fc0b901b39fbdb922 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 14:09:31 +0000 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20My=20Fitness=20=E2=80=94=20a=20pe?= =?UTF-8?q?rsonal=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.

    From 491756979a0e6ea3d28360a863aeec0133859f92 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 17:19:04 +0000 Subject: [PATCH 02/11] feat: give My Fitness its own navigation destination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My Fitness was reachable only from an icon in the Workouts top bar, which buried the one screen the Health Connect permissions exist to serve. It is now a NavBarItem, so it appears in the drawer and can be pinned to the bottom bar like any other destination. It sits under "You" rather than "Feeds": every other Feeds entry is a stream of other people's events, while this is the user's own training — the same reason Profile, Drafts and Wallet live there. Four places had to stay in sync, three of them guarded: DrawerSectionsTest and BottomBarCategoriesTest fail the build when a catalog id is missing from a section or a picker category, and BottomBarFeedPreloaders' exhaustive `when` caught the fourth. My Fitness reads Health Connect on the device and issues no REQs, so its preload branch is deliberately empty. Adding the enum value needs no migration: nav items are persisted by name and unknown names are already dropped on decode, so an older build ignores the row rather than failing the settings blob. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019egdJyBHnrATZHjs86up8f --- .../amethyst/ui/navigation/bottombars/NavBarItem.kt | 9 +++++++++ .../amethyst/ui/navigation/drawer/DrawerSections.kt | 1 + .../ui/screen/loggedIn/BottomBarFeedPreloaders.kt | 4 ++++ amethyst/src/main/res/values/strings.xml | 4 ++++ 4 files changed, 18 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 3c4fcef1d3..2050b16fcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -40,6 +40,7 @@ enum class NavBarItem { DISCOVER, NOTIFICATIONS, PROFILE, + MY_FITNESS, MY_LISTS, BOOKMARKS, WEB_BOOKMARKS, @@ -255,6 +256,13 @@ val NavBarCatalog: Map = icon = MaterialSymbols.DirectionsRun, resolveRoute = { Route.Workouts }, ), + NavBarItem.MY_FITNESS to + NavBarItemDef( + id = NavBarItem.MY_FITNESS, + labelRes = R.string.my_fitness, + icon = MaterialSymbols.AutoMirrored.ShowChart, + resolveRoute = { Route.MyFitness }, + ), NavBarItem.GIT_REPOSITORIES to NavBarItemDef( id = NavBarItem.GIT_REPOSITORIES, @@ -505,6 +513,7 @@ val BottomBarCategories: List = MaterialSymbols.AccountCircle, listOf( NavBarItem.PROFILE, + NavBarItem.MY_FITNESS, NavBarItem.MY_LISTS, NavBarItem.BOOKMARKS, NavBarItem.WEB_BOOKMARKS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt index 17014d4dd4..3bb35a47a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt @@ -107,6 +107,7 @@ private val DrawerNavigateItems: List = private val DrawerYouItems: List = listOf( NavBarItem.PROFILE, + NavBarItem.MY_FITNESS, NavBarItem.MY_LISTS, NavBarItem.BOOKMARKS, NavBarItem.WEB_BOOKMARKS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 809f89d5da..5575fdf25b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -117,6 +117,10 @@ private fun PreloadFor( // Favorite apps is a device-local launcher grid — nothing to preload from relays. NavBarItem.FAVORITE_APPS -> {} + // My Fitness summarises the user's own Health Connect data on the device. It issues no + // REQs, so there is nothing to warm up from relays. + NavBarItem.MY_FITNESS -> {} + NavBarItem.CALENDARS, NavBarItem.CALENDAR_COLLECTIONS, -> CalendarsFilterAssemblerSubscription(accountViewModel) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a67ef65287..eb7f3259d4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2771,4 +2771,8 @@ rationale screen. Needs to be an Android resource (not a commons Compose resource) because android:label on the manifest entry can only reference @string/. --> Health Connect and Amethyst + + + My Fitness From 40ab7e6ed2f03a04aefee06622061ff9673564a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 19:20:09 +0000 Subject: [PATCH 03/11] feat: build My Fitness from the whole training log, not just Health Connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My Fitness was a Health Connect viewer: the dashboard was empty without the permission, and it did not count workouts the user had logged in Amethyst's own composer. The app asked you to record a workout and then ignored it. The log is now built from both sources Amethyst can learn from — Health Connect sessions, and the user's own published kind 1301 events (their manual entries, and anything posted from another NIP-101e client). Neither source alone is the truth: Health Connect cannot see a workout typed by hand or one posted elsewhere, and cannot look past 30 days; the published events carry none of the device detail. The consequence that matters: My Fitness now works with no health permissions at all. Health Connect became an enhancement rather than a gate — a banner over a working dashboard instead of a wall in front of an empty one. That is also the honest form of the Play declaration's argument, which the previous build could not make: Health Connect enriches a tracking feature that exists on its own rather than being that feature. Where a workout appears in both — the usual case once someone shares one their watch recorded — the Health Connect copy wins: it keeps the metrics the post dropped and a recorded start time rather than a publish timestamp. Matching is on activity type plus a 15-minute start window, wide because a published event often carries only the minute the user typed. Matching on time alone would swallow a genuine second session of a different activity, so the type must agree. A published workout with no start tag falls back to created_at. That is the publish time, not the workout time, which is close enough for the day-level buckets (streaks, active days, the week split) and is the only signal a manual entry has. 17 new tests: the dedupe boundary in both directions, same-time-different- activity, ordering, and the mapper's unit conversions (miles and feet land as metres — otherwise every total is out by 1.6x) plus its zero-is-absent rule. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019egdJyBHnrATZHjs86up8f --- PRIVACY.md | 2 + .../workouts/health/DetectedWorkout.kt | 17 +- .../service/workouts/health/TrainingLog.kt | 101 ++++++++++++ .../workouts/fitness/MyFitnessScreen.kt | 82 +++++++--- .../workouts/fitness/MyFitnessViewModel.kt | 114 ++++++++++--- .../health/PublishedWorkoutMappingTest.kt | 144 +++++++++++++++++ .../workouts/health/TrainingLogTest.kt | 152 ++++++++++++++++++ .../composeResources/values/strings.xml | 3 +- docs/health-connect-play-declaration.md | 24 ++- 9 files changed, 583 insertions(+), 56 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/TrainingLog.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/PublishedWorkoutMappingTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/TrainingLogTest.kt diff --git a/PRIVACY.md b/PRIVACY.md index ab3c669159..1363eaf653 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -36,6 +36,8 @@ Amethyst's **My Fitness** screen (Workouts → the chart icon) summarises your o This is what the health permissions are for. The summary is computed on your phone and shown to you; nothing is sent anywhere to produce it, and you never have to post anything to use it. +My Fitness also counts the workouts you have logged in Amethyst itself, so it works whether or not you connect Health Connect. Connecting adds the sessions your watch recorded and the details a hand-typed workout does not carry — heart rate, steps and climb. + Separately, you may choose to publish one workout as a Nostr post (a NIP-101e kind 1301 event) so the people who follow you can see it. That takes a deliberate tap on "Share this workout", shows you the pre-filled post, and waits for you to confirm. It is never automatic. The feature is optional and off until you grant the permissions. Amethyst asks for them only when you open My Fitness or the New Workout composer — never on first launch. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt index f8f8637e55..f92e7d141f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt @@ -23,8 +23,18 @@ package com.vitorpamplona.amethyst.service.workouts.health import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType +/** Where a workout in the training log came from. */ +enum class WorkoutOrigin { + /** Recorded by a watch or fitness app and read from Health Connect. */ + HEALTH_CONNECT, + + /** A kind 1301 the user published themselves — typed into Amethyst, or posted from another client. */ + PUBLISHED, +} + /** - * A finished workout read from Health Connect and mapped to the fields Amethyst + * A finished workout in the user's training log: read from Health Connect, or + * recovered from a kind 1301 they published. Carries the fields Amethyst * can publish as a NIP-101e kind 1301 event. Platform-neutral and free of any * Health Connect types so it can feed the navigation route and the suggestion * UI directly. @@ -49,6 +59,11 @@ data class DetectedWorkout( val elevationGainMeters: Double?, /** Human-readable name of the app/device that wrote the record (e.g. "Samsung Health"). */ val source: String, + /** + * Which store this came from. Defaults to Health Connect because that is where the type + * originated and where every constructor but the published-event mapper still builds from. + */ + val origin: WorkoutOrigin = WorkoutOrigin.HEALTH_CONNECT, /** * How many Health Connect sessions this workout represents. 1 for a raw * session; higher when [WorkoutMerger] combined several close-by same-type diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/TrainingLog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/TrainingLog.kt new file mode 100644 index 0000000000..639ccee6c4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/TrainingLog.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.workouts.health + +import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent +import kotlin.math.abs + +/** + * The user's training log: everything they have done, from both places Amethyst can learn about + * it. + * + * Health Connect knows what their watch recorded. It does not know about a workout they typed + * into Amethyst by hand, or one they posted from another NIP-101e client, and it cannot see + * further back than 30 days. Their own kind 1301 events know all of those and none of the + * device detail. Neither source alone is the truth, so the dashboard reads both. + * + * The practical consequence is that My Fitness works with no health permissions at all — a user + * who never connects Health Connect still gets a summary of what they have logged. + */ +object TrainingLog { + /** + * How far apart two records of the same activity can start and still be judged the same + * workout. Generous on purpose: a published event often carries only the minute the user + * typed, or the time the post went out, while Health Connect has the exact second. + */ + const val DEDUPE_TOLERANCE_SECONDS = 900L + + /** + * Combines both sources into one log, newest first. + * + * Where the same workout appears in both — the usual case once a user shares one that came + * from their watch — the Health Connect copy wins: it carries the metrics the published + * event may have dropped (heart rate, steps, climb), and its start time is the recorded one + * rather than a publish timestamp. + */ + fun merge( + healthConnect: List, + published: List, + ): List { + val deduped = + published.filterNot { candidate -> + healthConnect.any { it.isProbablySameWorkoutAs(candidate) } + } + + return (healthConnect + deduped).sortedByDescending { it.startTimeEpochSeconds } + } + + private fun DetectedWorkout.isProbablySameWorkoutAs(other: DetectedWorkout): Boolean = + exercise == other.exercise && + abs(startTimeEpochSeconds - other.startTimeEpochSeconds) <= DEDUPE_TOLERANCE_SECONDS +} + +/** + * Reads a published workout back into the training log, or null when it carries nothing a + * summary can use — no recognisable activity, or no duration. + * + * Start time prefers the explicit `start` tag and falls back to the event's own `created_at`. + * That fallback is the publish time, not the workout time: a manual entry has no better signal, + * and for day-level bucketing (streaks, active days, the week split) it is close enough, since + * people post a workout the same day they do it. + */ +fun WorkoutRecordEvent.toDetectedWorkout(): DetectedWorkout? { + val activity = activityType() ?: return null + + val duration = durationSeconds() ?: return null + if (duration <= 0) return null + + return DetectedWorkout( + id = id, + exercise = activity, + title = title()?.takeIf { it.isNotBlank() }, + startTimeEpochSeconds = workoutStartTime() ?: workoutStart() ?: createdAt, + durationSeconds = duration, + distanceMeters = distance()?.toMeters()?.takeIf { it > 0 }, + calories = calories()?.takeIf { it > 0 }, + avgHeartRate = avgHeartRate()?.takeIf { it > 0 }, + maxHeartRate = maxHeartRate()?.takeIf { it > 0 }, + steps = steps()?.takeIf { it > 0 }, + elevationGainMeters = elevationGain()?.toMeters()?.takeIf { it > 0 }, + source = workoutSource() ?: "", + origin = WorkoutOrigin.PUBLISHED, + ) +} 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 index f31f9aa8a5..3af4c0422d 100644 --- 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 @@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.commons.resources.my_fitness_best_most_steps import com.vitorpamplona.amethyst.commons.resources.my_fitness_bests import com.vitorpamplona.amethyst.commons.resources.my_fitness_by_activity import com.vitorpamplona.amethyst.commons.resources.my_fitness_calories +import com.vitorpamplona.amethyst.commons.resources.my_fitness_connect_banner import com.vitorpamplona.amethyst.commons.resources.my_fitness_connect_button import com.vitorpamplona.amethyst.commons.resources.my_fitness_connect_message import com.vitorpamplona.amethyst.commons.resources.my_fitness_connect_title @@ -82,7 +83,6 @@ import com.vitorpamplona.amethyst.commons.resources.my_fitness_streak import com.vitorpamplona.amethyst.commons.resources.my_fitness_this_week import com.vitorpamplona.amethyst.commons.resources.my_fitness_time import com.vitorpamplona.amethyst.commons.resources.my_fitness_title -import com.vitorpamplona.amethyst.commons.resources.my_fitness_unavailable import com.vitorpamplona.amethyst.commons.resources.my_fitness_unit_bpm import com.vitorpamplona.amethyst.commons.resources.my_fitness_unit_kcal import com.vitorpamplona.amethyst.commons.resources.my_fitness_vs_last_week @@ -120,6 +120,10 @@ fun MyFitnessScreen( val context = LocalContext.current val state by viewModel.state.collectAsStateWithLifecycle() + // Which account's workouts to summarise. Re-runs on an account switch, which resets the + // dashboard to Loading rather than showing the previous user's numbers. + viewModel.init(accountViewModel.userProfile().pubkeyHex) + val permissionLauncher = rememberLauncherForActivityResult(PermissionController.createRequestPermissionResultContract()) { viewModel.refresh(context) @@ -139,34 +143,34 @@ fun MyFitnessScreen( when (val current = state) { MyFitnessViewModel.State.Loading -> CenteredBox { CircularProgressIndicator() } - MyFitnessViewModel.State.Unavailable -> - CenteredBox { - Text( - text = stringRes(Res.string.my_fitness_unavailable), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 32.dp), - ) - } - - MyFitnessViewModel.State.NeedsPermission -> - ConnectPrompt( - onDetails = { context.startActivity(Intent(context, HealthConnectRationaleActivity::class.java)) }, - onConnect = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) }, - ) - is MyFitnessViewModel.State.Ready -> if (current.report.isEmpty) { - CenteredBox { - Text( - text = stringRes(Res.string.my_fitness_empty), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 32.dp), + // Nothing logged yet. Offering Health Connect is the useful thing to do + // when it could fill the screen; otherwise just say the log is empty. + if (current.healthConnect == MyFitnessViewModel.HealthConnectStatus.AVAILABLE) { + ConnectPrompt( + onDetails = { context.startActivity(Intent(context, HealthConnectRationaleActivity::class.java)) }, + onConnect = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) }, ) + } else { + CenteredBox { + Text( + text = stringRes(Res.string.my_fitness_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp), + ) + } } } else { - Dashboard(current.report) { workout, label -> + Dashboard( + report = current.report, + // Only offered when it would actually add something: a device with no + // provider gets no banner to act on. + showConnectBanner = current.healthConnect == MyFitnessViewModel.HealthConnectStatus.AVAILABLE, + onDetails = { context.startActivity(Intent(context, HealthConnectRationaleActivity::class.java)) }, + onConnect = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) }, + ) { workout, label -> nav.nav(workout.toNewWorkoutRoute(label)) } } @@ -183,6 +187,32 @@ private fun CenteredBox(content: @Composable () -> Unit) { ) { content() } } +/** + * Shown above a dashboard that is already working, when Health Connect could add device-recorded + * workouts to it. Deliberately a strip rather than a blocking card: the summary below it is real, + * and this only offers to make it richer. + */ +@Composable +private fun ConnectBanner( + onDetails: () -> Unit, + onConnect: () -> Unit, +) { + OutlinedCard(shape = RoundedCornerShape(14.dp), modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringRes(Res.string.my_fitness_connect_title), style = MaterialTheme.typography.titleSmall) + Text( + text = stringRes(Res.string.my_fitness_connect_banner), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = onDetails) { Text(stringRes(Res.string.workout_suggestion_connect_details)) } + TextButton(onClick = onConnect) { Text(stringRes(Res.string.my_fitness_connect_button)) } + } + } + } +} + @Composable private fun ConnectPrompt( onDetails: () -> Unit, @@ -219,6 +249,9 @@ private fun ConnectPrompt( @Composable private fun Dashboard( report: WorkoutStats.Report, + showConnectBanner: Boolean, + onDetails: () -> Unit, + onConnect: () -> Unit, onShare: (DetectedWorkout, String) -> Unit, ) { val miles = remember { prefersMiles() } @@ -227,6 +260,7 @@ private fun Dashboard( modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(18.dp), ) { + if (showConnectBanner) ConnectBanner(onDetails = onDetails, onConnect = onConnect) ThisWeekCard(report, miles) ConsistencyRow(report) WindowTotalsCard(report, miles) 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 index 6246ded88b..e2c38ea854 100644 --- 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 @@ -25,38 +25,61 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager +import com.vitorpamplona.amethyst.service.workouts.health.TrainingLog import com.vitorpamplona.amethyst.service.workouts.health.WorkoutStats +import com.vitorpamplona.amethyst.service.workouts.health.toDetectedWorkout +import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.time.Duration import java.time.Instant /** * State holder for the My Fitness dashboard: the user's own training over the last - * [WorkoutStats.WINDOW_DAYS], read from Health Connect and summarised by [WorkoutStats]. + * [WorkoutStats.WINDOW_DAYS], summarised by [WorkoutStats]. * - * Nothing here publishes or touches the network. The dashboard is the user looking at their - * own numbers; sharing one of them is a separate, deliberate action from the workout list. + * The log is built from both sources Amethyst has — Health Connect, and the user's own published + * kind 1301 events (see [TrainingLog]) — so the dashboard is useful before any health permission + * is granted and stays useful if one is revoked. Health Connect adds device detail; it is not a + * precondition. + * + * Nothing here publishes or touches the network. The dashboard is the user looking at their own + * numbers; sharing one is a separate, deliberate action from the workout list. */ @Stable class MyFitnessViewModel : ViewModel() { + /** Whether the richer Health Connect source is switched on, and whether it even could be. */ + enum class HealthConnectStatus { + /** Granted and contributing to the log. */ + CONNECTED, + + /** A provider is installed but Amethyst has no permissions — worth offering. */ + AVAILABLE, + + /** No provider on this device; there is nothing to offer. */ + UNAVAILABLE, + } + @Immutable sealed interface State { /** First load, or a reload after a permission change. */ data object Loading : State - /** No Health Connect provider on this device — nothing to offer. */ - data object Unavailable : State - - /** Provider present, permissions not granted yet. */ - data object NeedsPermission : State - - /** Granted and summarised. [WorkoutStats.Report.isEmpty] covers "nothing recorded yet". */ + /** + * Summarised. [WorkoutStats.Report.isEmpty] covers "nothing logged yet", which is a + * normal state rather than an error — a new user has published nothing and may not have + * connected Health Connect. + */ data class Ready( val report: WorkoutStats.Report, + val healthConnect: HealthConnectStatus, ) : State } @@ -65,28 +88,67 @@ class MyFitnessViewModel : ViewModel() { private var manager: HealthConnectManager? = null + /** The pubkey whose workouts this dashboard summarises. Set by the screen before refreshing. */ + private var pubkeyHex: String? = null + + fun init(pubkeyHex: String) { + if (this.pubkeyHex != pubkeyHex) { + this.pubkeyHex = pubkeyHex + _state.value = State.Loading + } + } + /** - * Refreshes the dashboard. Safe to call on every resume: it re-checks permissions first, - * so revoking access in Health Connect drops the screen back to its prompt rather than + * Rebuilds the dashboard. Safe to call on every resume: it re-checks permissions as well as + * data, so revoking access in Health Connect drops those workouts out of the log rather than * leaving stale numbers on display. */ fun refresh(context: Context) { viewModelScope.launch { - if (!HealthConnectManager.isAvailable(context)) { - _state.value = State.Unavailable - return@launch - } - - val hc = manager ?: HealthConnectManager(context.applicationContext).also { manager = it } - - if (!hc.hasAllPermissions()) { - _state.value = State.NeedsPermission - return@launch - } - val now = Instant.now() - val workouts = hc.readWorkouts(now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)), now) - _state.value = State.Ready(WorkoutStats.report(workouts, now)) + val since = now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)) + + val status = healthConnectStatus(context) + val fromHealthConnect = + if (status == HealthConnectStatus.CONNECTED) { + manager?.readWorkouts(since, now).orEmpty() + } else { + emptyList() + } + + val fromRelays = publishedWorkouts(since.epochSecond) + + _state.value = + State.Ready( + report = WorkoutStats.report(TrainingLog.merge(fromHealthConnect, fromRelays), now), + healthConnect = status, + ) + } + } + + private suspend fun healthConnectStatus(context: Context): HealthConnectStatus { + if (!HealthConnectManager.isAvailable(context)) return HealthConnectStatus.UNAVAILABLE + + val hc = manager ?: HealthConnectManager(context.applicationContext).also { manager = it } + return if (hc.hasAllPermissions()) HealthConnectStatus.CONNECTED else HealthConnectStatus.AVAILABLE + } + + /** + * The user's own kind 1301 events from the local cache, newer than [sinceEpochSeconds]. + * + * Scans off the main thread: LocalCache holds every event the session has seen, and this + * walks all of them. + */ + private suspend fun publishedWorkouts(sinceEpochSeconds: Long): List { + val mine = pubkeyHex ?: return emptyList() + + return withContext(Dispatchers.Default) { + LocalCache.notes + .filterIntoSet { _, note -> + val event = note.event + event is WorkoutRecordEvent && event.pubKey == mine + }.mapNotNull { (it.event as WorkoutRecordEvent).toDetectedWorkout() } + .filter { it.startTimeEpochSeconds >= sinceEpochSeconds } } } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/PublishedWorkoutMappingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/PublishedWorkoutMappingTest.kt new file mode 100644 index 0000000000..e8c3ee06a6 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/PublishedWorkoutMappingTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.workouts.health + +import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.AvgHeartRateTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.CaloriesTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DistanceTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DurationTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ElevationGainTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.StepsTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.TitleTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutStartTimeTag +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class PublishedWorkoutMappingTest { + private val createdAt = 1_800_000_000L + + private fun event(vararg tags: Array) = + WorkoutRecordEvent( + id = "eventid", + pubKey = "pubkey", + createdAt = createdAt, + tags = arrayOf(*tags), + content = "", + sig = "sig", + ) + + @Test + fun `a full workout round-trips into the training log`() { + val workout = + event( + ExerciseTag.assemble(ExerciseType.RUNNING), + TitleTag.assemble("Morning Run"), + DurationTag.assemble(1800), + DistanceTag.assemble(5.0, DistanceTag.KILOMETERS), + CaloriesTag.assemble(380), + AvgHeartRateTag.assemble(150), + StepsTag.assemble(6000), + WorkoutStartTimeTag.assemble(createdAt - 3600), + ).toDetectedWorkout() + + assertNotNull(workout) + assertEquals(ExerciseType.RUNNING, workout!!.exercise) + assertEquals("Morning Run", workout.title) + assertEquals(1800, workout.durationSeconds) + assertEquals(5000.0, workout.distanceMeters!!, 0.001) + assertEquals(380, workout.calories) + assertEquals(150, workout.avgHeartRate) + assertEquals(6000, workout.steps) + assertEquals(createdAt - 3600, workout.startTimeEpochSeconds) + assertEquals(WorkoutOrigin.PUBLISHED, workout.origin) + } + + /** Miles on the wire must land as metres in the log, or every total is wrong by 1.6x. */ + @Test + fun `an imperial distance is converted to metres`() { + val workout = + event( + ExerciseTag.assemble(ExerciseType.RUNNING), + DurationTag.assemble(1800), + DistanceTag.assemble(3.0, DistanceTag.MILES), + ).toDetectedWorkout() + + assertEquals(4828.032, workout!!.distanceMeters!!, 0.001) + } + + @Test + fun `an elevation in feet is converted to metres`() { + val workout = + event( + ExerciseTag.assemble(ExerciseType.HIKING), + DurationTag.assemble(3600), + ElevationGainTag.assemble(1000.0, "ft"), + ).toDetectedWorkout() + + assertEquals(304.8, workout!!.elevationGainMeters!!, 0.01) + } + + /** + * A workout typed in by hand carries no start time. Falling back to created_at keeps it in + * the right day bucket for streaks and the week split. + */ + @Test + fun `a manual entry without a start time falls back to the publish time`() { + val workout = + event( + ExerciseTag.assemble(ExerciseType.YOGA), + DurationTag.assemble(600), + ).toDetectedWorkout() + + assertEquals(createdAt, workout!!.startTimeEpochSeconds) + } + + @Test + fun `an event with no recognisable activity is skipped`() { + assertNull(event(DurationTag.assemble(1800)).toDetectedWorkout()) + } + + @Test + fun `an event with no duration is skipped`() { + assertNull(event(ExerciseTag.assemble(ExerciseType.RUNNING)).toDetectedWorkout()) + } + + /** Zeroes on the wire are absences, not measurements — they must not drag totals or bests. */ + @Test + fun `zero metrics are treated as absent`() { + val workout = + event( + ExerciseTag.assemble(ExerciseType.STRENGTH), + DurationTag.assemble(2400), + CaloriesTag.assemble(0), + StepsTag.assemble(0), + AvgHeartRateTag.assemble(0), + ).toDetectedWorkout() + + assertNull(workout!!.calories) + assertNull(workout.steps) + assertNull(workout.avgHeartRate) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/TrainingLogTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/TrainingLogTest.kt new file mode 100644 index 0000000000..e5deae11a4 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/TrainingLogTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.workouts.health + +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TrainingLogTest { + private val noon = 1_800_000_000L + + private fun workout( + id: String, + origin: WorkoutOrigin, + startTimeEpochSeconds: Long, + exercise: ExerciseType = ExerciseType.RUNNING, + distanceMeters: Double? = null, + avgHeartRate: Int? = null, + ) = DetectedWorkout( + id = id, + exercise = exercise, + title = null, + startTimeEpochSeconds = startTimeEpochSeconds, + durationSeconds = 1800, + distanceMeters = distanceMeters, + calories = null, + avgHeartRate = avgHeartRate, + maxHeartRate = null, + steps = null, + elevationGainMeters = null, + source = "test", + origin = origin, + ) + + @Test + fun `with no health connect the log is just what the user published`() { + val published = listOf(workout("a", WorkoutOrigin.PUBLISHED, noon)) + + val merged = TrainingLog.merge(emptyList(), published) + + assertEquals(1, merged.size) + assertEquals("a", merged[0].id) + } + + @Test + fun `with no published workouts the log is just health connect`() { + val hc = listOf(workout("a", WorkoutOrigin.HEALTH_CONNECT, noon)) + + assertEquals(1, TrainingLog.merge(hc, emptyList()).size) + } + + /** The common case: the user shared a workout their watch recorded. It must not count twice. */ + @Test + fun `a published copy of a health connect workout is dropped`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon, avgHeartRate = 150)) + val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon + 60)) + + val merged = TrainingLog.merge(hc, published) + + assertEquals(1, merged.size) + assertEquals("hc", merged[0].id) + // The Health Connect copy wins because it carries the metrics the post dropped. + assertEquals(150, merged[0].avgHeartRate) + } + + @Test + fun `a published workout just outside the tolerance is kept as its own`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon)) + val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon + TrainingLog.DEDUPE_TOLERANCE_SECONDS + 1)) + + assertEquals(2, TrainingLog.merge(hc, published).size) + } + + @Test + fun `the tolerance boundary itself counts as the same workout`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon)) + val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon + TrainingLog.DEDUPE_TOLERANCE_SECONDS)) + + assertEquals(1, TrainingLog.merge(hc, published).size) + } + + /** Deduping on time alone would swallow a genuine second session. */ + @Test + fun `a different activity at the same time is not a duplicate`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon, exercise = ExerciseType.RUNNING)) + val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon, exercise = ExerciseType.STRENGTH)) + + assertEquals(2, TrainingLog.merge(hc, published).size) + } + + @Test + fun `dedupe also applies when the published copy is slightly earlier`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon)) + val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon - 300)) + + assertEquals(1, TrainingLog.merge(hc, published).size) + } + + @Test + fun `the merged log is ordered newest first`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon - 10_000)) + val published = + listOf( + workout("older", WorkoutOrigin.PUBLISHED, noon - 50_000), + workout("newest", WorkoutOrigin.PUBLISHED, noon), + ) + + val merged = TrainingLog.merge(hc, published) + + assertEquals(listOf("newest", "hc", "older"), merged.map { it.id }) + } + + @Test + fun `one health connect workout does not swallow several unrelated published ones`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon)) + val published = + listOf( + workout("dup", WorkoutOrigin.PUBLISHED, noon + 30), + workout("yesterday", WorkoutOrigin.PUBLISHED, noon - 86_400), + workout("tomorrow", WorkoutOrigin.PUBLISHED, noon + 86_400), + ) + + val merged = TrainingLog.merge(hc, published) + + assertEquals(3, merged.size) + assertTrue(merged.none { it.id == "dup" }) + } + + @Test + fun `an empty log merges to empty`() { + assertTrue(TrainingLog.merge(emptyList(), emptyList()).isEmpty()) + } +} diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index 019c73f205..5f22370237 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -708,10 +708,11 @@ 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. + Nothing logged in the last 4 weeks. Workouts you post, and any your watch records, show 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 Health Connect to add the workouts your watch records, with heart rate, steps and climb. Connect Share this workout bpm diff --git a/docs/health-connect-play-declaration.md b/docs/health-connect-play-declaration.md index 09fc252b6b..5f889834d5 100644 --- a/docs/health-connect-play-declaration.md +++ b/docs/health-connect-play-declaration.md @@ -24,9 +24,18 @@ publishes anything. Publishing a workout is now one optional action on a row of app, the test to apply is: every data type listed is rendered back to the user as their own statistic, on a screen that has no posting requirement. +**The dashboard does not depend on Health Connect.** It builds the training log from two sources: +Health Connect, and the user's own published kind 1301 workout events. A user who never grants a +health permission still gets the full summary of everything they have logged — Health Connect adds +the device detail (heart rate, steps, climb) and the workouts they never posted. This matters for +the declaration's honesty: Health Connect *enriches* a tracking feature that exists on its own +rather than *being* the feature. + Related code: - `service/workouts/health/WorkoutStats.kt` — all dashboard arithmetic; pure, unit-tested. +- `service/workouts/health/TrainingLog.kt` — merges Health Connect with the user's own published + workouts, deduping the ones that appear in both. - `ui/screen/loggedIn/workouts/fitness/MyFitnessScreen.kt` — the dashboard. - `service/workouts/health/HealthConnectManager.kt` — the only place the app touches Health Connect. - `ui/screen/loggedIn/workouts/health/HealthConnectRationaleActivity.kt` — the in-app rationale screen. @@ -39,14 +48,18 @@ Related code: > Amethyst is a social client for Nostr, an open decentralized social protocol. It also includes > a fitness feature, **My Fitness**, which is what uses Health Connect. > -> My Fitness is a personal training dashboard. It reads the workouts the user's watch or fitness -> app has already saved to Health Connect and turns them into a picture of how that person is +> My Fitness is a personal training dashboard. It builds a log of the user's own workouts — from +> the workouts their watch or fitness app has saved to Health Connect, and from the workout +> records they have logged in Amethyst itself — and turns it into a picture of how that person is > training: how much they did this week and whether that is up or down on last week, how their > time splits across running, cycling, walking, swimming and the gym, their best efforts, how > many days they trained, and their current streak of consecutive active days. > > The purpose is to help the user track, monitor, analyze and improve their own physical -> fitness. The numbers are shown to the person who recorded them. No part of the dashboard +> fitness. Health Connect is not a precondition for it: the dashboard works from the user's own +> logged workouts alone, and Health Connect is what lets it also count the sessions their watch +> recorded and show the device metrics — heart rate, steps and elevation — that a hand-entered +> workout does not carry. The numbers are shown to the person who recorded them. No part of the dashboard > requires posting anything, and nothing is transmitted anywhere to produce it — the summary is > computed on the device from Health Connect data and displayed. > @@ -62,7 +75,10 @@ Related code: > 2. Open the navigation drawer (hamburger, top-left), and under **Feeds** tap **Workouts**. > 3. In the top bar, tap the **chart icon** to open **My Fitness**. > 4. The screen explains what will be read and offers **What Amethyst reads** (the full rationale -> screen) and **Connect**. Tap Connect and grant the permissions. +> screen) and **Connect**. Tap Connect and grant the permissions. (If the account has already +> logged workouts in Amethyst, the dashboard is already populated from those and the Health +> Connect offer appears as a banner above it instead — the feature does not gate on the +> permission.) > 5. The dashboard appears: "This week" totals with the change against last week; the streak, > active-days and workout-count tiles; the four-week weekly average; the per-activity > breakdown; best efforts; and the recent-workout list. From e39d57e08f25373acff27c5747f5df01de618104 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 05:19:29 +0000 Subject: [PATCH 04/11] refactor: move the training-log domain to commons, open Workouts on Mine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that make My Fitness a first-class part of the app rather than a screen hanging off an icon. **The domain moves to commons.** DetectedWorkout, WorkoutStats and TrainingLog are pure Kotlin over quartz types with no Android dependency, so per ARCHITECTURE.md they belonged in commons all along; amethyst keeps only the Health Connect adapters (HealthConnectManager, ExerciseTypeMapper, and WorkoutMerger, which stitches split sessions and is meaningful only for a watch). They land in jvmAndroid, not commonMain: the day bucketing behind streaks and active days is java.time, which will not compile for iOS, and the module's preference chain is commonMain -> jvmAndroid -> platform-specific. jvmAndroid is shared by Android and Desktop, which is what this needs — the desktop app has no Health Connect but does have the user's published workouts, so the same dashboard can run there. Their 30 tests move to commons/jvmTest, which is what proves that: they now run on the JVM target, not only under the Android app. **Workouts opens on Mine.** The section is about the user's own training, so that is the landing tab; other people's workouts are the second one. The dashboard body was extracted as MyFitnessContent so the tab and the standalone drawer destination render exactly the same thing. The chart icon in the top bar is gone with its string — the tab supersedes it, and two routes to the same content from the same screen is one too many. That also retires the extraActions slot added to UserDrawerSearchTopBar last commit, since nothing uses it now; an unused parameter on a shared component is debt. The follow-list spinner only filters the Following feed, so it now shows only on that tab. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019egdJyBHnrATZHjs86up8f --- .../workouts/health/HealthConnectManager.kt | 1 + .../service/workouts/health/WorkoutMerger.kt | 1 + .../topbars/UserDrawerSearchTopBar.kt | 5 - .../loggedIn/workouts/WorkoutsScreen.kt | 84 ++++++++++++++-- .../loggedIn/workouts/WorkoutsTopBar.kt | 26 +---- .../workouts/fitness/MyFitnessScreen.kt | 97 +++++++++++-------- .../workouts/fitness/MyFitnessViewModel.kt | 8 +- .../suggestion/DetectedWorkoutCarousel.kt | 2 +- .../suggestion/WorkoutSuggestionShared.kt | 2 +- .../workouts/health/WorkoutMergerTest.kt | 1 + .../workouts/NewWorkoutPrefillTest.kt | 2 +- .../composeResources/values/strings.xml | 3 +- .../commons/fitness}/DetectedWorkout.kt | 2 +- .../amethyst/commons/fitness}/TrainingLog.kt | 2 +- .../amethyst/commons/fitness}/WorkoutStats.kt | 2 +- .../fitness}/PublishedWorkoutMappingTest.kt | 2 +- .../commons/fitness}/TrainingLogTest.kt | 2 +- .../commons/fitness}/WorkoutStatsTest.kt | 2 +- 18 files changed, 151 insertions(+), 93 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness}/DetectedWorkout.kt (98%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness}/TrainingLog.kt (98%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness}/WorkoutStats.kt (99%) rename {amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health => commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness}/PublishedWorkoutMappingTest.kt (98%) rename {amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health => commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness}/TrainingLogTest.kt (99%) rename {amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health => commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness}/WorkoutStatsTest.kt (99%) 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 5c9c9befef..893a51c2c2 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 @@ -34,6 +34,7 @@ import androidx.health.connect.client.records.TotalCaloriesBurnedRecord import androidx.health.connect.client.request.AggregateRequest import androidx.health.connect.client.request.ReadRecordsRequest import androidx.health.connect.client.time.TimeRangeFilter +import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMerger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMerger.kt index 90e10fa22a..12756018c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMerger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMerger.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.workouts.health +import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType import kotlin.math.roundToInt 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 e834ee9e99..f74d25ca16 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,7 +22,6 @@ 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 @@ -67,7 +66,6 @@ fun UserDrawerSearchTopBar( accountViewModel: AccountViewModel, nav: INav, baseFilter: SearchQuery?, - extraActions: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit, ) { ShorterTopAppBar( @@ -82,9 +80,6 @@ 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/WorkoutsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt index c4d562485f..0b72b51d20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt @@ -20,10 +20,25 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.workouts_tab_following +import com.vitorpamplona.amethyst.commons.resources.workouts_tab_mine import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState @@ -37,6 +52,10 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.WorkoutsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.fitness.MyFitnessContent +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import kotlinx.coroutines.launch @Composable fun WorkoutsScreen( @@ -50,6 +69,7 @@ fun WorkoutsScreen( ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun WorkoutsScreen( workoutsFeedContentState: FeedContentState, @@ -60,10 +80,20 @@ fun WorkoutsScreen( WatchAccountForWorkoutsScreen(workoutsFeedContentState = workoutsFeedContentState, accountViewModel = accountViewModel) WorkoutsFilterAssemblerSubscription(accountViewModel) + // Mine first. The section is about the user's own training; other people's workouts are the + // second tab, not the landing page. + val pagerState = rememberPagerState { 2 } + val onFollowingTab = pagerState.currentPage == FOLLOWING_TAB + DisappearingScaffold( isInvertedLayout = false, topBar = { - WorkoutsTopBar(accountViewModel, nav) + Column { + // The follow-list spinner filters the Following feed, so it only belongs on that + // tab; the Mine tab shows the plain title instead. + WorkoutsTopBar(accountViewModel, nav, showFeedFilter = onFollowingTab) + WorkoutsTabs(pagerState) + } }, bottomBar = { AppBottomBar(Route.Workouts, nav, accountViewModel) { route -> @@ -81,20 +111,54 @@ fun WorkoutsScreen( }, accountViewModel = accountViewModel, ) { - RefresheableBox(workoutsFeedContentState, true) { - SaveableFeedContentState(workoutsFeedContentState, scrollStateKey = ScrollStateKeys.WORKOUTS_SCREEN) { listState -> - RenderFeedContentState( - feedContentState = workoutsFeedContentState, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = "WorkoutsFeed", - ) + HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> + when (page) { + MINE_TAB -> MyFitnessContent(accountViewModel, nav) + + else -> + RefresheableBox(workoutsFeedContentState, true) { + SaveableFeedContentState(workoutsFeedContentState, scrollStateKey = ScrollStateKeys.WORKOUTS_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = workoutsFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "WorkoutsFeed", + ) + } + } } } } } +private const val MINE_TAB = 0 +private const val FOLLOWING_TAB = 1 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun WorkoutsTabs(pagerState: PagerState) { + val scope = rememberCoroutineScope() + + SecondaryTabRow( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + selectedTabIndex = pagerState.currentPage, + modifier = TabRowHeight, + ) { + Tab( + selected = pagerState.currentPage == MINE_TAB, + onClick = { scope.launch { pagerState.animateScrollToPage(MINE_TAB) } }, + text = { Text(stringRes(Res.string.workouts_tab_mine)) }, + ) + Tab( + selected = pagerState.currentPage == FOLLOWING_TAB, + onClick = { scope.launch { pagerState.animateScrollToPage(FOLLOWING_TAB) } }, + text = { Text(stringRes(Res.string.workouts_tab_following)) }, + ) + } +} + @Composable fun WatchAccountForWorkoutsScreen( workoutsFeedContentState: FeedContentState, 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 3b172486c6..e5f6d2dbe1 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,36 +20,29 @@ */ 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 fun WorkoutsTopBar( accountViewModel: AccountViewModel, nav: INav, + showFeedFilter: Boolean, ) { val list by accountViewModel.account.settings.defaultWorkoutsFollowList .collectAsStateWithLifecycle() @@ -59,21 +52,8 @@ 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, - 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, - ) - } - }, - ) { + UserDrawerSearchTopBar(accountViewModel, nav, seed) { + if (!showFeedFilter) return@UserDrawerSearchTopBar WorkoutsTopNavFilterBar( followListsModel = accountViewModel.feedStates.feedListOptions, listName = list, 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 index 3af4c0422d..3cb580aa41 100644 --- 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 @@ -55,6 +55,8 @@ import androidx.health.connect.client.PermissionController import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout +import com.vitorpamplona.amethyst.commons.fitness.WorkoutStats import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res @@ -91,9 +93,7 @@ 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 @@ -107,12 +107,15 @@ 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. + * 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. + * + * Carries no scaffolding of its own so it can be the "Mine" tab of the Workouts screen as well + * as its own destination from the drawer. */ @Composable -fun MyFitnessScreen( +fun MyFitnessContent( accountViewModel: AccountViewModel, nav: INav, ) { @@ -136,45 +139,57 @@ fun MyFitnessScreen( onPauseOrDispose {} } + val openRationale = { context.startActivity(Intent(context, HealthConnectRationaleActivity::class.java)) } + val requestPermissions = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) } + + when (val current = state) { + MyFitnessViewModel.State.Loading -> CenteredBox { CircularProgressIndicator() } + + is MyFitnessViewModel.State.Ready -> + if (current.report.isEmpty) { + // Nothing logged yet. Offering Health Connect is the useful thing to do when it + // could fill the screen; otherwise just say the log is empty. + if (current.healthConnect == MyFitnessViewModel.HealthConnectStatus.AVAILABLE) { + ConnectPrompt(onDetails = openRationale, onConnect = requestPermissions) + } else { + CenteredBox { + Text( + text = stringRes(Res.string.my_fitness_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp), + ) + } + } + } else { + Dashboard( + report = current.report, + // Only offered when it would actually add something: a device with no + // provider gets no banner to act on. + showConnectBanner = current.healthConnect == MyFitnessViewModel.HealthConnectStatus.AVAILABLE, + onDetails = openRationale, + onConnect = requestPermissions, + ) { workout, label -> + nav.nav(workout.toNewWorkoutRoute(label)) + } + } + } +} + +/** + * The standalone destination, reached from the drawer or a pinned bottom-bar slot. Inside the + * Workouts screen the same content is a tab instead — see [MyFitnessContent]. + */ +@Composable +fun MyFitnessScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { 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() } - - is MyFitnessViewModel.State.Ready -> - if (current.report.isEmpty) { - // Nothing logged yet. Offering Health Connect is the useful thing to do - // when it could fill the screen; otherwise just say the log is empty. - if (current.healthConnect == MyFitnessViewModel.HealthConnectStatus.AVAILABLE) { - ConnectPrompt( - onDetails = { context.startActivity(Intent(context, HealthConnectRationaleActivity::class.java)) }, - onConnect = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) }, - ) - } else { - CenteredBox { - Text( - text = stringRes(Res.string.my_fitness_empty), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 32.dp), - ) - } - } - } else { - Dashboard( - report = current.report, - // Only offered when it would actually add something: a device with no - // provider gets no banner to act on. - showConnectBanner = current.healthConnect == MyFitnessViewModel.HealthConnectStatus.AVAILABLE, - onDetails = { context.startActivity(Intent(context, HealthConnectRationaleActivity::class.java)) }, - onConnect = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) }, - ) { workout, label -> - nav.nav(workout.toNewWorkoutRoute(label)) - } - } - } + MyFitnessContent(accountViewModel, nav) } } } 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 index e2c38ea854..a893649434 100644 --- 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 @@ -25,12 +25,12 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout +import com.vitorpamplona.amethyst.commons.fitness.TrainingLog +import com.vitorpamplona.amethyst.commons.fitness.WorkoutStats +import com.vitorpamplona.amethyst.commons.fitness.toDetectedWorkout import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager -import com.vitorpamplona.amethyst.service.workouts.health.TrainingLog -import com.vitorpamplona.amethyst.service.workouts.health.WorkoutStats -import com.vitorpamplona.amethyst.service.workouts.health.toDetectedWorkout import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow 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 58528ea2d0..3258c2223f 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 @@ -57,6 +57,7 @@ import androidx.health.connect.client.PermissionController import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res @@ -67,7 +68,6 @@ import com.vitorpamplona.amethyst.commons.resources.workout_suggestion_connect_m import com.vitorpamplona.amethyst.commons.resources.workout_suggestion_connect_title import com.vitorpamplona.amethyst.commons.resources.workout_suggestion_distance_km import com.vitorpamplona.amethyst.model.BooleanType -import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionShared.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionShared.kt index 5030b4b872..20f6d93972 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionShared.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionShared.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.suggestion import android.text.format.DateUtils -import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout +import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SourceTag diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMergerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMergerTest.kt index f8ea473932..33d5732a1a 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMergerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMergerTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.workouts.health +import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType import org.junit.Assert.assertEquals import org.junit.Assert.assertNull diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutPrefillTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutPrefillTest.kt index 8d9fcf289e..f3b35e345c 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutPrefillTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutPrefillTest.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts -import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout +import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.suggestion.toNewWorkoutRoute import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index 5f22370237..f9ed7017d6 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -683,8 +683,9 @@ 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 + Mine + Following 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/DetectedWorkout.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/DetectedWorkout.kt index f92e7d141f..05c4aa8129 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/DetectedWorkout.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.workouts.health +package com.vitorpamplona.amethyst.commons.fitness import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/TrainingLog.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLog.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/TrainingLog.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLog.kt index 639ccee6c4..ad34dd5671 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/TrainingLog.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.workouts.health +package com.vitorpamplona.amethyst.commons.fitness import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import kotlin.math.abs diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutStats.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/WorkoutStats.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutStats.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/WorkoutStats.kt index eac9ea8ff0..88437973b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutStats.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/WorkoutStats.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.workouts.health +package com.vitorpamplona.amethyst.commons.fitness import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/PublishedWorkoutMappingTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PublishedWorkoutMappingTest.kt similarity index 98% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/PublishedWorkoutMappingTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PublishedWorkoutMappingTest.kt index e8c3ee06a6..6931914b53 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/PublishedWorkoutMappingTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PublishedWorkoutMappingTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.workouts.health +package com.vitorpamplona.amethyst.commons.fitness import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.experimental.fitness.workout.tags.AvgHeartRateTag diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/TrainingLogTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLogTest.kt similarity index 99% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/TrainingLogTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLogTest.kt index e5deae11a4..51975cd716 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/TrainingLogTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLogTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.workouts.health +package com.vitorpamplona.amethyst.commons.fitness import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType import org.junit.Assert.assertEquals diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutStatsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/WorkoutStatsTest.kt similarity index 99% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutStatsTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/WorkoutStatsTest.kt index b8f728ebf3..25c8ec021c 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutStatsTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/WorkoutStatsTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.workouts.health +package com.vitorpamplona.amethyst.commons.fitness import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType import org.junit.Assert.assertEquals From c5526f3575995c041367243b89829514ae587f26 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 13:11:48 +0000 Subject: [PATCH 05/11] refactor: filter Workouts by "Mine" from the top nav instead of tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mine/Following split was built as a SecondaryTabRow + HorizontalPager, which no other feed screen in the app does. Every other one narrows its feed through the top-nav FeedFilterSpinner, and TopFilter.Mine already exists for exactly this — music, playlists, communities, badges and git repositories all offer it, and FeedTopNavFilterState already maps it to MineFeedFlow against the signer's own pubkey. So Workouts now offers Mine in that spinner and the tabs are gone. It needed no new filtering machinery: liveWorkoutsFollowLists already runs through the same topNavFilterFlow, so both the feed scan and the relay subscription follow the selection. The only addition is a workoutRoutes catalog — the kind3 list plus Mine — mirroring the per-screen flows the file already keeps for music, git repositories, highlights and podcasts. That duplication is the established convention here rather than something to factor out. MyFitnessContent is inlined back into MyFitnessScreen. It was extracted only so the tab and the standalone destination could share a body; with the tab gone it had one caller, and a public composable with no second caller is API surface for nothing. Note the consequence: the Workouts screen no longer offers a route to the My Fitness dashboard, since the tab was it and the chart icon went with the previous commit. It stays reachable from the drawer and a pinned bottom-bar slot, which is how every other destination is reached. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019egdJyBHnrATZHjs86up8f --- .../amethyst/ui/screen/TopNavFilterState.kt | 23 +++++ .../loggedIn/workouts/WorkoutsScreen.kt | 84 +++---------------- .../loggedIn/workouts/WorkoutsTopBar.kt | 4 +- .../workouts/fitness/MyFitnessScreen.kt | 81 ++++++++---------- .../composeResources/values/strings.xml | 2 - 5 files changed, 68 insertions(+), 126 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index ae384f50e3..a62ae6e088 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -337,6 +337,24 @@ class TopNavFilterState( ) } + private val _workoutRoutes = + combineTransform( + livePeopleListsFlow, + liveInterestFlows, + ) { peopleLists, interests -> + checkNotInMainThread() + emit( + listOf( + // Workout records can be narrowed by author, hashtag and geohash, so this + // mirrors the kind3 catalog plus "Mine" — the user's own training. + listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow), + peopleLists, + interests, + listOf(muteListFollow), + ).flatten().toImmutableList(), + ) + } + private val _highlightsRoutes = combineTransform( livePeopleListsFlow, @@ -488,6 +506,11 @@ class TopNavFilterState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow)) + val workoutRoutes = + _workoutRoutes + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow)) + val highlightsRoutes = _highlightsRoutes .flowOn(Dispatchers.IO) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt index 0b72b51d20..c4d562485f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt @@ -20,25 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SecondaryTabRow -import androidx.compose.material3.Tab -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.workouts_tab_following -import com.vitorpamplona.amethyst.commons.resources.workouts_tab_mine import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState @@ -52,10 +37,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.WorkoutsFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.fitness.MyFitnessContent -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.TabRowHeight -import kotlinx.coroutines.launch @Composable fun WorkoutsScreen( @@ -69,7 +50,6 @@ fun WorkoutsScreen( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun WorkoutsScreen( workoutsFeedContentState: FeedContentState, @@ -80,20 +60,10 @@ fun WorkoutsScreen( WatchAccountForWorkoutsScreen(workoutsFeedContentState = workoutsFeedContentState, accountViewModel = accountViewModel) WorkoutsFilterAssemblerSubscription(accountViewModel) - // Mine first. The section is about the user's own training; other people's workouts are the - // second tab, not the landing page. - val pagerState = rememberPagerState { 2 } - val onFollowingTab = pagerState.currentPage == FOLLOWING_TAB - DisappearingScaffold( isInvertedLayout = false, topBar = { - Column { - // The follow-list spinner filters the Following feed, so it only belongs on that - // tab; the Mine tab shows the plain title instead. - WorkoutsTopBar(accountViewModel, nav, showFeedFilter = onFollowingTab) - WorkoutsTabs(pagerState) - } + WorkoutsTopBar(accountViewModel, nav) }, bottomBar = { AppBottomBar(Route.Workouts, nav, accountViewModel) { route -> @@ -111,54 +81,20 @@ fun WorkoutsScreen( }, accountViewModel = accountViewModel, ) { - HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> - when (page) { - MINE_TAB -> MyFitnessContent(accountViewModel, nav) - - else -> - RefresheableBox(workoutsFeedContentState, true) { - SaveableFeedContentState(workoutsFeedContentState, scrollStateKey = ScrollStateKeys.WORKOUTS_SCREEN) { listState -> - RenderFeedContentState( - feedContentState = workoutsFeedContentState, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = "WorkoutsFeed", - ) - } - } + RefresheableBox(workoutsFeedContentState, true) { + SaveableFeedContentState(workoutsFeedContentState, scrollStateKey = ScrollStateKeys.WORKOUTS_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = workoutsFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "WorkoutsFeed", + ) } } } } -private const val MINE_TAB = 0 -private const val FOLLOWING_TAB = 1 - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun WorkoutsTabs(pagerState: PagerState) { - val scope = rememberCoroutineScope() - - SecondaryTabRow( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - selectedTabIndex = pagerState.currentPage, - modifier = TabRowHeight, - ) { - Tab( - selected = pagerState.currentPage == MINE_TAB, - onClick = { scope.launch { pagerState.animateScrollToPage(MINE_TAB) } }, - text = { Text(stringRes(Res.string.workouts_tab_mine)) }, - ) - Tab( - selected = pagerState.currentPage == FOLLOWING_TAB, - onClick = { scope.launch { pagerState.animateScrollToPage(FOLLOWING_TAB) } }, - text = { Text(stringRes(Res.string.workouts_tab_following)) }, - ) - } -} - @Composable fun WatchAccountForWorkoutsScreen( workoutsFeedContentState: FeedContentState, 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 e5f6d2dbe1..4b0214055c 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 @@ -42,7 +42,6 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent fun WorkoutsTopBar( accountViewModel: AccountViewModel, nav: INav, - showFeedFilter: Boolean, ) { val list by accountViewModel.account.settings.defaultWorkoutsFollowList .collectAsStateWithLifecycle() @@ -53,7 +52,6 @@ fun WorkoutsTopBar( val seed = remember(list, me) { SearchSeed.merge(SearchSeed.ofKinds(WorkoutRecordEvent.KIND), list.asSearchQuery(me)) } UserDrawerSearchTopBar(accountViewModel, nav, seed) { - if (!showFeedFilter) return@UserDrawerSearchTopBar WorkoutsTopNavFilterBar( followListsModel = accountViewModel.feedStates.feedListOptions, listName = list, @@ -70,7 +68,7 @@ private fun WorkoutsTopNavFilterBar( accountViewModel: AccountViewModel, onChange: (FeedDefinition) -> Unit, ) { - val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + val allLists by followListsModel.workoutRoutes.collectAsStateWithLifecycle() FeedFilterSpinner( placeholderCode = listName, 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 index 3cb580aa41..125770000a 100644 --- 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 @@ -111,11 +111,10 @@ import com.vitorpamplona.amethyst.ui.stringRes * recorded them. Publishing one as a note is an optional action from the workout list, never a * precondition for seeing any of this. * - * Carries no scaffolding of its own so it can be the "Mine" tab of the Workouts screen as well - * as its own destination from the drawer. + * Reached from the drawer, or from a bottom-bar slot the user pinned. */ @Composable -fun MyFitnessContent( +fun MyFitnessScreen( accountViewModel: AccountViewModel, nav: INav, ) { @@ -142,54 +141,42 @@ fun MyFitnessContent( val openRationale = { context.startActivity(Intent(context, HealthConnectRationaleActivity::class.java)) } val requestPermissions = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) } - when (val current = state) { - MyFitnessViewModel.State.Loading -> CenteredBox { CircularProgressIndicator() } - - is MyFitnessViewModel.State.Ready -> - if (current.report.isEmpty) { - // Nothing logged yet. Offering Health Connect is the useful thing to do when it - // could fill the screen; otherwise just say the log is empty. - if (current.healthConnect == MyFitnessViewModel.HealthConnectStatus.AVAILABLE) { - ConnectPrompt(onDetails = openRationale, onConnect = requestPermissions) - } else { - CenteredBox { - Text( - text = stringRes(Res.string.my_fitness_empty), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 32.dp), - ) - } - } - } else { - Dashboard( - report = current.report, - // Only offered when it would actually add something: a device with no - // provider gets no banner to act on. - showConnectBanner = current.healthConnect == MyFitnessViewModel.HealthConnectStatus.AVAILABLE, - onDetails = openRationale, - onConnect = requestPermissions, - ) { workout, label -> - nav.nav(workout.toNewWorkoutRoute(label)) - } - } - } -} - -/** - * The standalone destination, reached from the drawer or a pinned bottom-bar slot. Inside the - * Workouts screen the same content is a tab instead — see [MyFitnessContent]. - */ -@Composable -fun MyFitnessScreen( - accountViewModel: AccountViewModel, - nav: INav, -) { Scaffold( topBar = { TopBarWithBackButton(stringRes(Res.string.my_fitness_title), nav) }, ) { padding -> Surface(modifier = Modifier.padding(padding)) { - MyFitnessContent(accountViewModel, nav) + when (val current = state) { + MyFitnessViewModel.State.Loading -> CenteredBox { CircularProgressIndicator() } + + is MyFitnessViewModel.State.Ready -> + if (current.report.isEmpty) { + // Nothing logged yet. Offering Health Connect is the useful thing to do + // when it could fill the screen; otherwise just say the log is empty. + if (current.healthConnect == MyFitnessViewModel.HealthConnectStatus.AVAILABLE) { + ConnectPrompt(onDetails = openRationale, onConnect = requestPermissions) + } else { + CenteredBox { + Text( + text = stringRes(Res.string.my_fitness_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp), + ) + } + } + } else { + Dashboard( + report = current.report, + // Only offered when it would actually add something: a device with no + // provider gets no banner to act on. + showConnectBanner = current.healthConnect == MyFitnessViewModel.HealthConnectStatus.AVAILABLE, + onDetails = openRationale, + onConnect = requestPermissions, + ) { workout, label -> + nav.nav(workout.toNewWorkoutRoute(label)) + } + } + } } } } diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index f9ed7017d6..7f46225d00 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -683,8 +683,6 @@ 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 - Mine - Following My Fitness Last 4 weeks Health Connect serves the last 30 days, so Amethyst summarises four whole weeks. Nothing here leaves your phone. From ec8a5fa573a3abf5bc83dcf6d7882b2201eea71d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 17 Sep 2026 09:19:50 -0400 Subject: [PATCH 06/11] fix: don't offer to re-share an already-published workout My Fitness builds its log from both Health Connect and the user's own kind 1301 events, but every row in Recent workouts carried a "Share this workout" button. On a relay-sourced entry that opened the composer prefilled and one tap from publishing a second kind 1301 for the same effort. WorkoutOrigin already recorded where each workout came from and nothing read it; the button is now gated on it. Also tidies the dashboard's copy and its empty cells: - This week showed "0 kcal" (and would show "0.0 mi" for a strength-only week) where the weekly-average card already omits metrics the window has none of. Distance and calories now appear only when this week or last week has one, so a drop to zero still renders its trend. - The footer note explained the four-week window as a Health Connect limit and claimed nothing leaves the phone. Neither holds once the log is fed by published events, which by definition already left. - Drops my_fitness_unavailable, unused since the log stopped depending on Health Connect, so it isn't sent out for translation. Co-Authored-By: Claude Opus 5 (1M context) --- .../workouts/fitness/MyFitnessScreen.kt | 100 +++++++++++------- .../composeResources/values/strings.xml | 3 +- 2 files changed, 63 insertions(+), 40 deletions(-) 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 index 125770000a..bf1886b371 100644 --- 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 @@ -56,6 +56,7 @@ import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout +import com.vitorpamplona.amethyst.commons.fitness.WorkoutOrigin import com.vitorpamplona.amethyst.commons.fitness.WorkoutStats import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -284,34 +285,50 @@ private fun ThisWeekCard( 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()), - ), - ), - ) + // Workouts and time are always worth a cell. Distance and calories are not: a week of + // strength work has neither, and a column of zeroes reads as missing data rather than + // as "this activity doesn't have that metric". Last week still counts, so a drop to + // zero keeps its cell and shows the fall. + val cells = + buildList { + add( + StatCell( + stringRes(Res.string.my_fitness_workouts), + report.thisWeek.workoutCount.toString(), + null, + WorkoutStats.percentChange(report.thisWeek.workoutCount.toDouble(), report.previousWeek.workoutCount.toDouble()), + ), + ) + add( + StatCell( + stringRes(Res.string.my_fitness_time), + formatDuration(report.thisWeek.durationSeconds), + null, + WorkoutStats.percentChange(report.thisWeek.durationSeconds.toDouble(), report.previousWeek.durationSeconds.toDouble()), + ), + ) + if (report.thisWeek.distanceMeters > 0 || report.previousWeek.distanceMeters > 0) { + add( + StatCell( + stringRes(Res.string.my_fitness_distance), + formatDistanceValue(report.thisWeek.distanceMeters, miles), + distanceUnit(miles), + WorkoutStats.percentChange(report.thisWeek.distanceMeters, report.previousWeek.distanceMeters), + ), + ) + } + if (report.thisWeek.calories > 0 || report.previousWeek.calories > 0) { + add( + StatCell( + stringRes(Res.string.my_fitness_calories), + report.thisWeek.calories.toString(), + stringRes(Res.string.my_fitness_unit_kcal), + WorkoutStats.percentChange(report.thisWeek.calories.toDouble(), report.previousWeek.calories.toDouble()), + ), + ) + } + } + StatGrid(cells) } } @@ -333,14 +350,16 @@ private fun WindowTotalsCard( 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.distanceMeters > 0) { + add( + StatCell( + stringRes(Res.string.my_fitness_distance), + formatDistanceValue(report.weeklyAverage.distanceMeters, miles), + distanceUnit(miles), + null, + ), + ) + } if (report.windowTotals.steps > 0) { add(StatCell(stringRes(Res.string.my_fitness_steps), report.weeklyAverage.steps.toString(), null, null)) } @@ -492,8 +511,13 @@ private fun RecentWorkouts( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - TextButton(onClick = { onShare(workout, label) }) { - Text(stringRes(Res.string.my_fitness_share), style = MaterialTheme.typography.labelMedium) + // Only what is still unpublished can be shared. A workout that came + // back from the relays is already posted: offering to share it again + // would publish a second kind 1301 for the same effort. + if (workout.origin != WorkoutOrigin.PUBLISHED) { + TextButton(onClick = { onShare(workout, label) }) { + Text(stringRes(Res.string.my_fitness_share), style = MaterialTheme.typography.labelMedium) + } } } Text( diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index 7f46225d00..4550aca749 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -685,7 +685,7 @@ Read the full privacy policy My Fitness Last 4 weeks - Health Connect serves the last 30 days, so Amethyst summarises four whole weeks. Nothing here leaves your phone. + The last four whole weeks of your training: the workouts you have published, plus anything Health Connect records on this phone. Amethyst works the summary out on the device and publishes none of it. This week vs last week Weekly average @@ -708,7 +708,6 @@ Highest heart rate Recent workouts Nothing logged in the last 4 weeks. Workouts you post, and any your watch records, show 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 Health Connect to add the workouts your watch records, with heart rate, steps and climb. From 0debf8a0233be1bd06debb7da7dba8b4b6e6ea87 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 17 Sep 2026 10:48:07 -0400 Subject: [PATCH 07/11] fix: don't offer to re-share a workout shared from Health Connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit gated the share button on WorkoutOrigin, which closes only half the case. When a user shares a workout their watch recorded, TrainingLog.merge keeps the Health Connect copy and drops the published one, so the survivor is still WorkoutOrigin.HEALTH_CONNECT even though a kind 1301 for it is already out there — and the dashboard went on offering to share it. That is the likelier route to a duplicate, since sharing from Health Connect is the feature's main flow. Origin answers "where did this data come from", which is the wrong question. DetectedWorkout now also carries alreadyPublished, the merge hands it to the surviving copy, and the published-event mapper sets it too, so the dashboard has one field to read. Confirmed on device: a Health Connect ride shared from My Fitness keeps its metrics, does not duplicate, and loses its share button, while the six unshared sessions keep theirs. Co-Authored-By: Claude Opus 5 (1M context) --- .../workouts/fitness/MyFitnessScreen.kt | 10 ++-- .../commons/fitness/DetectedWorkout.kt | 10 ++++ .../amethyst/commons/fitness/TrainingLog.kt | 18 ++++++- .../fitness/PublishedWorkoutMappingTest.kt | 3 ++ .../commons/fitness/TrainingLogTest.kt | 49 +++++++++++++++++++ 5 files changed, 84 insertions(+), 6 deletions(-) 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 index bf1886b371..ef3405c8e9 100644 --- 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 @@ -56,7 +56,6 @@ import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout -import com.vitorpamplona.amethyst.commons.fitness.WorkoutOrigin import com.vitorpamplona.amethyst.commons.fitness.WorkoutStats import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -511,10 +510,11 @@ private fun RecentWorkouts( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - // Only what is still unpublished can be shared. A workout that came - // back from the relays is already posted: offering to share it again - // would publish a second kind 1301 for the same effort. - if (workout.origin != WorkoutOrigin.PUBLISHED) { + // Only what is still unpublished can be shared. Offering to share a + // workout that is already posted would publish a second kind 1301 for + // the same effort — whether it came back from a relay, or is the + // Health Connect copy of one the user shared earlier. + if (!workout.alreadyPublished) { TextButton(onClick = { onShare(workout, label) }) { Text(stringRes(Res.string.my_fitness_share), style = MaterialTheme.typography.labelMedium) } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/DetectedWorkout.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/DetectedWorkout.kt index 05c4aa8129..a99e9bf58d 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/DetectedWorkout.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/DetectedWorkout.kt @@ -64,6 +64,16 @@ data class DetectedWorkout( * originated and where every constructor but the published-event mapper still builds from. */ val origin: WorkoutOrigin = WorkoutOrigin.HEALTH_CONNECT, + /** + * Whether a kind 1301 for this workout already exists. + * + * Not the same question as [origin]: a workout read from Health Connect is published the + * moment the user shares it, and [TrainingLog.merge] keeps the richer Health Connect copy + * rather than the published one — so the survivor is still [WorkoutOrigin.HEALTH_CONNECT] + * while a kind 1301 for it is already out there. Anything offering to publish a workout + * must read this, not the origin, or it offers to post the same effort twice. + */ + val alreadyPublished: Boolean = false, /** * How many Health Connect sessions this workout represents. 1 for a raw * session; higher when [WorkoutMerger] combined several close-by same-type diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLog.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLog.kt index ad34dd5671..ef62be0f78 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLog.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLog.kt @@ -50,17 +50,31 @@ object TrainingLog { * from their watch — the Health Connect copy wins: it carries the metrics the published * event may have dropped (heart rate, steps, climb), and its start time is the recorded one * rather than a publish timestamp. + * + * The winner keeps the loser's one piece of information: that a kind 1301 for this workout + * exists. Dropping the published copy would otherwise lose that fact, and the survivor — + * still flagged as Health Connect data — would read as never posted. See + * [DetectedWorkout.alreadyPublished]. */ fun merge( healthConnect: List, published: List, ): List { + val flagged = + healthConnect.map { recorded -> + if (published.any { recorded.isProbablySameWorkoutAs(it) }) { + recorded.copy(alreadyPublished = true) + } else { + recorded + } + } + val deduped = published.filterNot { candidate -> healthConnect.any { it.isProbablySameWorkoutAs(candidate) } } - return (healthConnect + deduped).sortedByDescending { it.startTimeEpochSeconds } + return (flagged + deduped).sortedByDescending { it.startTimeEpochSeconds } } private fun DetectedWorkout.isProbablySameWorkoutAs(other: DetectedWorkout): Boolean = @@ -97,5 +111,7 @@ fun WorkoutRecordEvent.toDetectedWorkout(): DetectedWorkout? { elevationGainMeters = elevationGain()?.toMeters()?.takeIf { it > 0 }, source = workoutSource() ?: "", origin = WorkoutOrigin.PUBLISHED, + // It came back from a relay, so by definition it is already out there. + alreadyPublished = true, ) } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PublishedWorkoutMappingTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PublishedWorkoutMappingTest.kt index 6931914b53..264e4735cf 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PublishedWorkoutMappingTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PublishedWorkoutMappingTest.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutStartTi import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class PublishedWorkoutMappingTest { @@ -73,6 +74,8 @@ class PublishedWorkoutMappingTest { assertEquals(6000, workout.steps) assertEquals(createdAt - 3600, workout.startTimeEpochSeconds) assertEquals(WorkoutOrigin.PUBLISHED, workout.origin) + // Recovered from a relay, so the dashboard must not offer to publish it again. + assertTrue(workout.alreadyPublished) } /** Miles on the wire must land as metres in the log, or every total is wrong by 1.6x. */ diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLogTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLogTest.kt index 51975cd716..bdfb0f8e1c 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLogTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLogTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.fitness import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -49,6 +50,8 @@ class TrainingLogTest { elevationGainMeters = null, source = "test", origin = origin, + // Matches the real mappers: anything recovered from a relay is by definition published. + alreadyPublished = origin == WorkoutOrigin.PUBLISHED, ) @Test @@ -82,6 +85,52 @@ class TrainingLogTest { assertEquals(150, merged[0].avgHeartRate) } + /** + * The survivor is Health Connect data, so its origin cannot answer "has this been posted?". + * Without the flag the dashboard offers to share it again and the user posts a second + * kind 1301 for the same effort. + */ + @Test + fun `the surviving copy remembers that the workout was already published`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon)) + val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon + 60)) + + val merged = TrainingLog.merge(hc, published) + + assertEquals(1, merged.size) + assertEquals(WorkoutOrigin.HEALTH_CONNECT, merged[0].origin) + assertTrue(merged[0].alreadyPublished) + } + + @Test + fun `a health connect workout that was never shared is not marked published`() { + val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon)) + val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon + TrainingLog.DEDUPE_TOLERANCE_SECONDS + 1)) + + val merged = TrainingLog.merge(hc, published) + + assertEquals(2, merged.size) + assertFalse(merged.first { it.id == "hc" }.alreadyPublished) + assertTrue(merged.first { it.id == "pub" }.alreadyPublished) + } + + /** Only the matching session is flagged; an unrelated one in the same log is left alone. */ + @Test + fun `flagging one workout does not flag the rest of the log`() { + val hc = + listOf( + workout("shared", WorkoutOrigin.HEALTH_CONNECT, noon), + workout("private", WorkoutOrigin.HEALTH_CONNECT, noon - 86_400), + ) + val published = listOf(workout("pub", WorkoutOrigin.PUBLISHED, noon)) + + val merged = TrainingLog.merge(hc, published) + + assertEquals(2, merged.size) + assertTrue(merged.first { it.id == "shared" }.alreadyPublished) + assertFalse(merged.first { it.id == "private" }.alreadyPublished) + } + @Test fun `a published workout just outside the tolerance is kept as its own`() { val hc = listOf(workout("hc", WorkoutOrigin.HEALTH_CONNECT, noon)) From f1bba332a95793a008dd87a33658b99c14b503f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 15:06:22 +0000 Subject: [PATCH 08/11] docs: point the Health Connect walkthrough at the drawer entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reviewer walkthrough still routed through Workouts and a chart icon in its top bar. That icon is gone — the Mine/Following split moved to the top-nav filter — so following the walkthrough would have dead-ended, which is how the first rejection ("insufficient information to determine app functionality") started. The route is now the drawer, where My Fitness is the second entry under "You", the first section, directly below Profile. That is a shorter path than the old one and needs no scrolling. Two other things the walkthrough got wrong, now that the dashboard reads both sources: an empty Health Connect database no longer means an empty screen, and a reviewer can populate the dashboard with no health permission at all by logging a workout in the app. Both are said explicitly, because a reviewer who sees an empty screen and assumes the feature is broken is the failure mode these notes exist to prevent. The sharing step also said to tap Share on any row, which stopped being true one commit ago: the button is gated on alreadyPublished, so a workout already posted does not offer it. Said as written, a reviewer would look for a control that isn't there on some rows. PRIVACY.md and the store listing carried the same stale path and are fixed to match. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019egdJyBHnrATZHjs86up8f --- PRIVACY.md | 2 +- docs/health-connect-play-declaration.md | 33 ++++++++++++------- .../android/en-US/full_description.txt | 9 ++--- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 1363eaf653..384e0677af 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -32,7 +32,7 @@ Configuration, cached events, keys, drafts, and other operational data live in t ### Health and fitness data (Health Connect) -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**. +Amethyst's **My Fitness** screen (drawer → You → My Fitness) summarises your own training for you: how much you did this week against last week, how your time splits across activities, your best efforts, how many days you trained, and your current streak. It builds that picture from the workouts your watch or fitness app has already saved to **Android Health Connect**. 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. diff --git a/docs/health-connect-play-declaration.md b/docs/health-connect-play-declaration.md index 5f889834d5..a3add5e9e7 100644 --- a/docs/health-connect-play-declaration.md +++ b/docs/health-connect-play-declaration.md @@ -17,8 +17,8 @@ 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. +streak. It is its own destination in the navigation drawer, under "You", and works fully whether +or not the user ever publishes anything. Publishing a workout is now one optional action on a row of that dashboard. **The permissions serve the dashboard.** If the declaration below is ever re-read against the app, the test to apply is: every data type listed is rendered back to the user as their own @@ -72,22 +72,33 @@ Related code: ## 2. Reviewer walkthrough > 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 +> 2. Open the navigation drawer (hamburger, top-left). **My Fitness** is the second entry under +> **You**, the first section — directly below Profile. Tap it. +> 3. The screen explains what will be read and offers **What Amethyst reads** (the full rationale > screen) and **Connect**. Tap Connect and grant the permissions. (If the account has already > logged workouts in Amethyst, the dashboard is already populated from those and the Health > Connect offer appears as a banner above it instead — the feature does not gate on the > permission.) -> 5. The dashboard appears: "This week" totals with the change against last week; the streak, +> 4. The dashboard appears: "This week" totals with the change against last week; the streak, > active-days and workout-count tiles; the four-week weekly average; the per-activity > breakdown; best efforts; and the recent-workout list. -> 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. +> 5. Everything above is the tracking feature. To see the optional sharing path, tap **Share this +> workout** on a row in Recent workouts — it opens a pre-filled composer that the user must +> confirm. The button appears only on workouts that have not been published yet, so a workout +> is never offered for sharing twice. > -> Note for 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. +> My Fitness can also be pinned to the bottom bar, under Settings, like any other destination. +> +> Notes for testing: +> +> - The dashboard summarises two sources: workouts read from Health Connect, and workout records +> the account has published from Amethyst. On a fresh device with an empty Health Connect +> database and an account that has never logged a workout, it correctly reports that nothing has +> been recorded — there is no data to summarise, which is not a failure of the feature. +> - To see it populated, either grant Health Connect access on a device whose fitness app or watch +> has saved sessions in the last four weeks, or log one in the app: drawer → **Feeds** → +> **Workouts** → **+**. A workout logged that way appears in the dashboard without any health +> permission at all. ## 3. Per-permission justification diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index acab602da2..2b2f056798 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -16,10 +16,11 @@ classifieds, calendars and more.
  • Track your training and share a workout — see below.
  • 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:

    +

    Amethyst includes a personal training dashboard. Open the left drawer and tap My Fitness, +under You. It summarises the workouts you have logged in Amethyst, and — with your +permission — the ones your watch or fitness app has already saved to Android Health Connect +(Samsung Health, Google Fit, Fitbit, Garmin Connect, Strava and others all write there). It shows +you:

    • 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.
    • From ccac827d5fae384e24c726757f66e5fbeff492a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 15:53:00 +0000 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20code-review=20pass=20=E2=80=94=20P?= =?UTF-8?q?OWR=20durations,=20pinned=20bottom=20bar,=20carousel=20duplicat?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRIBUTING-WITH-AI requires a review pass by a different agent before opening the PR. It found five issues; four are fixed here. toDetectedWorkout read durationSeconds(), so a POWR-dialect kind 1301 — which carries start/end and no duration tag — mapped to null and disappeared from the dashboard entirely. It now reads effectiveDurationSeconds(), the helper WorkoutDisplay already uses, and the function was already falling back to workoutStart() for the timestamp, so it was half-converted. Covered by a test built from start/end tags alone. MyFitnessScreen used a bare Scaffold while MY_FITNESS is pinnable to the bottom bar, so pinning it and tapping it made the bar vanish. Now DisappearingScaffold + AppBottomBar, matching Drafts and every other pinnable destination. The New Workout carousel still offered Health Connect workouts the user had already shared — the alreadyPublished flag was honoured only in My Fitness, so the duplicate-publish path fixed two commits ago stayed open one screen over. Both screens now build their "what have I already posted" view from one shared helper, publishedWorkoutsOf, because two answers to that question is what let them disagree in the first place. Also corrects the declaration's "Related code" paths, which still pointed at service/workouts/health for two files that now live in commons/fitness. The fifth finding is NOT fixed and is called out in the PR description: the dashboard never REQs kind 1301, so on a cold start it summarises only what some other screen happened to load. Fixing it means choosing outbox relays, which I cannot verify without a device or relay, and guessing at relay selection is worse than not fetching. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019egdJyBHnrATZHjs86up8f --- .../workouts/health/PublishedWorkouts.kt | 53 +++++++++++++++++++ .../workouts/fitness/MyFitnessScreen.kt | 15 +++++- .../workouts/fitness/MyFitnessViewModel.kt | 28 +--------- .../suggestion/DetectedWorkoutCarousel.kt | 13 ++++- .../amethyst/commons/fitness/TrainingLog.kt | 5 +- .../fitness/PublishedWorkoutMappingTest.kt | 20 +++++++ docs/health-connect-play-declaration.md | 6 +-- 7 files changed, 107 insertions(+), 33 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/PublishedWorkouts.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/PublishedWorkouts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/PublishedWorkouts.kt new file mode 100644 index 0000000000..ad2d5fdfe5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/PublishedWorkouts.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.workouts.health + +import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout +import com.vitorpamplona.amethyst.commons.fitness.toDetectedWorkout +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * The workouts [pubkeyHex] has published, from [sinceEpochSeconds] onwards, as the cache currently + * holds them. + * + * Shared by the My Fitness dashboard, which counts them, and the New Workout carousel, which uses + * them to avoid offering a workout the user already shared. Both need the same view of "what have + * I already posted", and two answers to that question would mean the carousel offering something + * the dashboard knows is a duplicate. + * + * Scans off the main thread: LocalCache holds every event the session has seen and this walks all + * of them. It reports only what is already cached — it issues no REQ of its own. + */ +suspend fun publishedWorkoutsOf( + pubkeyHex: String, + sinceEpochSeconds: Long, +): List = + withContext(Dispatchers.Default) { + LocalCache.notes + .filterIntoSet { _, note -> + val event = note.event + event is WorkoutRecordEvent && event.pubKey == pubkeyHex + }.mapNotNull { (it.event as WorkoutRecordEvent).toDetectedWorkout() } + .filter { it.startTimeEpochSeconds >= sinceEpochSeconds } + } 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 index ef3405c8e9..50c251dce1 100644 --- 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 @@ -38,7 +38,6 @@ 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 @@ -94,7 +93,10 @@ import com.vitorpamplona.amethyst.commons.resources.my_fitness_window_note import com.vitorpamplona.amethyst.commons.resources.my_fitness_workouts import com.vitorpamplona.amethyst.commons.resources.workout_suggestion_connect_details import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.health.HealthConnectRationaleActivity @@ -141,8 +143,17 @@ fun MyFitnessScreen( val openRationale = { context.startActivity(Intent(context, HealthConnectRationaleActivity::class.java)) } val requestPermissions = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) } - Scaffold( + // DisappearingScaffold + AppBottomBar, like every other pinnable destination: My Fitness can + // be pinned to the bottom bar, and a bare Scaffold would make that bar vanish on arrival. + DisappearingScaffold( + isInvertedLayout = false, topBar = { TopBarWithBackButton(stringRes(Res.string.my_fitness_title), nav) }, + bottomBar = { + AppBottomBar(Route.MyFitness, nav, accountViewModel) { route -> + if (route != Route.MyFitness) nav.navBottomBar(route) + } + }, + accountViewModel = accountViewModel, ) { padding -> Surface(modifier = Modifier.padding(padding)) { when (val current = state) { 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 index a893649434..3f829aed08 100644 --- 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 @@ -25,19 +25,14 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout import com.vitorpamplona.amethyst.commons.fitness.TrainingLog import com.vitorpamplona.amethyst.commons.fitness.WorkoutStats -import com.vitorpamplona.amethyst.commons.fitness.toDetectedWorkout -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager -import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent -import kotlinx.coroutines.Dispatchers +import com.vitorpamplona.amethyst.service.workouts.health.publishedWorkoutsOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.time.Duration import java.time.Instant @@ -116,7 +111,7 @@ class MyFitnessViewModel : ViewModel() { emptyList() } - val fromRelays = publishedWorkouts(since.epochSecond) + val fromRelays = publishedWorkoutsOf(pubkeyHex ?: return@launch, since.epochSecond) _state.value = State.Ready( @@ -132,23 +127,4 @@ class MyFitnessViewModel : ViewModel() { val hc = manager ?: HealthConnectManager(context.applicationContext).also { manager = it } return if (hc.hasAllPermissions()) HealthConnectStatus.CONNECTED else HealthConnectStatus.AVAILABLE } - - /** - * The user's own kind 1301 events from the local cache, newer than [sinceEpochSeconds]. - * - * Scans off the main thread: LocalCache holds every event the session has seen, and this - * walks all of them. - */ - private suspend fun publishedWorkouts(sinceEpochSeconds: Long): List { - val mine = pubkeyHex ?: return emptyList() - - return withContext(Dispatchers.Default) { - LocalCache.notes - .filterIntoSet { _, note -> - val event = note.event - event is WorkoutRecordEvent && event.pubKey == mine - }.mapNotNull { (it.event as WorkoutRecordEvent).toDetectedWorkout() } - .filter { it.startTimeEpochSeconds >= sinceEpochSeconds } - } - } } 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 3258c2223f..1f29d64ecd 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 @@ -58,6 +58,8 @@ import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout +import com.vitorpamplona.amethyst.commons.fitness.TrainingLog +import com.vitorpamplona.amethyst.commons.fitness.WorkoutOrigin import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res @@ -69,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.resources.workout_suggestion_connect_t import com.vitorpamplona.amethyst.commons.resources.workout_suggestion_distance_km import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager +import com.vitorpamplona.amethyst.service.workouts.health.publishedWorkoutsOf import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.health.HealthConnectRationaleActivity @@ -108,13 +111,21 @@ fun DetectedWorkoutCarousel( var granted by remember { mutableStateOf(null) } var workouts by remember { mutableStateOf>(emptyList()) } + val myPubkey = accountViewModel.userProfile().pubkeyHex + val reload: suspend () -> Unit = { val ok = manager.hasAllPermissions() granted = ok workouts = if (ok) { val since = Instant.now().minus(Duration.ofDays(HealthConnectManager.LOOKBACK_DAYS)) - manager.readWorkouts(since).sortedByDescending { it.startTimeEpochSeconds } + // Offering a workout the user already shared would publish a second kind 1301 for + // the same effort. merge() flags the Health Connect copies that match something + // already posted, so drop those; what is left is genuinely unshared. + TrainingLog + .merge(manager.readWorkouts(since), publishedWorkoutsOf(myPubkey, since.epochSecond)) + .filter { it.origin == WorkoutOrigin.HEALTH_CONNECT && !it.alreadyPublished } + .sortedByDescending { it.startTimeEpochSeconds } } else { emptyList() } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLog.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLog.kt index ef62be0f78..d6cb6c6d08 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLog.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/fitness/TrainingLog.kt @@ -94,7 +94,10 @@ object TrainingLog { fun WorkoutRecordEvent.toDetectedWorkout(): DetectedWorkout? { val activity = activityType() ?: return null - val duration = durationSeconds() ?: return null + // effectiveDurationSeconds, not durationSeconds: a POWR-dialect record carries `start`/`end` + // and no `duration` tag, so the raw accessor returns null and the workout would vanish from + // the log entirely. WorkoutDisplay already reads it through the same helper. + val duration = effectiveDurationSeconds() ?: return null if (duration <= 0) return null return DetectedWorkout( diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PublishedWorkoutMappingTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PublishedWorkoutMappingTest.kt index 264e4735cf..3664f09399 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PublishedWorkoutMappingTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/fitness/PublishedWorkoutMappingTest.kt @@ -30,6 +30,8 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType import com.vitorpamplona.quartz.experimental.fitness.workout.tags.StepsTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.TitleTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutEndTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutStartTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutStartTimeTag import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -123,6 +125,24 @@ class PublishedWorkoutMappingTest { assertNull(event(DurationTag.assemble(1800)).toDetectedWorkout()) } + /** + * POWR-dialect records carry `start`/`end` and no `duration` tag. Reading the raw duration + * accessor drops them from the log entirely. + */ + @Test + fun `a POWR record with start and end but no duration tag is kept`() { + val workout = + event( + ExerciseTag.assemble(ExerciseType.CYCLING), + WorkoutStartTag.assemble(createdAt - 3600), + WorkoutEndTag.assemble(createdAt - 387), + ).toDetectedWorkout() + + assertNotNull(workout) + assertEquals(3213, workout!!.durationSeconds) + assertEquals(createdAt - 3600, workout.startTimeEpochSeconds) + } + @Test fun `an event with no duration is skipped`() { assertNull(event(ExerciseTag.assemble(ExerciseType.RUNNING)).toDetectedWorkout()) diff --git a/docs/health-connect-play-declaration.md b/docs/health-connect-play-declaration.md index a3add5e9e7..cafe931432 100644 --- a/docs/health-connect-play-declaration.md +++ b/docs/health-connect-play-declaration.md @@ -33,9 +33,9 @@ rather than *being* the feature. Related code: -- `service/workouts/health/WorkoutStats.kt` — all dashboard arithmetic; pure, unit-tested. -- `service/workouts/health/TrainingLog.kt` — merges Health Connect with the user's own published - workouts, deduping the ones that appear in both. +- `commons/.../commons/fitness/WorkoutStats.kt` — all dashboard arithmetic; pure, unit-tested. +- `commons/.../commons/fitness/TrainingLog.kt` — merges Health Connect with the user's own + published workouts, deduping the ones that appear in both. - `ui/screen/loggedIn/workouts/fitness/MyFitnessScreen.kt` — the dashboard. - `service/workouts/health/HealthConnectManager.kt` — the only place the app touches Health Connect. - `ui/screen/loggedIn/workouts/health/HealthConnectRationaleActivity.kt` — the in-app rationale screen. From 623faa87e82078d2f0f0b86ee4d6e56915f7bc05 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 16:13:33 +0000 Subject: [PATCH 10/11] fix: observe published workouts instead of scanning, and translate durations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review comments on #4129. **The dashboard now observes the cache.** publishedWorkoutsOf walked every note in LocalCache on a suspend call, so it was both O(cache) per refresh and a snapshot: a workout arriving from a relay — or the one the user had just posted — only appeared on the next resume. It is now LocalCache.observeEvents(Filter(kinds = [1301], authors = [me])), which is indexed by kind and author, so it neither walks the cache nor needs a timer, and the screen updates itself. That turned the ViewModel inside out for the better. Health Connect stays a push source — the platform has no change feed to observe, so refresh() still re-reads it on resume and on a permission grant — but the published side is a flow, and state is now a combine() of the two, keyed on the account via flatMapLatest so an account switch re-subscribes. Loading is expressed as "the Health Connect status is not resolved yet" rather than a separate flag. The carousel takes .first() from the same flow: it reloads on resume anyway, and one definition of "what have I already posted" is the point — two would let it offer something the dashboard knows is a duplicate. This does not close the REQ gap documented in the PR: observing the cache still only sees what something else fetched. It does mean that once the REQ exists, the dashboard will pick its results up without further work. **Durations are translatable.** formatDuration hardcoded "7h 12m" / "42m" / "45s". The suffixes are English abbreviations and the order of the two parts is not universal either, so both move into string resources and the function becomes @Composable. The zero case reuses the minutes form rather than a second literal. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019egdJyBHnrATZHjs86up8f --- .../workouts/health/PublishedWorkouts.kt | 32 +++---- .../workouts/fitness/MyFitnessFormat.kt | 20 +++-- .../workouts/fitness/MyFitnessViewModel.kt | 87 +++++++++++++------ .../suggestion/DetectedWorkoutCarousel.kt | 3 +- .../composeResources/values/strings.xml | 5 ++ 5 files changed, 97 insertions(+), 50 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/PublishedWorkouts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/PublishedWorkouts.kt index ad2d5fdfe5..7f0947f50a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/PublishedWorkouts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/PublishedWorkouts.kt @@ -24,30 +24,26 @@ import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout import com.vitorpamplona.amethyst.commons.fitness.toDetectedWorkout import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map /** - * The workouts [pubkeyHex] has published, from [sinceEpochSeconds] onwards, as the cache currently - * holds them. + * The workouts [pubkeyHex] has published, as a live view of the cache. * * Shared by the My Fitness dashboard, which counts them, and the New Workout carousel, which uses * them to avoid offering a workout the user already shared. Both need the same view of "what have * I already posted", and two answers to that question would mean the carousel offering something * the dashboard knows is a duplicate. * - * Scans off the main thread: LocalCache holds every event the session has seen and this walks all - * of them. It reports only what is already cached — it issues no REQ of its own. + * [LocalCache.observeEvents] rather than a scan: it is indexed by kind and author, so it neither + * walks every note in the cache nor needs re-running on a timer — the dashboard updates itself + * when a relay delivers a workout, including the one the user just published. + * + * Reports only what the cache holds; it issues no REQ of its own. */ -suspend fun publishedWorkoutsOf( - pubkeyHex: String, - sinceEpochSeconds: Long, -): List = - withContext(Dispatchers.Default) { - LocalCache.notes - .filterIntoSet { _, note -> - val event = note.event - event is WorkoutRecordEvent && event.pubKey == pubkeyHex - }.mapNotNull { (it.event as WorkoutRecordEvent).toDetectedWorkout() } - .filter { it.startTimeEpochSeconds >= sinceEpochSeconds } - } +fun publishedWorkoutsOf(pubkeyHex: String): Flow> = + LocalCache + .observeEvents( + Filter(kinds = listOf(WorkoutRecordEvent.KIND), authors = listOf(pubkeyHex)), + ).map { events -> events.mapNotNull { it.toDetectedWorkout() } } 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 index 576b2c7d38..9599e2c129 100644 --- 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 @@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.fitness import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.my_fitness_duration_hours_minutes +import com.vitorpamplona.amethyst.commons.resources.my_fitness_duration_minutes +import com.vitorpamplona.amethyst.commons.resources.my_fitness_duration_seconds import com.vitorpamplona.amethyst.commons.resources.my_fitness_unit_ft import com.vitorpamplona.amethyst.commons.resources.my_fitness_unit_km import com.vitorpamplona.amethyst.commons.resources.my_fitness_unit_m @@ -40,17 +43,24 @@ import kotlin.math.roundToLong */ internal fun prefersMiles(): Boolean = phonePrefersMiles() -/** `7h 12m` / `42m` / `45s` — a total, so hours run past 24 rather than wrapping. */ +/** + * `7h 12m` / `42m` / `45s` — a total, so hours run past 24 rather than wrapping. + * + * The unit suffixes come from string resources: "h"/"m"/"s" are English abbreviations, and the + * order of the two parts is not universal either, so both belong to the translator rather than + * to this function. + */ +@Composable internal fun formatDuration(totalSeconds: Long): String { - if (totalSeconds <= 0) return "0m" + if (totalSeconds <= 0) return stringRes(Res.string.my_fitness_duration_minutes, 0) val hours = totalSeconds / 3600 val minutes = (totalSeconds % 3600) / 60 return when { - hours > 0 -> "${hours}h ${minutes}m" - minutes > 0 -> "${minutes}m" - else -> "${totalSeconds}s" + hours > 0 -> stringRes(Res.string.my_fitness_duration_hours_minutes, hours, minutes) + minutes > 0 -> stringRes(Res.string.my_fitness_duration_minutes, minutes) + else -> stringRes(Res.string.my_fitness_duration_seconds, totalSeconds) } } 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 index 3f829aed08..ad8324944f 100644 --- 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 @@ -25,13 +25,22 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.fitness.DetectedWorkout import com.vitorpamplona.amethyst.commons.fitness.TrainingLog import com.vitorpamplona.amethyst.commons.fitness.WorkoutStats import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager import com.vitorpamplona.amethyst.service.workouts.health.publishedWorkoutsOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.time.Duration import java.time.Instant @@ -64,7 +73,7 @@ class MyFitnessViewModel : ViewModel() { @Immutable sealed interface State { - /** First load, or a reload after a permission change. */ + /** First load, before Health Connect has been checked. */ data object Loading : State /** @@ -78,46 +87,72 @@ class MyFitnessViewModel : ViewModel() { ) : State } - private val _state = MutableStateFlow(State.Loading) - val state: StateFlow = _state.asStateFlow() + private val pubkeyHex = MutableStateFlow(null) + + /** + * Health Connect's contribution. A push source: the platform has no change feed we can + * observe, so [refresh] re-reads it when the screen resumes or a permission is granted. + */ + private val fromHealthConnect = MutableStateFlow>(emptyList()) + + /** Null until the first [refresh] resolves, which is what keeps the screen on [State.Loading]. */ + private val healthConnectStatus = MutableStateFlow(null) private var manager: HealthConnectManager? = null - /** The pubkey whose workouts this dashboard summarises. Set by the screen before refreshing. */ - private var pubkeyHex: String? = null - - fun init(pubkeyHex: String) { - if (this.pubkeyHex != pubkeyHex) { - this.pubkeyHex = pubkeyHex - _state.value = State.Loading + /** + * The user's published workouts, live. Re-subscribes on an account switch; a workout arriving + * from a relay — or the one the user just posted — lands here without a refresh. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private val fromRelays: Flow> = + pubkeyHex.flatMapLatest { me -> + if (me == null) flowOf(emptyList()) else publishedWorkoutsOf(me) } + + val state: StateFlow = + combine(fromHealthConnect, fromRelays, healthConnectStatus) { healthConnect, published, status -> + if (status == null) { + State.Loading + } else { + val now = Instant.now() + val since = now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)).epochSecond + + State.Ready( + report = + WorkoutStats.report( + TrainingLog.merge(healthConnect, published.filter { it.startTimeEpochSeconds >= since }), + now, + ), + healthConnect = status, + ) + } + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), State.Loading) + + /** The account whose workouts this dashboard summarises. */ + fun init(pubkeyHex: String) { + this.pubkeyHex.value = pubkeyHex } /** - * Rebuilds the dashboard. Safe to call on every resume: it re-checks permissions as well as - * data, so revoking access in Health Connect drops those workouts out of the log rather than - * leaving stale numbers on display. + * Re-reads Health Connect. Safe to call on every resume: it re-checks permissions as well as + * data, so revoking access drops those workouts out of the log rather than leaving stale + * numbers on display. The published side needs no refresh — it is observed. */ fun refresh(context: Context) { viewModelScope.launch { - val now = Instant.now() - val since = now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)) - val status = healthConnectStatus(context) - val fromHealthConnect = + + fromHealthConnect.value = if (status == HealthConnectStatus.CONNECTED) { - manager?.readWorkouts(since, now).orEmpty() + val now = Instant.now() + manager?.readWorkouts(now.minus(Duration.ofDays(WorkoutStats.WINDOW_DAYS)), now).orEmpty() } else { emptyList() } - val fromRelays = publishedWorkoutsOf(pubkeyHex ?: return@launch, since.epochSecond) - - _state.value = - State.Ready( - report = WorkoutStats.report(TrainingLog.merge(fromHealthConnect, fromRelays), now), - healthConnect = status, - ) + healthConnectStatus.value = status } } 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 1f29d64ecd..0f2322ca1d 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 @@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.health.HealthConne import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.labelRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.symbol import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.time.Duration import java.time.Instant @@ -123,7 +124,7 @@ fun DetectedWorkoutCarousel( // the same effort. merge() flags the Health Connect copies that match something // already posted, so drop those; what is left is genuinely unshared. TrainingLog - .merge(manager.readWorkouts(since), publishedWorkoutsOf(myPubkey, since.epochSecond)) + .merge(manager.readWorkouts(since), publishedWorkoutsOf(myPubkey).first()) .filter { it.origin == WorkoutOrigin.HEALTH_CONNECT && !it.alreadyPublished } .sortedByDescending { it.startTimeEpochSeconds } } else { diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index ae6a40eb98..053702457f 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -694,6 +694,11 @@ Active days Day streak Time + + %1$dh %2$dm + %1$dm + %1$ds Distance Calories Steps From b3b1844a670eaf89ca41d572d870efa2e8113873 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 16:18:19 +0000 Subject: [PATCH 11/11] fix: translate the call timer, and stop folding hours into its minutes field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The call timer's formatDuration had a different problem from the workout one — no English words, just "%02d:%02d" — but two real ones underneath. A call past an hour rendered as "65:12": hours were folded into the minutes field rather than shown. It now switches to a three-field form at an hour and keeps the two-field form below it, so a five-minute call still reads "05:23" rather than the "00:05:23" that reusing DurationTag.formatTime would have given. The pattern moves into string resources. The separator and field order belong to the translator, and String.format without an explicit locale follows the JVM-wide default rather than the locale Compose is configured with — not the same thing in an app that ships its own language picker, where the two can disagree on digit shaping. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019egdJyBHnrATZHjs86up8f --- .../amethyst/ui/call/CallWidgets.kt | 24 +++++++++++++++++-- .../composeResources/values/strings.xml | 5 ++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt index 8e7041e7d6..faaa4d5eb9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt @@ -50,6 +50,8 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.call_calling +import com.vitorpamplona.amethyst.commons.resources.call_duration_hours_minutes_seconds +import com.vitorpamplona.amethyst.commons.resources.call_duration_minutes_seconds import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay @@ -400,10 +402,28 @@ fun GroupCallNames( } } +/** + * Elapsed call time as a clock: `05:23`, and `1:05:23` once the call passes an hour. + * + * Hours are not folded into the minutes field — the previous version rendered a 65-minute call + * as "65:12". + * + * The pattern comes from a string resource rather than a literal so the separator and field + * order stay the translator's, and so the numbers are formatted with the locale Compose is + * configured with. `String.format` without an explicit locale follows the JVM-wide default, + * which is not the same thing in an app that has its own language picker. + */ +@Composable fun formatDuration(seconds: Long): String { - val mins = seconds / 60 + val hours = seconds / 3600 + val mins = (seconds % 3600) / 60 val secs = seconds % 60 - return "%02d:%02d".format(mins, secs) + + return if (hours > 0) { + stringRes(Res.string.call_duration_hours_minutes_seconds, hours, mins, secs) + } else { + stringRes(Res.string.call_duration_minutes_seconds, mins, secs) + } } @Composable diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 053702457f..0cdb97a43e 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -1296,6 +1296,11 @@ Calling\u2026 Connecting\u2026 Call ended + + %1$02d:%2$02d + %1$d:%2$02d:%3$02d Dismiss Switch camera Voice call