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:
Claude
2026-06-16 22:19:58 +00:00
parent adcf71d43e
commit 6de43dec2d
18 changed files with 943 additions and 14 deletions
+3
View File
@@ -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 813 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>
+19
View File
@@ -3,6 +3,11 @@
xmlns:tools="http://schemas.android.com/tools">
<queries>
<package android:name="org.torproject.android"/>
<!-- Health Connect data store (Android 813 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" />
@@ -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?,
)
@@ -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
}
}
@@ -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
}
}
@@ -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(
@@ -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,
@@ -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()
@@ -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)
}
}
}
@@ -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()),
)
}
}
}
@@ -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,
)
@@ -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 }
}
}
+7
View File
@@ -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>
+2
View File
@@ -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" }
@@ -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? {