mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
feat: detect workouts from Health Connect and suggest a kind 1301 post
Adds foreground auto-detection of finished workouts on Android via Google Health Connect — the single aggregator every Android health source funnels into (Samsung Health/Galaxy Watch, Google Fit, Fitbit, Garmin, Strava), the same Android path RUNSTR uses. On opening the Workouts screen, Amethyst scans Health Connect for sessions the user hasn't handled and surfaces a banner that opens the existing workout composer pre-filled, ready to publish as a NIP-101e WorkoutRecordEvent (kind 1301). - HealthConnectManager reads ExerciseSessionRecord + aggregated distance, calories, heart rate, steps and elevation, mapping each to DetectedWorkout. - ExerciseTypeMapper maps Health Connect activity types to NIP-101e verbs. - HealthConnectStore remembers handled sessions per account so each is offered once; 7-day foreground lookback, no background service. - WorkoutSuggestions banner: connect prompt (on-demand permission request, never on cold start) or detected-workout rows on the Workouts screen. - Route.NewWorkout carries optional pre-fill; NewWorkoutViewModel publishes the richer metrics (heart rate, steps, elevation, start time) with source=health_connect (new SourceTag constant in quartz). - androidx.health.connect:connect-client (Apache-2.0) + read-only health permissions and the required privacy-rationale manifest entries. Design notes in amethyst/plans/2026-06-16-health-connect-workout-detection.md; background ~15-min polling + notification left as a documented future seam. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qgqQKHSewRHVM8vSCLt9P
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<List<DetectedWorkout>>;
|
||||
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 `<queries>` 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).
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -3,6 +3,11 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<queries>
|
||||
<package android:name="org.torproject.android"/>
|
||||
<!-- Health Connect data store (Android 8–13 ships it as a separate system app) -->
|
||||
<package android:name="com.google.android.apps.healthdata" />
|
||||
<intent>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
@@ -73,6 +78,15 @@
|
||||
<!-- Adds Geohash to posts if active -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!-- Reads finished workouts from Health Connect to suggest a kind 1301 post.
|
||||
Read-only; requested on demand, never on cold start. -->
|
||||
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
|
||||
<uses-permission android:name="android.permission.health.READ_DISTANCE" />
|
||||
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED" />
|
||||
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
|
||||
<uses-permission android:name="android.permission.health.READ_STEPS" />
|
||||
<uses-permission android:name="android.permission.health.READ_ELEVATION_GAINED" />
|
||||
|
||||
<!-- Old permission to access media -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
@@ -109,6 +123,11 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Health Connect privacy-policy rationale (required by Google when reading health data) -->
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
+48
@@ -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?,
|
||||
)
|
||||
+67
@@ -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
|
||||
}
|
||||
}
|
||||
+154
@@ -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<String> = 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<DetectedWorkout> {
|
||||
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
|
||||
}
|
||||
}
|
||||
+62
@@ -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<String> = 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
|
||||
}
|
||||
}
|
||||
@@ -507,8 +507,9 @@ fun BuildNavigation(
|
||||
)
|
||||
}
|
||||
|
||||
composableFromBottom<Route.NewWorkout> {
|
||||
composableFromBottomArgs<Route.NewWorkout> {
|
||||
NewWorkoutScreen(
|
||||
prefill = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+3
@@ -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()
|
||||
|
||||
+62
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-9
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+245
@@ -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,
|
||||
)
|
||||
+76
@@ -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<List<DetectedWorkout>>(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 }
|
||||
}
|
||||
}
|
||||
@@ -701,6 +701,13 @@
|
||||
<string name="workout_hours">Hours</string>
|
||||
<string name="workout_minutes">Minutes</string>
|
||||
<string name="workout_seconds">Seconds</string>
|
||||
<string name="workout_suggestion_connect_title">Share your workouts</string>
|
||||
<string name="workout_suggestion_connect_message">Let Amethyst read finished workouts from Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) and suggest a post.</string>
|
||||
<string name="workout_suggestion_connect_button">Connect</string>
|
||||
<string name="workout_suggestion_detected_title">New workout detected</string>
|
||||
<string name="workout_suggestion_share">Share</string>
|
||||
<string name="workout_suggestion_dismiss">Dismiss</string>
|
||||
<string name="workout_suggestion_distance_km">%1$s km</string>
|
||||
<string name="exercise_running">Running</string>
|
||||
<string name="exercise_walking">Walking</string>
|
||||
<string name="exercise_cycling">Cycling</string>
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+7
-1
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
|
||||
Reference in New Issue
Block a user