diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index f0e6dc09e2..a55d77093a 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -365,6 +365,9 @@ dependencies { // Background Work implementation(libs.androidx.work.runtime.ktx) + // Reads workouts from Android Health Connect (Samsung Health, Google Fit, Fitbit, Garmin, …) + implementation(libs.androidx.health.connect.client) + // Websockets API implementation(libs.okhttp) implementation(libs.okhttpCoroutines) diff --git a/amethyst/plans/2026-06-16-health-connect-workout-detection.md b/amethyst/plans/2026-06-16-health-connect-workout-detection.md new file mode 100644 index 0000000000..cb031cdfb3 --- /dev/null +++ b/amethyst/plans/2026-06-16-health-connect-workout-detection.md @@ -0,0 +1,138 @@ +# Auto-detecting workouts from Android health data → suggest a kind 1301 post + +Research date: 2026-06-16. Goal: when the user finishes a workout that lands in +their phone's health data (Samsung Watch, Wear OS, Fitbit, Garmin, Strava, +Google Fit, manual entry in any health app), Amethyst should **notice it** and +**offer to publish it** as a NIP-101e `WorkoutRecordEvent` (kind 1301) — the +event type Quartz already implements and that RUNSTR and other fitness clients +read off relays. + +## 1. How RUNSTR sources workouts (and why we copy the Android half) + +RUNSTR (React Native + NDK) imports from **Apple HealthKit** on iOS and +**Google Health Connect** on Android, polling Health Connect on a ~15-minute +background cadence. It advertises Strava, Nike Run Club, Garmin, Apple Watch, +Fitbit and Google Fit as sources — but on Android every one of those funnels +through a single platform API: **Health Connect**. + +The Samsung Watch case the request calls out is the same story: +`Galaxy Watch → Samsung Health → Health Connect`. Samsung Health, Google Fit, +Fitbit, Strava and Garmin Connect all write `ExerciseSessionRecord`s into +Health Connect. So instead of integrating each vendor SDK (most of which are +proprietary and would fail F-Droid), we read the one aggregator, exactly as +RUNSTR does on Android. + +Health Connect is `androidx.health.connect:connect-client`, **Apache-2.0** +(permissive — fine for both the `play` and `fdroid` flavors; it is an AndroidX +library, not a Google Play Services blob). On Android 14+ the data store is +part of the OS; on 8–13 it is a separately installable system app. + +## 2. What already exists (reuse, don't rebuild) + +- `quartz` `WorkoutRecordEvent` (kind 1301) + the full NIP-101e/RUNSTR tag set + (`distance`, `duration`, `calories`, `avg/max_heart_rate`, `steps`, + `elevation_gain/loss`, `source`, `workout_start_time`, splits, strength…). + **No protocol work needed** beyond adding a `source` constant. +- A manual composer: `NewWorkoutScreen` + `NewWorkoutViewModel`, publishing via + `account.signAndComputeBroadcast`. This is the screen we pre-fill. +- `Route.NewWorkout` navigation target and the `WorkoutsScreen` (kind-1301 + feed) where the suggestion surfaces. + +The only missing layer is **detection → suggestion → pre-fill**. + +## 3. Decision: foreground scan (no background service) + +Per product decision (2026-06-16) this first cut runs **only while the app is +open** — no `WorkManager`, no `POST_NOTIFICATIONS`, no background-read +permission. That keeps the Play Store data-safety surface minimal and avoids a +foreground service. The seam for a future ~15-min background poll (RUNSTR +parity) is noted in §7. + +## 4. Architecture + +``` +service/workouts/health/ + HealthConnectManager.kt — wraps HealthConnectClient: availability, + permission set, readNewWorkouts(since) + DetectedWorkout.kt — platform-neutral mapped result (one session + + aggregated metrics), with toRoute() + ExerciseTypeMapper.kt — HC exercise-type Int → quartz ExerciseType + HealthConnectStore.kt — per-npub "last scan" watermark + dismissed ids + (SharedPreferences) + +ui/screen/loggedIn/workouts/ + WorkoutSuggestionViewModel.kt — holds StateFlow>; + scan(), dismiss(), permission state + WorkoutSuggestionCard.kt — banner at top of WorkoutsScreen + NewWorkoutViewModel.kt — +prefill(route) + richer metrics in template +``` + +Data flow: + +1. `WorkoutsScreen` opens → `WorkoutSuggestionViewModel.scan()` checks Health + Connect availability + granted permissions. If unavailable/denied, no card. +2. `HealthConnectManager.readNewWorkouts(since = watermark)` reads + `ExerciseSessionRecord`s ending after the watermark, and for each aggregates + `DistanceRecord`, `TotalCaloriesBurnedRecord`, `HeartRateRecord` (avg/max), + `StepsRecord`, `ElevationGainedRecord` over the session window. +3. Sessions already dismissed or already published (matched by + `workout_start_time` + `source=health_connect` in `LocalCache`) are filtered. +4. Remaining → `StateFlow` → `WorkoutSuggestionCard` ("New run detected · 5.2 km + · 28:14 — share it?"). Tap → `nav.nav(detected.toRoute())` opens the composer + pre-filled; user reviews and posts. Dismiss → add id to dismissed set. +5. After a scan the watermark advances to `now`, so each session is offered once. + +## 5. Permissions + +Health Connect uses its own permission strings and request contract +(`PermissionController.createRequestPermissionResultContract()`), *not* the +standard runtime-permission dialog. Manifest adds read-only: + +``` +android.permission.health.READ_EXERCISE +android.permission.health.READ_DISTANCE +android.permission.health.READ_TOTAL_CALORIES_BURNED +android.permission.health.READ_HEART_RATE +android.permission.health.READ_STEPS +android.permission.health.READ_ELEVATION_GAINED +``` + +plus the `` entry for the Health Connect package and the +privacy-policy `ACTION_SHOW_PERMISSIONS_RATIONALE` intent-filter Google +requires. The card only asks for permission when the user taps "Connect" — we +never request on cold start. + +## 6. Mapping (Health Connect → kind 1301) + +| Health Connect | kind 1301 tag | +|---|---| +| `ExerciseSessionRecord.exerciseType` | `exercise` (+ `t` hashtag) via `ExerciseTypeMapper` | +| `ExerciseSessionRecord.title` | `title` | +| `endTime − startTime` | `duration` (seconds) | +| `startTime` (epoch s) | `workout_start_time` | +| Σ `DistanceRecord` (m) | `distance` (km, 2-dp) | +| Σ `TotalCaloriesBurnedRecord` (kcal) | `calories` | +| avg/max `HeartRateRecord.bpm` | `avg_heart_rate` / `max_heart_rate` | +| Σ `StepsRecord.count` | `steps` | +| Σ `ElevationGainedRecord` (m) | `elevation_gain` | +| constant | `source = health_connect` | + +`source = "health_connect"` is added as a `SourceTag` constant in quartz +(alongside `gps`/`manual`; other clients already publish free-form sources). + +## 7. Future seam (out of scope now) + +A `WorkManager` `CoroutineWorker` calling the same +`HealthConnectManager.readNewWorkouts` on a 15-min `PeriodicWorkRequest`, gated +behind a setting, posting a `POST_NOTIFICATIONS` notification that deep-links to +the pre-filled composer. The reader, mapper and `DetectedWorkout.toRoute()` are +built to be reused verbatim; only the trigger + notification + extra Play +data-safety disclosure would be new. + +## 8. Licensing note + +`androidx.health.connect:connect-client:1.1.0` — Apache-2.0, permissive, OK for +the distributed APK under MIT. No copyleft. Verified against the AndroidX +release (Apache-2.0 like all of Jetpack). + + diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 065754cc4f..21479bac7f 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -3,6 +3,11 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + @@ -73,6 +78,15 @@ + + + + + + + + + + + + + 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 new file mode 100644 index 0000000000..32ac42aaeb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt @@ -0,0 +1,48 @@ +/* + * 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 + +/** + * A finished workout read from Health Connect and mapped to 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. + * + * [id] is the Health Connect record id, used to remember which sessions the + * user has already handled (accepted or dismissed) so each is offered once. + */ +@Immutable +data class DetectedWorkout( + val id: String, + val exercise: ExerciseType, + val title: String?, + val startTimeEpochSeconds: Long, + val durationSeconds: Long, + val distanceMeters: Double?, + val calories: Int?, + val avgHeartRate: Int?, + val maxHeartRate: Int?, + val steps: Int?, + val elevationGainMeters: Double?, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/ExerciseTypeMapper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/ExerciseTypeMapper.kt new file mode 100644 index 0000000000..bec70a2327 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/ExerciseTypeMapper.kt @@ -0,0 +1,67 @@ +/* + * 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.health.connect.client.records.ExerciseSessionRecord +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType + +/** + * Maps a Health Connect [ExerciseSessionRecord.exerciseType] integer to the + * NIP-101e [ExerciseType] verb. Returns null for activity types Amethyst's + * workout vocabulary cannot represent, so those sessions are skipped rather + * than mislabeled. + */ +object ExerciseTypeMapper { + fun toExerciseType(healthConnectType: Int): ExerciseType? = + when (healthConnectType) { + ExerciseSessionRecord.EXERCISE_TYPE_RUNNING, + ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL, + -> ExerciseType.RUNNING + + ExerciseSessionRecord.EXERCISE_TYPE_WALKING, + -> ExerciseType.WALKING + + ExerciseSessionRecord.EXERCISE_TYPE_BIKING, + ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY, + -> ExerciseType.CYCLING + + ExerciseSessionRecord.EXERCISE_TYPE_HIKING, + -> ExerciseType.HIKING + + ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL, + ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER, + -> ExerciseType.SWIMMING + + ExerciseSessionRecord.EXERCISE_TYPE_ROWING, + ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE, + -> ExerciseType.ROWING + + ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, + ExerciseSessionRecord.EXERCISE_TYPE_WEIGHTLIFTING, + ExerciseSessionRecord.EXERCISE_TYPE_CALISTHENICS, + -> ExerciseType.STRENGTH + + ExerciseSessionRecord.EXERCISE_TYPE_YOGA, + -> ExerciseType.YOGA + + else -> null + } +} 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 new file mode 100644 index 0000000000..9b3b91c83a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt @@ -0,0 +1,154 @@ +/* + * 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 android.content.Context +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.aggregate.AggregationResult +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.DistanceRecord +import androidx.health.connect.client.records.ElevationGainedRecord +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.StepsRecord +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.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import java.time.Duration +import java.time.Instant +import kotlin.math.roundToInt + +/** + * Reads finished workouts from Android Health Connect — the single aggregator + * every Android health source funnels into (Samsung Health/Galaxy Watch, + * Google Fit, Fitbit, Garmin Connect, Strava, …) — and maps each session to a + * [DetectedWorkout] ready to become a NIP-101e kind 1301 event. + * + * Read-only. The caller decides when to request permission; this class never + * prompts on its own. + */ +class HealthConnectManager( + private val context: Context, +) { + private val client: HealthConnectClient by lazy { HealthConnectClient.getOrCreate(context) } + + companion object { + private const val TAG = "HealthConnectManager" + + /** Read permissions needed to map a workout. */ + val PERMISSIONS = + setOf( + HealthPermission.getReadPermission(ExerciseSessionRecord::class), + HealthPermission.getReadPermission(DistanceRecord::class), + HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class), + HealthPermission.getReadPermission(HeartRateRecord::class), + HealthPermission.getReadPermission(StepsRecord::class), + HealthPermission.getReadPermission(ElevationGainedRecord::class), + ) + + /** True when a Health Connect provider is installed and up to date on this device. */ + fun isAvailable(context: Context): Boolean = HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE + + /** True when the device has no Health Connect provider at all (vs needing an update). */ + fun needsProviderUpdate(context: Context): Boolean = HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED + } + + suspend fun grantedPermissions(): Set = client.permissionController.getGrantedPermissions() + + suspend fun hasAllPermissions(): Boolean = grantedPermissions().containsAll(PERMISSIONS) + + /** + * All exercise sessions that ended within [since]..[now], mapped to + * [DetectedWorkout]. Sessions whose activity type Amethyst cannot represent + * are skipped. Returns an empty list (never throws) if Health Connect is + * unavailable or a read fails. + */ + suspend fun readNewWorkouts( + since: Instant, + now: Instant = Instant.now(), + ): List { + if (!isAvailable(context)) return emptyList() + + return try { + val response = + client.readRecords( + ReadRecordsRequest( + recordType = ExerciseSessionRecord::class, + timeRangeFilter = TimeRangeFilter.between(since, now), + ), + ) + response.records.mapNotNull { mapSession(it) } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "Failed to read workouts from Health Connect", e) + emptyList() + } + } + + private suspend fun mapSession(session: ExerciseSessionRecord): DetectedWorkout? { + val exercise = ExerciseTypeMapper.toExerciseType(session.exerciseType) ?: return null + + val durationSeconds = Duration.between(session.startTime, session.endTime).seconds + if (durationSeconds <= 0) return null + + val totals = aggregate(session) + + return DetectedWorkout( + id = session.metadata.id, + exercise = exercise, + title = session.title?.takeIf { it.isNotBlank() }, + startTimeEpochSeconds = session.startTime.epochSecond, + durationSeconds = durationSeconds, + distanceMeters = totals?.get(DistanceRecord.DISTANCE_TOTAL)?.inMeters, + calories = totals?.get(TotalCaloriesBurnedRecord.ENERGY_TOTAL)?.inKilocalories?.roundToInt(), + avgHeartRate = totals?.get(HeartRateRecord.BPM_AVG)?.toInt(), + maxHeartRate = totals?.get(HeartRateRecord.BPM_MAX)?.toInt(), + steps = totals?.get(StepsRecord.COUNT_TOTAL)?.toInt(), + elevationGainMeters = totals?.get(ElevationGainedRecord.ELEVATION_GAINED_TOTAL)?.inMeters, + ) + } + + /** Aggregates the optional metrics over the session window. Null if aggregation fails. */ + private suspend fun aggregate(session: ExerciseSessionRecord): AggregationResult? = + try { + client.aggregate( + AggregateRequest( + metrics = + setOf( + DistanceRecord.DISTANCE_TOTAL, + TotalCaloriesBurnedRecord.ENERGY_TOTAL, + HeartRateRecord.BPM_AVG, + HeartRateRecord.BPM_MAX, + StepsRecord.COUNT_TOTAL, + ElevationGainedRecord.ELEVATION_GAINED_TOTAL, + ), + timeRangeFilter = TimeRangeFilter.between(session.startTime, session.endTime), + ), + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "Failed to aggregate workout metrics", e) + null + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectStore.kt new file mode 100644 index 0000000000..92da6dae0c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectStore.kt @@ -0,0 +1,62 @@ +/* + * 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 android.content.Context + +/** + * Remembers which Health Connect workouts the user has already handled (accepted + * the suggestion or dismissed it), per account, so a session is offered once and + * the suggestion survives process death. Stored in a small private + * SharedPreferences file keyed by npub. + */ +class HealthConnectStore( + context: Context, +) { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun handledIds(npub: String): Set = prefs.getStringSet(key(npub), emptySet()) ?: emptySet() + + fun markHandled( + npub: String, + workoutId: String, + ) { + val current = handledIds(npub).toMutableSet() + current.add(workoutId) + // Cap the set so it cannot grow without bound; HC ids are opaque so we + // simply keep the most recent MAX_IDS by dropping arbitrary extras. + val capped = if (current.size > MAX_IDS) current.toList().takeLast(MAX_IDS).toSet() else current + prefs.edit().putStringSet(key(npub), capped).apply() + } + + private fun key(npub: String) = "handled_$npub" + + companion object { + private const val PREFS_NAME = "health_connect_workouts" + private const val MAX_IDS = 500 + + /** How far back the foreground scan looks for workouts to suggest. */ + const val LOOKBACK_DAYS = 7L + + /** Cap on suggestions surfaced at once, newest first. */ + const val MAX_SUGGESTIONS = 10 + } +} 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 3a996974ac..b2bff17501 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 @@ -507,8 +507,9 @@ fun BuildNavigation( ) } - composableFromBottom { + composableFromBottomArgs { NewWorkoutScreen( + prefill = it, accountViewModel = accountViewModel, nav = 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 b9340f3568..be8f33a6ec 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 @@ -612,7 +612,25 @@ sealed class Route { @Serializable data object NewGoal : Route() - @Serializable data object NewWorkout : Route() + /** + * Manual workout composer. All fields are optional pre-fill, used when a + * workout is detected from Health Connect and the user accepts the + * suggestion; a blank [NewWorkout] opens the empty manual form. + */ + @Serializable + data class NewWorkout( + val exercise: String? = null, + val title: String? = null, + val durationSeconds: Long = 0, + val distanceMeters: Double = 0.0, + val calories: Int = 0, + val avgHeartRate: Int = 0, + val maxHeartRate: Int = 0, + val steps: Int = 0, + val elevationGainMeters: Double = 0.0, + val startTime: Long = 0, + val source: String? = null, + ) : Route() @Serializable data class NewLongFormPost( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutButton.kt index 9e7dacd27e..9cc0260923 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutButton.kt @@ -37,7 +37,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @Composable fun NewWorkoutButton(nav: INav) { FloatingActionButton( - onClick = { nav.nav(Route.NewWorkout) }, + onClick = { nav.nav(Route.NewWorkout()) }, modifier = Size55Modifier, shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt index f4f9516444..fd1823d00e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -56,11 +57,13 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType @Composable fun NewWorkoutScreen( + prefill: Route.NewWorkout, accountViewModel: AccountViewModel, nav: INav, ) { val postViewModel: NewWorkoutViewModel = viewModel() postViewModel.init(accountViewModel) + postViewModel.prefill(prefill) BackHandler { postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutViewModel.kt index 69b461b7c5..969912f317 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutViewModel.kt @@ -26,15 +26,22 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.avgHeartRate import com.vitorpamplona.quartz.experimental.fitness.workout.calories import com.vitorpamplona.quartz.experimental.fitness.workout.distance +import com.vitorpamplona.quartz.experimental.fitness.workout.elevationGain +import com.vitorpamplona.quartz.experimental.fitness.workout.maxHeartRate import com.vitorpamplona.quartz.experimental.fitness.workout.source +import com.vitorpamplona.quartz.experimental.fitness.workout.steps import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DistanceTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SourceTag +import com.vitorpamplona.quartz.experimental.fitness.workout.workoutStartTime import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.math.roundToInt @Stable class NewWorkoutViewModel : ViewModel() { @@ -51,11 +58,53 @@ class NewWorkoutViewModel : ViewModel() { var calories by mutableStateOf("") var notes by mutableStateOf("") + // Source plus extra metrics carried from a detected workout. These are not + // editable in the simple form but are published when present (> 0). + private var source = SourceTag.MANUAL + private var avgHeartRate = 0 + private var maxHeartRate = 0 + private var steps = 0 + private var elevationGainMeters = 0.0 + private var workoutStartTime = 0L + + private var hasPrefilled = false + fun init(accountVM: AccountViewModel) { this.accountViewModel = accountVM this.account = accountVM.account } + /** + * Pre-fills the form from a [Route.NewWorkout] (e.g. a Health Connect + * detection). Applied once per ViewModel so user edits are not overwritten + * on recomposition; a blank route leaves the empty manual form untouched. + */ + fun prefill(route: Route.NewWorkout) { + if (hasPrefilled) return + hasPrefilled = true + + route.exercise?.let { ExerciseType.parse(it) }?.let { exercise = it } + route.title?.let { title = it } + + if (route.durationSeconds > 0) { + hours = (route.durationSeconds / 3600).toString() + minutes = ((route.durationSeconds % 3600) / 60).toString() + seconds = (route.durationSeconds % 60).toString() + } + if (route.distanceMeters > 0) { + distance = ((route.distanceMeters / 1000.0 * 100).roundToInt() / 100.0).toString() + distanceUnit = DistanceTag.KILOMETERS + } + if (route.calories > 0) calories = route.calories.toString() + + route.source?.let { source = it } + avgHeartRate = route.avgHeartRate + maxHeartRate = route.maxHeartRate + steps = route.steps + elevationGainMeters = route.elevationGainMeters + workoutStartTime = route.startTime + } + fun durationSeconds(): Long = (hours.toLongOrNull() ?: 0L) * 3600 + (minutes.toLongOrNull() ?: 0L) * 60 + @@ -73,6 +122,13 @@ class NewWorkoutViewModel : ViewModel() { distanceUnit = DistanceTag.KILOMETERS calories = "" notes = "" + source = SourceTag.MANUAL + avgHeartRate = 0 + maxHeartRate = 0 + steps = 0 + elevationGainMeters = 0.0 + workoutStartTime = 0L + hasPrefilled = false } suspend fun sendPostSync() { @@ -94,9 +150,14 @@ class NewWorkoutViewModel : ViewModel() { notes = notes, title = title.ifBlank { null }, ) { - source(SourceTag.MANUAL) + source(source) distanceValue?.let { distance(it, distanceUnit) } kcal?.let { calories(it) } + if (avgHeartRate > 0) avgHeartRate(avgHeartRate) + if (maxHeartRate > 0) maxHeartRate(maxHeartRate) + if (steps > 0) steps(steps) + if (elevationGainMeters > 0) elevationGain(elevationGainMeters) + if (workoutStartTime > 0) workoutStartTime(workoutStartTime) } } } 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..5a6e93fb5f 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,9 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox @@ -31,12 +36,14 @@ import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.layouts.LocalDisappearingScaffoldPadding import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded 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.suggestion.WorkoutSuggestions @Composable fun WorkoutsScreen( @@ -81,16 +88,28 @@ 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", - ) + Box(Modifier.fillMaxSize()) { + RefresheableBox(workoutsFeedContentState, true) { + SaveableFeedContentState(workoutsFeedContentState, scrollStateKey = ScrollStateKeys.WORKOUTS_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = workoutsFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "WorkoutsFeed", + ) + } } + + // Health Connect detection banner: invites connect or offers detected workouts as kind 1301 posts. + WorkoutSuggestions( + accountViewModel = accountViewModel, + nav = nav, + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(top = LocalDisappearingScaffoldPadding.current.calculateTopPadding()), + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionCard.kt new file mode 100644 index 0000000000..95b908da18 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionCard.kt @@ -0,0 +1,245 @@ +/* + * 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.suggestion + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.health.connect.client.PermissionController +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout +import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager +import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectStore +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.labelRes +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SourceTag +import kotlinx.coroutines.launch + +/** + * Banner shown above the Workouts feed. When Health Connect is available it + * either invites the user to connect (first run) or surfaces workouts detected + * since the last visit, each offering to open the pre-filled kind 1301 composer. + * Renders nothing on devices without Health Connect. + */ +@Composable +fun WorkoutSuggestions( + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val available = remember { HealthConnectManager.isAvailable(context) } + if (!available) return + + val pubkeyHex = accountViewModel.account.signer.pubKey + val state = + remember(pubkeyHex) { + WorkoutSuggestionState( + manager = HealthConnectManager(context), + store = HealthConnectStore(context), + pubkeyHex = pubkeyHex, + ) + } + val scope = rememberCoroutineScope() + + val permissionLauncher = + rememberLauncherForActivityResult(PermissionController.createRequestPermissionResultContract()) { + scope.launch { state.refresh() } + } + + LaunchedEffect(pubkeyHex) { state.refresh() } + + val suggestions by state.suggestions.collectAsStateWithLifecycle() + val hasPermission by state.hasPermission.collectAsStateWithLifecycle() + var connectDismissed by rememberSaveable(pubkeyHex) { mutableStateOf(false) } + + if (suggestions.isEmpty() && (hasPermission || connectDismissed)) return + + Column( + modifier = modifier.fillMaxWidth().padding(horizontal = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (!hasPermission && !connectDismissed) { + ConnectHealthCard( + onConnect = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) }, + onDismiss = { connectDismissed = true }, + ) + } + + suggestions.forEach { workout -> + WorkoutSuggestionRow( + workout = workout, + onShare = { + state.handle(workout.id) + nav.nav(workout.toNewWorkoutRoute()) + }, + onDismiss = { state.handle(workout.id) }, + ) + } + } +} + +@Composable +private fun ConnectHealthCard( + onConnect: () -> Unit, + onDismiss: () -> Unit, +) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 14.dp, top = 10.dp, bottom = 4.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(R.string.workout_suggestion_connect_title), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDismiss) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.workout_suggestion_dismiss), + ) + } + } + Text( + text = stringRes(R.string.workout_suggestion_connect_message), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 14.dp), + ) + Row(modifier = Modifier.fillMaxWidth().padding(end = 8.dp), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onConnect) { + Text(stringRes(R.string.workout_suggestion_connect_button)) + } + } + } +} + +@Composable +private fun WorkoutSuggestionRow( + workout: DetectedWorkout, + onShare: () -> Unit, + onDismiss: () -> Unit, +) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 14.dp, top = 10.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.DirectionsRun, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { + Text( + text = workout.title ?: stringRes(R.string.workout_suggestion_detected_title), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = workout.summaryLine(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onDismiss) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.workout_suggestion_dismiss), + ) + } + } + Row(modifier = Modifier.fillMaxWidth().padding(end = 8.dp), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onShare) { + Text(stringRes(R.string.workout_suggestion_share)) + } + } + } +} + +/** "Running · 5.20 km · 28:14" — activity, distance (if any), then duration. */ +@Composable +private fun DetectedWorkout.summaryLine(): String { + val parts = mutableListOf(stringRes(exercise.labelRes())) + distanceMeters?.takeIf { it > 0 }?.let { + parts.add(stringRes(R.string.workout_suggestion_distance_km, "%.2f".format(it / 1000.0))) + } + parts.add(formatDuration(durationSeconds)) + return parts.joinToString(" · ") +} + +private fun formatDuration(totalSeconds: Long): String { + val h = totalSeconds / 3600 + val m = (totalSeconds % 3600) / 60 + val s = totalSeconds % 60 + return if (h > 0) { + "%d:%02d:%02d".format(h, m, s) + } else { + "%d:%02d".format(m, s) + } +} + +private fun DetectedWorkout.toNewWorkoutRoute() = + Route.NewWorkout( + exercise = exercise.code, + title = title, + durationSeconds = durationSeconds, + distanceMeters = distanceMeters ?: 0.0, + calories = calories ?: 0, + avgHeartRate = avgHeartRate ?: 0, + maxHeartRate = maxHeartRate ?: 0, + steps = steps ?: 0, + elevationGainMeters = elevationGainMeters ?: 0.0, + startTime = startTimeEpochSeconds, + source = SourceTag.HEALTH_CONNECT, + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionState.kt new file mode 100644 index 0000000000..3162e99c2b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/WorkoutSuggestionState.kt @@ -0,0 +1,76 @@ +/* + * 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.suggestion + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.service.workouts.health.DetectedWorkout +import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectManager +import com.vitorpamplona.amethyst.service.workouts.health.HealthConnectStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.time.Duration +import java.time.Instant + +/** + * Foreground state holder for the Health Connect workout suggestions shown on + * the Workouts screen. Scans Health Connect for recently finished workouts the + * user has not yet handled and exposes them for the suggestion banner. + * + * Remembered in composition (not a ViewModel) to match the codebase's + * permission-launcher pattern; [refresh] is driven from a LaunchedEffect. + */ +@Stable +class WorkoutSuggestionState( + private val manager: HealthConnectManager, + private val store: HealthConnectStore, + private val pubkeyHex: String, +) { + private val _suggestions = MutableStateFlow>(emptyList()) + val suggestions = _suggestions.asStateFlow() + + private val _hasPermission = MutableStateFlow(false) + val hasPermission = _hasPermission.asStateFlow() + + suspend fun refresh() { + val granted = manager.hasAllPermissions() + _hasPermission.value = granted + if (!granted) { + _suggestions.value = emptyList() + return + } + + val since = Instant.now().minus(Duration.ofDays(HealthConnectStore.LOOKBACK_DAYS)) + val handled = store.handledIds(pubkeyHex) + + _suggestions.value = + manager + .readNewWorkouts(since) + .filter { it.id !in handled } + .sortedByDescending { it.startTimeEpochSeconds } + .take(HealthConnectStore.MAX_SUGGESTIONS) + } + + /** Marks a workout handled (accepted or dismissed) so it is not offered again. */ + fun handle(workoutId: String) { + store.markHandled(pubkeyHex, workoutId) + _suggestions.value = _suggestions.value.filterNot { it.id == workoutId } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 86d82fcc06..87a6aa328a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -701,6 +701,13 @@ Hours Minutes Seconds + Share your workouts + Let Amethyst read finished workouts from Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) and suggest a post. + Connect + New workout detected + Share + Dismiss + %1$s km Running Walking Cycling diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 37e80fa957..9e7bfa05d5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ espressoCore = "3.7.0" firebaseBom = "34.14.1" fragmentKtx = "1.8.9" gms = "4.4.4" +healthConnect = "1.1.0" jacksonModuleKotlin = "2.22.0" javaKeyring = "1.0.4" kmpTorRuntime = "2.6.0" @@ -216,6 +217,7 @@ androidx-sqlite = { group = "androidx.sqlite", name = "sqlite", version.ref = "s androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } androidx-sqlite-bundled-jvm = { module = "androidx.sqlite:sqlite-bundled-jvm", version.ref = "sqlite" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntime" } +androidx-health-connect-client = { group = "androidx.health.connect", name = "connect-client", version.ref = "healthConnect" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/SourceTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/SourceTag.kt index 055b77152c..fee762b0ee 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/SourceTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/SourceTag.kt @@ -23,7 +23,10 @@ package com.vitorpamplona.quartz.experimental.fitness.workout.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure -/** How the workout was recorded. Known values: [GPS], [MANUAL]; other clients also publish `healthkit` and `runstr`. */ +/** + * How the workout was recorded. Known values: [GPS], [MANUAL], [HEALTH_CONNECT]; + * other clients also publish `healthkit` and `runstr`. + */ class SourceTag { companion object { const val TAG_NAME = "source" @@ -31,6 +34,9 @@ class SourceTag { const val GPS = "gps" const val MANUAL = "manual" + /** Imported from Android Health Connect (Samsung Health, Google Fit, Fitbit, Garmin, …). */ + const val HEALTH_CONNECT = "health_connect" + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() fun parse(tag: Array): String? {