Merge pull request #3184 from vitorpamplona/claude/beautiful-turing-j0czsm

Add NIP-101e fitness workout support (Kind 1301)
This commit is contained in:
Vitor Pamplona
2026-06-11 18:26:36 -04:00
committed by GitHub
54 changed files with 3203 additions and 0 deletions
@@ -105,6 +105,7 @@ private object PrefKeys {
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val DEFAULT_POLLS_FOLLOW_LIST = "defaultPollsFollowList"
const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
const val DEFAULT_WORKOUTS_FOLLOW_LIST = "defaultWorkoutsFollowList"
const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList"
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
@@ -383,6 +384,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value))
putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value))
putString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultWorkoutsFollowList.value))
putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value))
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
@@ -698,6 +700,7 @@ object LocalPreferences {
defaultDiscoveryFollowList = MutableStateFlow(followListPrefs.discovery),
defaultPollsFollowList = MutableStateFlow(followListPrefs.polls),
defaultPicturesFollowList = MutableStateFlow(followListPrefs.pictures),
defaultWorkoutsFollowList = MutableStateFlow(followListPrefs.workouts),
defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars),
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts),
@@ -785,6 +788,7 @@ object LocalPreferences {
val discovery: TopFilter,
val polls: TopFilter,
val pictures: TopFilter,
val workouts: TopFilter,
val calendars: TopFilter,
val products: TopFilter,
val shorts: TopFilter,
@@ -812,6 +816,7 @@ object LocalPreferences {
discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global),
pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global),
workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global),
calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global),
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global),
@@ -526,6 +526,9 @@ class Account(
val livePicturesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPicturesFollowList)
val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow
val liveWorkoutsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultWorkoutsFollowList)
val liveWorkoutsFollowListsPerRelay = OutboxLoaderState(liveWorkoutsFollowLists, cache, scope).flow
val liveCalendarsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultCalendarsFollowList)
val liveCalendarsFollowListsPerRelay = OutboxLoaderState(liveCalendarsFollowLists, cache, scope).flow
@@ -179,6 +179,7 @@ class AccountSettings(
val defaultDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPollsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPicturesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultWorkoutsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCalendarsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -620,6 +621,17 @@ class AccountSettings(
}
}
fun changeDefaultWorkoutsFollowList(name: FeedDefinition) {
changeDefaultWorkoutsFollowList(name.code)
}
fun changeDefaultWorkoutsFollowList(name: TopFilter) {
if (defaultWorkoutsFollowList.value != name) {
defaultWorkoutsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultCalendarsFollowList(name: FeedDefinition) {
changeDefaultCalendarsFollowList(name.code)
}
@@ -59,6 +59,7 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
@@ -3964,6 +3965,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consume(event, relay, wasVerified)
}
is WorkoutRecordEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is PaymentTargetsEvent -> {
consume(event, relay, wasVerified)
}
@@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.Sof
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.datasource.OnchainZapsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.WorkoutsFilterAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus
@@ -120,6 +121,7 @@ class RelaySubscriptionsCoordinator(
val polls = PollsFilterAssembler(client)
val pictures = PicturesFilterAssembler(client)
val workouts = WorkoutsFilterAssembler(client)
val calendars = CalendarsFilterAssembler(client)
val products = ProductsFilterAssembler(client)
val shorts = ShortsFilterAssembler(client)
@@ -166,6 +168,7 @@ class RelaySubscriptionsCoordinator(
discovery,
polls,
pictures,
workouts,
calendars,
products,
shorts,
@@ -65,6 +65,7 @@ object ScrollStateKeys {
const val BROWSE_EMOJI_SETS_SCREEN = "BrowseEmojiSetsFeed"
const val COMMUNITIES_LIST = "CommunitiesListFeed"
const val PICTURES_SCREEN = "PicturesFeed"
const val WORKOUTS_SCREEN = "WorkoutsFeed"
const val CALENDARS_SCREEN = "CalendarsFeed"
const val CALENDAR_COLLECTIONS_SCREEN = "CalendarCollectionsFeed"
const val PRODUCTS_SCREEN = "ProductsFeed"
@@ -211,6 +211,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletTransactionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.WebBookmarksScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.NewWorkoutScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -277,6 +279,7 @@ fun BuildNavigation(
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.Workouts> { WorkoutsScreen(accountViewModel, nav) }
composableFromEnd<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
@@ -500,6 +503,13 @@ fun BuildNavigation(
)
}
composableFromBottom<Route.NewWorkout> {
NewWorkoutScreen(
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromBottomArgs<Route.HashtagPost> {
HashtagPostScreen(
hashtag = it.hashtag,
@@ -50,6 +50,7 @@ enum class NavBarItem {
COMMUNITIES,
ARTICLES,
PICTURES,
WORKOUTS,
SOFTWARE_APPS,
CALENDARS,
CALENDAR_COLLECTIONS,
@@ -206,6 +207,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Photo,
resolveRoute = { Route.Pictures },
),
NavBarItem.WORKOUTS to
NavBarItemDef(
id = NavBarItem.WORKOUTS,
labelRes = R.string.workouts,
icon = MaterialSymbols.DirectionsRun,
resolveRoute = { Route.Workouts },
),
NavBarItem.SOFTWARE_APPS to
NavBarItemDef(
id = NavBarItem.SOFTWARE_APPS,
@@ -374,6 +382,7 @@ val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.COMMUNITIES,
NavBarItem.ARTICLES,
NavBarItem.PICTURES,
NavBarItem.WORKOUTS,
NavBarItem.SOFTWARE_APPS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
@@ -81,6 +81,8 @@ sealed class Route {
@Serializable object Pictures : Route()
@Serializable object Workouts : Route()
@Serializable object SoftwareApps : Route()
@Serializable data class SoftwareAppDetail(
@@ -591,6 +593,8 @@ sealed class Route {
@Serializable data object NewGoal : Route()
@Serializable data object NewWorkout : Route()
@Serializable
data class NewLongFormPost(
val draft: String? = null,
@@ -189,6 +189,7 @@ import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Font12SP
@@ -220,6 +221,7 @@ import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
@@ -1363,6 +1365,10 @@ private fun RenderNoteRow(
PictureDisplay(baseNote, true, ContentScale.FillWidth, PaddingValues(vertical = 5.dp), backgroundColor, accountViewModel, nav)
}
is WorkoutRecordEvent -> {
WorkoutDisplay(baseNote)
}
is BaseVoiceEvent -> {
RenderVoiceTrack(baseNote, accountViewModel, nav)
}
@@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.dal.ShortsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.dal.SoftwareAppsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.dal.WorkoutFeedFilter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.drop
@@ -107,6 +108,7 @@ class AccountFeedContentStates(
val communitiesList = FeedContentState(CommunitiesFeedFilter(account), scope, LocalCache)
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
val workoutsFeed = FeedContentState(WorkoutFeedFilter(account), scope, LocalCache)
val calendarAppointmentsFeed = FeedContentState(CalendarAppointmentsFeedFilter(account), scope, LocalCache)
val calendarCollectionsFeed = FeedContentState(CalendarCollectionsFeedFilter(account), scope, LocalCache)
val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache)
@@ -213,6 +215,7 @@ class AccountFeedContentStates(
communitiesList.updateFeedWith(newNotes)
picturesFeed.updateFeedWith(newNotes)
workoutsFeed.updateFeedWith(newNotes)
productsFeed.updateFeedWith(newNotes)
shortsFeed.updateFeedWith(newNotes)
publicChatsFeed.updateFeedWith(newNotes)
@@ -273,6 +276,7 @@ class AccountFeedContentStates(
communitiesList.deleteFromFeed(newNotes)
picturesFeed.deleteFromFeed(newNotes)
workoutsFeed.deleteFromFeed(newNotes)
productsFeed.deleteFromFeed(newNotes)
shortsFeed.deleteFromFeed(newNotes)
publicChatsFeed.deleteFromFeed(newNotes)
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.Publ
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.datasource.SoftwareAppsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.WorkoutsFilterAssemblerSubscription
/**
* Activates the relay subscription for each feed the user has pinned to the bottom
@@ -90,6 +91,8 @@ private fun PreloadFor(
NavBarItem.PICTURES -> PicturesFilterAssemblerSubscription(accountViewModel)
NavBarItem.WORKOUTS -> WorkoutsFilterAssemblerSubscription(accountViewModel)
NavBarItem.SOFTWARE_APPS -> SoftwareAppsFilterAssemblerSubscription(accountViewModel)
NavBarItem.CALENDARS,
@@ -118,6 +118,7 @@ import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
@@ -645,6 +646,7 @@ fun kindDisplayName(kind: Int): Int =
PeopleListEvent.KIND -> R.string.kind_people_lists
ProfileBadgesEvent.KIND -> R.string.kind_profile_badges
PictureEvent.KIND -> R.string.kind_pictures
WorkoutRecordEvent.KIND -> R.string.kind_workouts
PinListEvent.KIND -> R.string.kind_pins
ZapPollEvent.KIND -> R.string.kind_zap_poll
PollEvent.KIND -> R.string.kind_poll
@@ -206,6 +206,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28P
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.LevelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -233,6 +234,7 @@ import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
@@ -633,6 +635,8 @@ private fun FullBleedNoteCompose(
VideoDisplay(baseNote, makeItShort = false, canPreview = true, backgroundColor = backgroundColor, ContentScale.FillWidth, accountViewModel = accountViewModel, nav = nav)
} else if (noteEvent is PictureEvent) {
PictureDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, PaddingValues(vertical = Size5dp), backgroundColor, accountViewModel = accountViewModel, nav)
} else if (noteEvent is WorkoutRecordEvent) {
WorkoutDisplay(baseNote)
} else if (noteEvent is BaseVoiceEvent) {
VoiceHeader(noteEvent, baseNote, accountViewModel, nav)
} else if (noteEvent is FileHeaderEvent) {
@@ -0,0 +1,52 @@
/*
* 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
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
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.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
@Composable
fun NewWorkoutButton(nav: INav) {
FloatingActionButton(
onClick = { nav.nav(Route.NewWorkout) },
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
symbol = MaterialSymbols.DirectionsRun,
contentDescription = stringRes(id = R.string.new_workout),
modifier = Size26Modifier,
tint = Color.White,
)
}
}
@@ -0,0 +1,200 @@
/*
* 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
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
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.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DistanceTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
@Composable
fun NewWorkoutScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val postViewModel: NewWorkoutViewModel = viewModel()
postViewModel.init(accountViewModel)
BackHandler {
postViewModel.cancel()
nav.popBack()
}
Scaffold(
topBar = {
PostingTopBar(
titleRes = R.string.new_workout,
isActive = postViewModel::canPost,
onCancel = {
postViewModel.cancel()
nav.popBack()
},
onPost = {
accountViewModel.launchSigner {
postViewModel.sendPostSync()
nav.popBack()
}
},
)
},
) { pad ->
Surface(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding(),
) {
NewWorkoutBody(postViewModel)
}
}
}
@Composable
private fun NewWorkoutBody(postViewModel: NewWorkoutViewModel) {
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
ExerciseTypeSelector(postViewModel.exercise) { postViewModel.exercise = it }
OutlinedTextField(
value = postViewModel.title,
onValueChange = { postViewModel.title = it },
label = { Text(stringRes(R.string.workout_title)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Text(
text = stringRes(R.string.workout_duration),
style = MaterialTheme.typography.titleSmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
NumberField(postViewModel.hours, { postViewModel.hours = it }, stringRes(R.string.workout_hours), Modifier.weight(1f))
NumberField(postViewModel.minutes, { postViewModel.minutes = it }, stringRes(R.string.workout_minutes), Modifier.weight(1f))
NumberField(postViewModel.seconds, { postViewModel.seconds = it }, stringRes(R.string.workout_seconds), Modifier.weight(1f))
}
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = postViewModel.distance,
onValueChange = { postViewModel.distance = it },
label = { Text(stringRes(R.string.workout_distance)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(2f),
singleLine = true,
)
FilterChip(
selected = postViewModel.distanceUnit == DistanceTag.KILOMETERS,
onClick = { postViewModel.distanceUnit = DistanceTag.KILOMETERS },
label = { Text(DistanceTag.KILOMETERS) },
)
FilterChip(
selected = postViewModel.distanceUnit == DistanceTag.MILES,
onClick = { postViewModel.distanceUnit = DistanceTag.MILES },
label = { Text(DistanceTag.MILES) },
)
}
NumberField(postViewModel.calories, { postViewModel.calories = it }, stringRes(R.string.workout_calories), Modifier.fillMaxWidth())
OutlinedTextField(
value = postViewModel.notes,
onValueChange = { postViewModel.notes = it },
label = { Text(stringRes(R.string.workout_notes)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ExerciseTypeSelector(
selected: ExerciseType,
onSelect: (ExerciseType) -> Unit,
) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
ExerciseType.entries.forEach { type ->
FilterChip(
selected = type == selected,
onClick = { onSelect(type) },
label = { Text(stringRes(type.labelRes())) },
)
}
}
}
@Composable
private fun NumberField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = modifier,
singleLine = true,
)
}
@@ -0,0 +1,102 @@
/*
* 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
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.calories
import com.vitorpamplona.quartz.experimental.fitness.workout.distance
import com.vitorpamplona.quartz.experimental.fitness.workout.source
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.nip01Core.signers.EventTemplate
@Stable
class NewWorkoutViewModel : ViewModel() {
lateinit var accountViewModel: AccountViewModel
lateinit var account: Account
var exercise by mutableStateOf(ExerciseType.RUNNING)
var title by mutableStateOf("")
var hours by mutableStateOf("")
var minutes by mutableStateOf("")
var seconds by mutableStateOf("")
var distance by mutableStateOf("")
var distanceUnit by mutableStateOf(DistanceTag.KILOMETERS)
var calories by mutableStateOf("")
var notes by mutableStateOf("")
fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
this.account = accountVM.account
}
fun durationSeconds(): Long =
(hours.toLongOrNull() ?: 0L) * 3600 +
(minutes.toLongOrNull() ?: 0L) * 60 +
(seconds.toLongOrNull() ?: 0L)
fun canPost(): Boolean = durationSeconds() > 0
fun cancel() {
exercise = ExerciseType.RUNNING
title = ""
hours = ""
minutes = ""
seconds = ""
distance = ""
distanceUnit = DistanceTag.KILOMETERS
calories = ""
notes = ""
}
suspend fun sendPostSync() {
val template = createTemplate() ?: return
cancel()
account.signAndComputeBroadcast(template)
}
private fun createTemplate(): EventTemplate<WorkoutRecordEvent>? {
val durationSecs = durationSeconds()
if (durationSecs <= 0) return null
val distanceValue = distance.toDoubleOrNull()
val kcal = calories.toIntOrNull()
return WorkoutRecordEvent.build(
exercise = exercise,
durationSeconds = durationSecs,
notes = notes,
title = title.ifBlank { null },
) {
source(SourceTag.MANUAL)
distanceValue?.let { distance(it, distanceUnit) }
kcal?.let { calories(it) }
}
}
}
@@ -0,0 +1,73 @@
/*
* 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
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
@Composable
fun WorkoutCardCompose(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = (baseNote.event as? WorkoutRecordEvent) ?: return
Column(
modifier =
Modifier.fillMaxWidth().clickable {
routeFor(baseNote, accountViewModel.account)?.let { nav.nav(it) }
},
) {
UserCardHeader(baseNote, accountViewModel, nav)
WorkoutDisplay(baseNote)
if (event.content.isNotBlank()) {
Text(
text = event.content,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
)
}
ReactionsRow(
baseNote = baseNote,
showReactionDetail = true,
addPadding = true,
editState = null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@@ -0,0 +1,243 @@
/*
* 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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DistanceTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DurationTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.Elevation
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WeightTag
import kotlin.math.abs
fun ExerciseType?.symbol(): MaterialSymbol =
when (this) {
ExerciseType.RUNNING -> MaterialSymbols.DirectionsRun
ExerciseType.WALKING -> MaterialSymbols.DirectionsWalk
ExerciseType.CYCLING -> MaterialSymbols.DirectionsBike
ExerciseType.HIKING -> MaterialSymbols.Hiking
ExerciseType.SWIMMING -> MaterialSymbols.Pool
ExerciseType.ROWING -> MaterialSymbols.Rowing
ExerciseType.STRENGTH -> MaterialSymbols.FitnessCenter
ExerciseType.YOGA -> MaterialSymbols.SelfImprovement
ExerciseType.MEDITATION -> MaterialSymbols.SelfImprovement
ExerciseType.DIET -> MaterialSymbols.Restaurant
ExerciseType.FASTING -> MaterialSymbols.Timer
null -> MaterialSymbols.DirectionsRun
}
fun ExerciseType.labelRes(): Int =
when (this) {
ExerciseType.RUNNING -> R.string.exercise_running
ExerciseType.WALKING -> R.string.exercise_walking
ExerciseType.CYCLING -> R.string.exercise_cycling
ExerciseType.HIKING -> R.string.exercise_hiking
ExerciseType.SWIMMING -> R.string.exercise_swimming
ExerciseType.ROWING -> R.string.exercise_rowing
ExerciseType.STRENGTH -> R.string.exercise_strength
ExerciseType.YOGA -> R.string.exercise_yoga
ExerciseType.MEDITATION -> R.string.exercise_meditation
ExerciseType.DIET -> R.string.exercise_diet
ExerciseType.FASTING -> R.string.exercise_fasting
}
private fun Double.trimmed(): String = if (this % 1.0 == 0.0 && abs(this) < 1e15) toLong().toString() else toString()
private fun paceMinPerUnit(
durationSeconds: Long,
distanceValue: Double,
): String {
val secondsPerUnit = (durationSeconds / distanceValue).toLong()
return "${secondsPerUnit / 60}:${(secondsPerUnit % 60).toString().padStart(2, '0')}"
}
/** One-shot snapshot of the parsed workout tags, so the feed doesn't re-scan the tag array on every recomposition. */
@Immutable
class WorkoutInfo(
val title: String?,
val type: ExerciseType?,
val exerciseRaw: String?,
val durationSeconds: Long?,
val distance: DistanceTag?,
val elevationGain: Elevation?,
val calories: Int?,
val steps: Int?,
val avgHeartRate: Int?,
val sets: Int?,
val reps: Int?,
val weight: WeightTag?,
) {
companion object {
fun from(event: WorkoutRecordEvent) =
WorkoutInfo(
title = event.title(),
type = event.exerciseType(),
exerciseRaw = event.exercise(),
durationSeconds = event.durationSeconds(),
distance = event.distance(),
elevationGain = event.elevationGain(),
calories = event.calories(),
steps = event.steps(),
avgHeartRate = event.avgHeartRate(),
sets = event.sets(),
reps = event.reps(),
weight = event.weight(),
)
}
}
@Composable
fun WorkoutDisplay(baseNote: Note) {
val event = (baseNote.event as? WorkoutRecordEvent) ?: return
val info = remember(baseNote) { WorkoutInfo.from(event) }
val typeLabel = info.type?.let { stringRes(it.labelRes()) } ?: info.exerciseRaw ?: stringRes(R.string.workout)
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = info.type.symbol(),
contentDescription = typeLabel,
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = info.title ?: typeLabel,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
)
if (info.title != null) {
Text(
text = typeLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
}
}
}
WorkoutStatsRow(info)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun WorkoutStatsRow(info: WorkoutInfo) {
val duration = info.durationSeconds
val distance = info.distance
FlowRow(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
duration?.let {
WorkoutStat(DurationTag.formatTime(it), stringRes(R.string.workout_duration))
}
distance?.let {
WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_distance))
}
if (duration != null && distance != null && distance.value > 0.0) {
WorkoutStat(
"${paceMinPerUnit(duration, distance.value)} /${distance.unit}",
stringRes(R.string.workout_pace),
)
}
info.elevationGain?.let {
WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_elevation))
}
info.calories?.let {
WorkoutStat("$it kcal", stringRes(R.string.workout_calories))
}
info.steps?.let {
WorkoutStat("$it", stringRes(R.string.workout_steps))
}
info.avgHeartRate?.let {
WorkoutStat("$it bpm", stringRes(R.string.workout_heart_rate))
}
info.sets?.let {
WorkoutStat("$it", stringRes(R.string.workout_sets))
}
info.reps?.let {
WorkoutStat("$it", stringRes(R.string.workout_reps))
}
info.weight?.let {
WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_weight))
}
}
}
@Composable
private fun WorkoutStat(
value: String,
label: String,
) {
Column {
Text(
text = value,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
}
}
@@ -0,0 +1,75 @@
/*
* 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
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
@Composable
fun WorkoutFeedLoaded(
loaded: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
if (item.event is WorkoutRecordEvent) {
WorkoutCardCompose(
baseNote = item,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider(
thickness = DividerThickness,
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@@ -0,0 +1,119 @@
/*
* 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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
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.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
@Composable
fun WorkoutsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
WorkoutsScreen(
workoutsFeedContentState = accountViewModel.feedStates.workoutsFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun WorkoutsScreen(
workoutsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(workoutsFeedContentState)
WatchAccountForWorkoutsScreen(workoutsFeedContentState = workoutsFeedContentState, accountViewModel = accountViewModel)
WorkoutsFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
WorkoutsTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Workouts, nav, accountViewModel) { route ->
if (route == Route.Workouts) {
workoutsFeedContentState.sendToTop()
} else {
nav.navBottomBar(route)
}
}
},
floatingButton = {
FabBottomBarPadded(nav) {
NewWorkoutButton(nav)
}
},
accountViewModel = accountViewModel,
) {
RefresheableBox(workoutsFeedContentState, true) {
SaveableFeedContentState(workoutsFeedContentState, scrollStateKey = ScrollStateKeys.WORKOUTS_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = workoutsFeedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "WorkoutsFeed",
onLoaded = { loaded ->
WorkoutFeedLoaded(
loaded = loaded,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
)
},
)
}
}
}
}
@Composable
fun WatchAccountForWorkoutsScreen(
workoutsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.liveWorkoutsFollowLists.collectAsStateWithLifecycle()
val hiddenUsers =
accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
workoutsFeedContentState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -0,0 +1,70 @@
/*
* 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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun WorkoutsTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultWorkoutsFollowList
.collectAsStateWithLifecycle()
WorkoutsTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultWorkoutsFollowList,
)
}
}
@Composable
private fun WorkoutsTopNavFilterBar(
followListsModel: TopNavFilterState,
listName: TopFilter,
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = onChange,
accountViewModel = accountViewModel,
)
}
@@ -0,0 +1,77 @@
/*
* 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.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
class WorkoutFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code
override fun limit() = 200
fun followList(): TopFilter = account.settings.defaultWorkoutsFollowList.value
fun TopFilter.isMuteList() = this is TopFilter.MuteList
fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, it ->
val noteEvent = it.event
noteEvent is WorkoutRecordEvent && params.match(noteEvent, it.relays)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.liveWorkoutsFollowLists.value,
account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
return collection.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is WorkoutRecordEvent && params.match(noteEvent, it.relays)
}
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,58 @@
/*
* 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.datasource
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies.filterWorkoutsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies.filterWorkoutsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies.filterWorkoutsByCommunity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies.filterWorkoutsByFollows
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies.filterWorkoutsByGeohashes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies.filterWorkoutsByHashtag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies.filterWorkoutsByMutedAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies.filterWorkoutsGlobal
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun makeWorkoutsFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> =
when (feedSettings) {
is AllCommunitiesTopNavPerRelayFilterSet -> filterWorkoutsByAllCommunities(feedSettings, since, defaultSince)
is AllFollowsTopNavPerRelayFilterSet -> filterWorkoutsByFollows(feedSettings, since, defaultSince)
is AuthorsTopNavPerRelayFilterSet -> filterWorkoutsByAuthors(feedSettings, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterWorkoutsGlobal(feedSettings, since, defaultSince)
is HashtagTopNavPerRelayFilterSet -> filterWorkoutsByHashtag(feedSettings, since, defaultSince)
is LocationTopNavPerRelayFilterSet -> filterWorkoutsByGeohashes(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterWorkoutsByMutedAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterWorkoutsByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
@@ -0,0 +1,50 @@
/*
* 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.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class WorkoutsQueryState(
val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
@Stable
class WorkoutsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<WorkoutsQueryState>() {
val group =
listOf(
WorkoutsSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -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.ui.screen.loggedIn.workouts.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun WorkoutsFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
WorkoutsFilterAssemblerSubscription(
accountViewModel.dataSources().workouts,
accountViewModel,
)
}
@Composable
fun WorkoutsFilterAssemblerSubscription(
dataSource: WorkoutsFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
WorkoutsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,97 @@
/*
* 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.datasource
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class WorkoutsSubAssembler(
client: INostrClient,
allKeys: () -> Set<WorkoutsQueryState>,
) : PerUserAndFollowListEoseManager<WorkoutsQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: WorkoutsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val feedSettings = key.followsPerRelay()
return makeWorkoutsFilter(feedSettings, since, key.feedStates.workoutsFeed.lastNoteCreatedAtIfFilled())
}
override fun user(key: WorkoutsQueryState) = key.account.userProfile()
override fun list(key: WorkoutsQueryState) = key.listName()
fun WorkoutsQueryState.listNameFlow() = account.settings.defaultWorkoutsFollowList
fun WorkoutsQueryState.listName() = listNameFlow().value
fun WorkoutsQueryState.followsPerRelayFlow() = account.liveWorkoutsFollowListsPerRelay
fun WorkoutsQueryState.followsPerRelay() = followsPerRelayFlow().value
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: WorkoutsQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.scope.launch(Dispatchers.IO) {
key.listNameFlow().collectLatest {
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.IO) {
key.feedStates.workoutsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -0,0 +1,151 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
val WorkoutsFromCommunityKinds =
listOf(
WorkoutRecordEvent.KIND,
)
val WorkoutsFromCommunityKindsStr =
listOf(
WorkoutRecordEvent.KIND.toString(),
)
fun filterWorkoutsFromAllCommunities(
relay: NormalizedRelayUrl,
communities: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> {
val communityList = communities.sorted()
return listOf(
// approved
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = CommunityPostApprovalEvent.KIND_LIST,
tags =
mapOf(
"a" to communityList,
"k" to WorkoutsFromCommunityKindsStr,
),
limit = communityList.size * 20,
since = since,
),
),
// not approved
RelayBasedFilter(
relay = relay,
filter =
Filter(
tags = mapOf("a" to communityList),
kinds = WorkoutsFromCommunityKinds,
limit = communityList.size * 20,
since = since,
),
),
)
}
fun filterWorkoutsByAllCommunities(
communitySet: AllCommunitiesTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (communitySet.set.isEmpty()) return emptyList()
return communitySet.set
.mapNotNull {
filterWorkoutsFromAllCommunities(
relay = it.key,
communities = it.value.communities,
since = since?.get(it.key)?.time ?: defaultSince,
)
}.flatten()
}
fun filterWorkoutsFromCommunity(
relay: NormalizedRelayUrl,
community: String,
authors: Set<String>?,
since: Long? = null,
): List<RelayBasedFilter> {
val authors = authors?.sorted()
return listOf(
// approved
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authors,
kinds = CommunityPostApprovalEvent.KIND_LIST,
tags =
mapOf(
"a" to listOf(community),
"k" to WorkoutsFromCommunityKindsStr,
),
limit = 100,
since = since,
),
),
// not approved
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authors,
tags = mapOf("a" to listOf(community)),
kinds = WorkoutsFromCommunityKinds,
limit = 100,
since = since,
),
),
)
}
fun filterWorkoutsByCommunity(
communitySet: SingleCommunityTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (communitySet.set.isEmpty()) return emptyList()
return communitySet.set
.mapNotNull {
filterWorkoutsFromCommunity(
relay = it.key,
community = it.value.community,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}.flatten()
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
fun filterWorkoutsByAuthors(
relay: NormalizedRelayUrl,
authors: Set<HexKey>,
since: Long? = null,
): List<RelayBasedFilter> {
val authorList = authors.sorted()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authorList,
kinds = listOf(WorkoutRecordEvent.KIND),
limit = 200,
since = since,
),
),
)
}
fun filterWorkoutsByAuthors(
authorSet: AuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set
.mapNotNull {
if (it.value.authors.isEmpty()) {
null
} else {
filterWorkoutsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
fun filterWorkoutsByMutedAuthors(
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set
.mapNotNull {
if (it.value.authors.isEmpty()) {
null
} else {
filterWorkoutsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,44 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun filterWorkoutsByFollows(
followsSet: AllFollowsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (followsSet.set.isEmpty()) return emptyList()
return followsSet.set.flatMap {
val since = since?.get(it.key)?.time ?: defaultSince
val relay = it.key
listOfNotNull(
it.value.authors?.let {
filterWorkoutsByAuthors(relay, it, since)
},
).flatten()
}
}
@@ -0,0 +1,70 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
fun filterWorkoutsByGeohashes(
relay: NormalizedRelayUrl,
geotags: Set<String>,
since: Long?,
): List<RelayBasedFilter> {
if (geotags.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(WorkoutRecordEvent.KIND),
tags = mapOf("g" to geotags.sorted()),
limit = 100,
since = since,
),
),
)
}
fun filterWorkoutsByGeohashes(
geoSet: LocationTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long?,
): List<RelayBasedFilter> {
if (geoSet.set.isEmpty()) return emptyList()
return geoSet.set
.mapNotNull {
if (it.value.geotags.isEmpty()) {
null
} else {
filterWorkoutsByGeohashes(
relay = it.key,
geotags = it.value.geotags,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -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.ui.screen.loggedIn.workouts.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
fun filterWorkoutsByHashtag(
relay: NormalizedRelayUrl,
hashtags: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(WorkoutRecordEvent.KIND),
tags = mapOf("t" to hashtags.toList()),
limit = 200,
since = since,
),
),
)
fun filterWorkoutsByHashtag(
hashtagSet: HashtagTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (hashtagSet.set.isEmpty()) return emptyList()
return hashtagSet.set
.mapNotNull { relayHashSet ->
if (relayHashSet.value.hashtags.isEmpty()) {
null
} else {
filterWorkoutsByHashtag(
relay = relayHashSet.key,
hashtags = relayHashSet.value.hashtags,
since = since?.get(relayHashSet.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,49 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.utils.TimeUtils
fun filterWorkoutsGlobal(
relays: GlobalTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (relays.set.isEmpty()) return emptyList()
return relays.set.map {
val since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo()
RelayBasedFilter(
relay = it.key,
filter =
Filter(
kinds = listOf(WorkoutRecordEvent.KIND),
limit = 200,
since = since,
),
)
}
}
+31
View File
@@ -623,6 +623,35 @@
<string name="profile_badges_description">Choose which of the badges you\'ve received appear on your profile.</string>
<string name="profile_badges_empty">You haven\'t received any badges yet.</string>
<string name="pictures">Pictures</string>
<string name="workouts">Workouts</string>
<string name="workout">Workout</string>
<string name="new_workout">New Workout</string>
<string name="workout_title">Title</string>
<string name="workout_duration">Duration</string>
<string name="workout_distance">Distance</string>
<string name="workout_pace">Pace</string>
<string name="workout_elevation">Elevation</string>
<string name="workout_calories">Calories</string>
<string name="workout_steps">Steps</string>
<string name="workout_heart_rate">Heart rate</string>
<string name="workout_sets">Sets</string>
<string name="workout_reps">Reps</string>
<string name="workout_weight">Weight</string>
<string name="workout_notes">Notes</string>
<string name="workout_hours">Hours</string>
<string name="workout_minutes">Minutes</string>
<string name="workout_seconds">Seconds</string>
<string name="exercise_running">Running</string>
<string name="exercise_walking">Walking</string>
<string name="exercise_cycling">Cycling</string>
<string name="exercise_hiking">Hiking</string>
<string name="exercise_swimming">Swimming</string>
<string name="exercise_rowing">Rowing</string>
<string name="exercise_strength">Strength</string>
<string name="exercise_yoga">Yoga</string>
<string name="exercise_meditation">Meditation</string>
<string name="exercise_diet">Diet</string>
<string name="exercise_fasting">Fasting</string>
<string name="software_apps">Apps</string>
<string name="route_software_apps">Apps</string>
<string name="nip82_repository_label">Source: %1$s</string>
@@ -1999,6 +2028,7 @@
<string name="route_global">Global</string>
<string name="route_video">Shorts</string>
<string name="route_pictures">Pictures</string>
<string name="route_workouts">Workouts</string>
<string name="route_calendars">Calendars</string>
<string name="route_calendar_collections">Calendar lists</string>
<string name="route_chess">Chess</string>
@@ -2907,6 +2937,7 @@
<string name="kind_pay_to">PayTo</string>
<string name="kind_people_lists">People Lists</string>
<string name="kind_pictures">Pictures</string>
<string name="kind_workouts">Workouts</string>
<string name="kind_pins">Pins</string>
<string name="kind_zap_poll">Zap Poll</string>
<string name="kind_poll">Poll</string>
@@ -83,6 +83,9 @@ object MaterialSymbols {
val DeleteForever = MaterialSymbol("\uE92B")
val DeleteSweep = MaterialSymbol("\uE16C")
val Description = MaterialSymbol("\uE873")
val DirectionsBike = MaterialSymbol("\uE52F")
val DirectionsRun = MaterialSymbol("\uE566")
val DirectionsWalk = MaterialSymbol("\uE536")
val Dns = MaterialSymbol("\uE875")
val Done = MaterialSymbol("\uE876")
val DoneAll = MaterialSymbol("\uE877")
@@ -108,6 +111,7 @@ object MaterialSymbols {
val FavoriteBorder = MaterialSymbol("\uE87E")
val FileOpen = MaterialSymbol("\uEAF3")
val FilterAlt = MaterialSymbol("\uEF4F")
val FitnessCenter = MaterialSymbol("\uEB43")
val FolderZip = MaterialSymbol("\uEB2C")
val FormatBold = MaterialSymbol("\uE238")
val FormatItalic = MaterialSymbol("\uE23F")
@@ -124,6 +128,7 @@ object MaterialSymbols {
val Groups = MaterialSymbol("\uF233")
val Headphones = MaterialSymbol("\uF01F")
val Hearing = MaterialSymbol("\uE023")
val Hiking = MaterialSymbol("\uE50A")
val History = MaterialSymbol("\uE8B3")
val Home = MaterialSymbol("\uE9B2")
val HorizontalRule = MaterialSymbol("\uF108")
@@ -137,6 +142,7 @@ object MaterialSymbols {
val KeyboardArrowUp = MaterialSymbol("\uE316")
val Language = MaterialSymbol("\uE894")
val Link = MaterialSymbol("\uE250")
val LocalFireDepartment = MaterialSymbol("\uEF55")
val LocationOff = MaterialSymbol("\uE0C7")
val LocationOn = MaterialSymbol("\uF1DB")
val Lock = MaterialSymbol("\uE899")
@@ -175,6 +181,7 @@ object MaterialSymbols {
val PlayCircleOutline = MaterialSymbol("\uE1C4")
val Podcasts = MaterialSymbol("\uF048")
val Poll = MaterialSymbol("\uF0CC")
val Pool = MaterialSymbol("\uEB48")
val PrivacyTip = MaterialSymbol("\uF0DC")
val Public = MaterialSymbol("\uE80B")
val PublicOff = MaterialSymbol("\uF1CA")
@@ -189,12 +196,15 @@ object MaterialSymbols {
val RemoveDone = MaterialSymbol("\uE9D3")
val Replay = MaterialSymbol("\uE042")
val Report = MaterialSymbol("\uF052")
val Restaurant = MaterialSymbol("\uE56C")
val Save = MaterialSymbol("\uE161")
val SaveAlt = MaterialSymbol("\uF090")
val Schedule = MaterialSymbol("\uEFD6")
val Science = MaterialSymbol("\uEA4B")
val Rowing = MaterialSymbol("\uE921")
val Search = MaterialSymbol("\uE8B6")
val Security = MaterialSymbol("\uE32A")
val SelfImprovement = MaterialSymbol("\uEA78")
val Sensors = MaterialSymbol("\uE51E")
val Settings = MaterialSymbol("\uE8B8")
val SettingsInputAntenna = MaterialSymbol("\uE8BF")
+207
View File
@@ -0,0 +1,207 @@
# RUNSTR interop: Quartz events + Amethyst fitness screens
Research date: 2026-06-11. Source: `RUNSTR-LLC/RUNSTR` @ `main`
(commit `398cdffab452c9e482b3ecf3e5b956b3c0fe1b7b`), its `docs/KIND_1301_SPEC.md`,
`docs/ARCHITECTURE.md`, and the companion `RUNSTR-LLC/runstr-fitness-skill`
repo. Upstream spec context: NIP-101h PR
[nostr-protocol/nips#1937](https://github.com/nostr-protocol/nips/pull/1937)
(health metric kinds 13511399; *not* implemented by RUNSTR today).
## 1. The critical architecture finding
RUNSTR (React Native + NDK) is **no longer a pure-Nostr app**. Teams, clubs,
chat, competitions, and global leaderboards migrated to **Supabase**. The
current app:
- **builds and signs kind 1301 workout events in the NIP-101e dialect, but
submits them to Supabase instead of relays**
(`src/services/nostr/workoutPublishingService.ts:1-13,130-140,373-376`);
- still **consumes kind 1301 from relays** with intentionally lax parsing
("nuclear pattern": `src/services/fitness/Nuclear1301Service.ts`,
`src/services/competition/Competition1301QueryService.ts`) for workout
history import and club/event leaderboards;
- keeps Nostr for identity (kind 0), social posts (kind 1 + reactions/reposts),
encrypted workout backups (kind 30078), event discovery (kind 31923), WoT
(kind 30382), and published leaderboards (kind 30150, external aggregator).
Consequences for us:
1. Amethyst-published 1301s **will be seen** by RUNSTR (history import, club
and event leaderboards aggregate members' relay 1301s).
2. We should **not expect new RUNSTR workouts to appear on relays** from the
current app — but historical RUNSTR 1301s, RUNSTR-iOS, and other NIP-101e
clients do publish them.
3. RUNSTR's *global daily* leaderboards/rewards require their Supabase
submission + anti-cheat (`v` rolling code, `wot_score`) — not reachable via
relays. Out of scope.
## 2. Event catalog — what to implement in Quartz
Quartz has **no fitness kinds today** (verified: no 1301/1351-1357/workout
code anywhere). All of this is net-new. Suggested package:
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip101eFitness/`
(name mirrors how RUNSTR refers to the draft; can be renamed if upstream
numbering lands differently).
### 2.1 Kind 1301 — `WorkoutRecordEvent` (the core interop surface)
Regular (non-addressable) event, but RUNSTR always emits a `d` tag (UUID) and
dedupes on it — parse it, don't rely on it. Authoritative dialect:
`docs/KIND_1301_SPEC.md` in the RUNSTR repo.
Tags RUNSTR always emits (treat all as optional when parsing — their own
parser validates nothing):
| Tag | Format | Notes |
|---|---|---|
| `d` | UUID string | client-side dedupe id |
| `title` | free text | e.g. "Morning Run" |
| `exercise` | lowercase verb | `running\|walking\|cycling\|hiking\|swimming\|rowing\|strength\|yoga\|meditation\|diet\|fasting` |
| `duration` | `HH:MM:SS` | parser must also accept raw seconds |
| `source` | `gps\|manual` | also seen: `healthkit`, `runstr` |
| `client` | `["client","RUNSTR",version]` | 3-element form, not NIP-89 |
| `t` | Capitalized hashtag | `Running`, `Strength`, … fallback `Fitness` |
Conditional metric tags: `["distance", "5.20", "km"|"mi"]` (2-decimal string),
`["elevation_gain"/"elevation_loss", n, "m"|"ft"]`, `["calories", n]`,
`["steps", n]`, `["avg_pace","MM:SS","min/km"|"min/mi"]`,
`["split", kmNumber, "HH:MM:SS"]` (cumulative), `["split_pace", n, seconds]`,
`["avg_heart_rate"/"max_heart_rate", bpm]` (spec-defined),
strength: `["sets",n]`, `["reps",n]`, `["weight",n,"lbs"]`,
`["weight_set", setNum, weight, "lbs"]`; plus `meditation_type`, `meal_type`,
`meal_size`, `exercise_type`, `data_points`, `recording_pauses`,
`workout_start_time` (unix seconds string).
Competition/reward tags (parse, surface, never required): `["team", id|"self"]`,
`["club", id]`, `["charity", id, name, lud16?]`, `["lightning", lud16]`,
`["reward_destination", "user"|"charity"|"ppq"]`, `["challenge", slug]`,
`["wot_score","0".."100"]`, `["v", rollingCode]` (anti-bot, from kind-30150
note), `verified`/`verification_*` tags.
**Content is plain text, never JSON** (user notes / human-readable summary).
Unit handling for parsing (match RUNSTR's lax rules): default `km`/`m`/`lbs`
when unit missing; `mi`×1609.344 m; `ft`×0.3048 m.
When *Amethyst publishes*, emit the strict canonical form above so RUNSTR's
leaderboard aggregation (`fastest_time` needs `distance` + `duration`;
`most_distance` sums `distance`; `participation` counts events) scores us
correctly.
### 2.2 Kind 30078 — RUNSTR encrypted workout backup (clean interop win)
NIP-78 app-data event, `d = "runstr-workout-backup"`. Plaintext metadata tags:
`["client","RUNSTR",v]`, `["encrypted","nip44"]`, `["compression","gzip"]`,
`["backup_version","1"]`, `["workout_count",n]`, optional `habit_count`,
`journal_count`, date ranges. Content = JSON → gzip → base64 → **NIP-44
self-encrypt** (to own pubkey). Decode: nip44-decrypt → base64 → gunzip.
Backup relays: damus + nos.lol. Quartz already has NIP-44 and NIP-78
machinery; we need the gzip step (JVM/Android trivial; check iOS source set)
and a `RunstrWorkoutBackupEvent` wrapper. This lets Amethyst **import a user's
entire RUNSTR history with just their key** — the highest-leverage interop
feature, immune to the Supabase migration.
### 2.3 Kind 31923 — RUNSTR fitness events (already-implemented base)
Quartz has NIP-52 (`nip52Calendar`, kind 31923 time-based calendar event).
RUNSTR layers extra tags on it (`src/services/events/RunstrEventPublishService.ts`):
discovery marker **`["t","runstr"]`** (their filter key) + activity-type and
distance hashtags, and RUNSTR-specific tags: `scoring`
(`fastest_time|most_distance|participation`), `payout`, `join_method`,
`duration_type`, `distance` (value+unit), `pledge_cost`, `pledge_destination`,
`captain_lightning_address`, `entry_fee`, `prize_pool`, `suggested_donation`,
`activity_type`, `image`, `team_competition`. Plan: extension accessors on the
existing calendar event (or a thin `RunstrEventTags` helper) rather than a new
kind. Note their kind-31925 RSVP is typed but dead code — joining is
local/Supabase; don't build RSVP interop expecting RUNSTR to read it.
### 2.4 Kind 30150 — published leaderboard note (read-only)
External aggregator (pubkey
`611021eaaa2692741b1236bbcea54c6aa9f20ba30cace316c3a93d45089a7d0f`,
`d = "runstr-leaderboards"`, on damus + nos.lol, refreshed ~5 min). Content is
JSON: `{v:1, updatedAt, competitions:[{id,name,activityType,scoringMethod,
status,entries:[{r,p,n,s,w}]}]}` (rank/npub/name/score/workout-count). A small
`RunstrLeaderboardEvent` (addressable) + Jackson DTO gives Amethyst live
RUNSTR leaderboards for free. This is also where the rolling `v` anti-bot
code is published.
### 2.5 Kind 30000 participant lists (already-implemented base)
Season participant lists are plain NIP-51 follow sets authored by the admin
pubkey (e.g. `d = "runstr-season-2-participants"`). Quartz `nip51Lists`
already parses these; only the well-known author/d-tag constants are needed.
### 2.6 Explicitly skip (dormant post-migration)
Kinds **33404** (team), **30100/30101** (league/event), **31013**
(competition), **30002** (participant list), **1104/1105** (join requests),
**9321/37375** (nutzap/wallet — they reverted to LNURL/NWC). Code exists in
their repo but nothing publishes or reads them anymore. Their custom
notification kinds **11011103** are backend-published and Supabase-coupled;
revisit only if their backend keeps emitting them. **21301** (paid anti-cheat
request) is RUNSTR-business-specific — skip.
NIP-101h (kinds 13511399, NIP-44-encrypted health metrics,
[nips#1937](https://github.com/nostr-protocol/nips/pull/1937)) has **no code
in RUNSTR today**; implement later as its own `nip101hHealth` package if/when
we want health-profile interop.
### 2.7 Quartz mechanics (per codebase survey)
- Event class per kind following e.g.
`nip25Reactions/ReactionEvent.kt` (regular) /
`nip53LiveActivities/streaming/LiveActivitiesEvent.kt` (addressable):
`KIND` constant, `build()` via `eventTemplate` + `TagArrayBuilder` DSL,
tag-accessor functions, `@Immutable`.
- Register each kind in
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt`.
- Unit tests with real RUNSTR event JSON captured from relays (damus/primal/
nos.lol) as fixtures; `amy` can fetch live samples for interop checks.
## 3. Amethyst screens
Survey of existing UI says: no fitness UI exists; Vico charts (v3.1.0) is
already a dependency (used in the notifications summary chart at
`amethyst/.../notifications/chart/ShowChart.kt`); new kinds render via the
`when(event)` dispatch in `amethyst/.../ui/note/NoteCompose.kt`
(`RenderNoteRow`) with per-type composables under `ui/note/types/`.
Phased UI plan (ViewModels in `commons/.../viewmodels/`, shared cards in
`commons` per `commons/ARCHITECTURE.md`; screen scaffolding/nav per platform):
1. **Workout card in feeds**`RenderWorkoutRecordEvent` showing
activity icon, title, distance/duration/pace/elevation/calories chips, and
splits. This alone makes Amethyst display every 1301 on the network.
2. **Profile "Fitness" tab / workout history feed**`AdditiveFeedFilter`
over kind 1301 by author; stats header (weekly distance, streak) with Vico.
3. **Workout composer** — manual entry first (type, duration, distance,
notes), publishing canonical 1301s. GPS tracking is a much bigger,
Android-only follow-up (foreground service, location permissions).
4. **RUNSTR events discovery** — feed of kind 31923 with `t=runstr`, detail
screen showing scoring/entry-fee/prize tags, and a client-side leaderboard
computed from participants' 1301s using RUNSTR's scoring rules (and/or the
pre-computed 30150 note).
5. **Backup import** — settings action: fetch `30078:user:runstr-workout-backup`,
decrypt, and ingest workouts into LocalCache (optionally re-publish as
1301s with user consent).
## 4. Relays & constants
- RUNSTR defaults: `wss://relay.damus.io`, `wss://relay.primal.net`,
`wss://nos.lol`; backups on damus + nos.lol.
- Admin/aggregator pubkey: `611021eaaa2692741b1236bbcea54c6aa9f20ba30cace316c3a93d45089a7d0f`.
- WoT assertions: kind 30382 from Brainstorm
(`3eaeb02c4f94a0aabf016527c35222a2ede49b3981df32aa9096f5db2dad58e2`) on
`wss://nip85.brainstorm.world` — only needed if we ever want RUNSTR reward
parity; skip initially.
## 5. Suggested implementation order
1. `nip101eFitness` package: `WorkoutRecordEvent` (kind 1301) + tag classes +
EventFactory registration + fixture tests.
2. Feed card (phase-1 UI) — immediate visible interop.
3. `RunstrWorkoutBackupEvent` (30078 dialect) + import flow.
4. RUNSTR tag accessors on NIP-52 31923 + events discovery screen.
5. `RunstrLeaderboardEvent` (30150) + leaderboard rendering.
6. Workout composer (manual), then evaluate GPS tracking as its own plan.
@@ -0,0 +1,93 @@
/*
* 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.quartz.experimental.fitness.workout
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.AvgHeartRateTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.CaloriesTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DistanceTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DurationTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.Elevation
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ElevationGainTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ElevationLossTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.MaxHeartRateTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.RepsTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SetsTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SourceTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SplitTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.StepsTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.TitleTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WeightTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutStartTimeTag
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
fun TagArrayBuilder<WorkoutRecordEvent>.dTag(workoutId: String) = addUnique(DTag.assemble(workoutId))
fun TagArrayBuilder<WorkoutRecordEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
fun TagArrayBuilder<WorkoutRecordEvent>.exercise(type: ExerciseType) = addUnique(ExerciseTag.assemble(type))
fun TagArrayBuilder<WorkoutRecordEvent>.exercise(code: String) = addUnique(ExerciseTag.assemble(code))
fun TagArrayBuilder<WorkoutRecordEvent>.duration(seconds: Long) = addUnique(DurationTag.assemble(seconds))
fun TagArrayBuilder<WorkoutRecordEvent>.source(source: String) = addUnique(SourceTag.assemble(source))
fun TagArrayBuilder<WorkoutRecordEvent>.distance(
value: Double,
unit: String = DistanceTag.KILOMETERS,
) = addUnique(DistanceTag.assemble(value, unit))
fun TagArrayBuilder<WorkoutRecordEvent>.elevationGain(
value: Double,
unit: String = Elevation.METERS,
) = addUnique(ElevationGainTag.assemble(value, unit))
fun TagArrayBuilder<WorkoutRecordEvent>.elevationLoss(
value: Double,
unit: String = Elevation.METERS,
) = addUnique(ElevationLossTag.assemble(value, unit))
fun TagArrayBuilder<WorkoutRecordEvent>.calories(kcal: Int) = addUnique(CaloriesTag.assemble(kcal))
fun TagArrayBuilder<WorkoutRecordEvent>.steps(steps: Int) = addUnique(StepsTag.assemble(steps))
fun TagArrayBuilder<WorkoutRecordEvent>.avgHeartRate(bpm: Int) = addUnique(AvgHeartRateTag.assemble(bpm))
fun TagArrayBuilder<WorkoutRecordEvent>.maxHeartRate(bpm: Int) = addUnique(MaxHeartRateTag.assemble(bpm))
fun TagArrayBuilder<WorkoutRecordEvent>.splits(splits: List<SplitTag>) = addAll(SplitTag.assemble(splits))
fun TagArrayBuilder<WorkoutRecordEvent>.sets(sets: Int) = addUnique(SetsTag.assemble(sets))
fun TagArrayBuilder<WorkoutRecordEvent>.reps(reps: Int) = addUnique(RepsTag.assemble(reps))
fun TagArrayBuilder<WorkoutRecordEvent>.weight(
value: Double,
unit: String = WeightTag.POUNDS,
) = addUnique(WeightTag.assemble(value, unit))
fun TagArrayBuilder<WorkoutRecordEvent>.workoutStartTime(timestamp: Long) = addUnique(WorkoutStartTimeTag.assemble(timestamp))
fun TagArrayBuilder<WorkoutRecordEvent>.hashtag(hashtag: String) = add(HashtagTag.assemble(hashtag))
@@ -0,0 +1,73 @@
/*
* 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.quartz.experimental.fitness.workout
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.AvgHeartRateTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.CaloriesTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DistanceTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DurationTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ElevationGainTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ElevationLossTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.MaxHeartRateTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.RepsTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SetsTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SourceTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SplitTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.StepsTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.TitleTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WeightTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutStartTimeTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
fun TagArray.title() = firstNotNullOfOrNull(TitleTag::parse)
fun TagArray.exercise() = firstNotNullOfOrNull(ExerciseTag::parse)
fun TagArray.exerciseType() = firstNotNullOfOrNull(ExerciseTag::parseType)
fun TagArray.durationSeconds() = firstNotNullOfOrNull(DurationTag::parse)
fun TagArray.distance() = firstNotNullOfOrNull(DistanceTag::parse)
fun TagArray.elevationGain() = firstNotNullOfOrNull(ElevationGainTag::parse)
fun TagArray.elevationLoss() = firstNotNullOfOrNull(ElevationLossTag::parse)
fun TagArray.calories() = firstNotNullOfOrNull(CaloriesTag::parse)
fun TagArray.steps() = firstNotNullOfOrNull(StepsTag::parse)
fun TagArray.avgHeartRate() = firstNotNullOfOrNull(AvgHeartRateTag::parse)
fun TagArray.maxHeartRate() = firstNotNullOfOrNull(MaxHeartRateTag::parse)
fun TagArray.splits() = mapNotNull(SplitTag::parse)
fun TagArray.sets() = firstNotNullOfOrNull(SetsTag::parse)
fun TagArray.reps() = firstNotNullOfOrNull(RepsTag::parse)
fun TagArray.weight() = firstNotNullOfOrNull(WeightTag::parse)
fun TagArray.workoutSource() = firstNotNullOfOrNull(SourceTag::parse)
fun TagArray.workoutStartTime() = firstNotNullOfOrNull(WorkoutStartTimeTag::parse)
@@ -0,0 +1,109 @@
/*
* 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.quartz.experimental.fitness.workout
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* NIP-101e (draft) workout record, interoperable with the RUNSTR dialect.
*
* Parsing is intentionally lax: every tag is optional, units default to
* metric (km/m) and pounds, and durations accept `HH:MM:SS` or raw seconds.
* The content is plain text (user notes), never JSON.
*/
@Immutable
class WorkoutRecordEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
RootScope {
fun title() = tags.title()
fun exercise() = tags.exercise()
fun exerciseType() = tags.exerciseType()
fun durationSeconds() = tags.durationSeconds()
fun distance() = tags.distance()
fun elevationGain() = tags.elevationGain()
fun elevationLoss() = tags.elevationLoss()
fun calories() = tags.calories()
fun steps() = tags.steps()
fun avgHeartRate() = tags.avgHeartRate()
fun maxHeartRate() = tags.maxHeartRate()
fun splits() = tags.splits()
fun sets() = tags.sets()
fun reps() = tags.reps()
fun weight() = tags.weight()
fun workoutSource() = tags.workoutSource()
fun workoutStartTime() = tags.workoutStartTime()
companion object {
const val KIND = 1301
const val ALT_DESCRIPTION = "Workout record"
@OptIn(ExperimentalUuidApi::class)
fun build(
exercise: ExerciseType,
durationSeconds: Long,
notes: String = "",
title: String? = null,
workoutId: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<WorkoutRecordEvent>.() -> Unit = {},
) = eventTemplate(KIND, notes, createdAt) {
alt(ALT_DESCRIPTION)
dTag(workoutId)
exercise(exercise)
hashtag(exercise.hashtag)
duration(durationSeconds)
title?.let { title(it) }
initializer()
}
}
}
@@ -0,0 +1,41 @@
/*
* 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.quartz.experimental.fitness.workout.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class CaloriesTag {
companion object {
const val TAG_NAME = "calories"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
/** Returns the energy burned in kcal. */
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return tag[1].toIntOrNull()
}
fun assemble(kcal: Int) = arrayOf(TAG_NAME, kcal.toString())
}
}
@@ -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.quartz.experimental.fitness.workout.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
@Stable
class DistanceTag(
val value: Double,
val unit: String,
) {
fun toMeters() =
when (unit) {
MILES -> value * METERS_PER_MILE
METERS -> value
else -> value * 1000.0
}
fun toKilometers() = toMeters() / 1000.0
companion object {
const val TAG_NAME = "distance"
const val KILOMETERS = "km"
const val MILES = "mi"
const val METERS = "m"
const val METERS_PER_MILE = 1609.344
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
/** Lax by design: a missing or unknown unit defaults to kilometers. */
fun parse(tag: Array<String>): DistanceTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val value = tag[1].toDoubleOrNull() ?: return null
return DistanceTag(value, tag.getOrNull(2) ?: KILOMETERS)
}
fun assemble(
value: Double,
unit: String = KILOMETERS,
) = arrayOf(TAG_NAME, value.toString(), unit)
fun assemble(distance: DistanceTag) = assemble(distance.value, distance.unit)
}
}
@@ -0,0 +1,66 @@
/*
* 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.quartz.experimental.fitness.workout.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class DurationTag {
companion object {
const val TAG_NAME = "duration"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
/**
* Returns the total duration in seconds. The canonical value is `HH:MM:SS`,
* but some clients publish raw seconds, so both are accepted.
*/
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return parseTime(tag[1])
}
fun parseTime(value: String): Long? {
if (':' !in value) return value.toLongOrNull()
val parts = value.split(':')
ensure(parts.size in 2..3) { return null }
var seconds = 0L
parts.forEach { part ->
val number = part.toLongOrNull() ?: return null
seconds = seconds * 60 + number
}
return seconds
}
fun assemble(seconds: Long) = arrayOf(TAG_NAME, formatTime(seconds))
fun formatTime(seconds: Long): String {
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
val secs = seconds % 60
return "${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}"
}
}
}
@@ -0,0 +1,81 @@
/*
* 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.quartz.experimental.fitness.workout.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
@Stable
class Elevation(
val value: Double,
val unit: String,
) {
fun toMeters() = if (unit == FEET) value * METERS_PER_FOOT else value
companion object {
const val METERS = "m"
const val FEET = "ft"
const val METERS_PER_FOOT = 0.3048
/** Lax by design: a missing or unknown unit defaults to meters. */
fun parse(
tag: Array<String>,
tagName: String,
): Elevation? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == tagName) { return null }
val value = tag[1].toDoubleOrNull() ?: return null
return Elevation(value, tag.getOrNull(2) ?: METERS)
}
}
}
class ElevationGainTag {
companion object {
const val TAG_NAME = "elevation_gain"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>) = Elevation.parse(tag, TAG_NAME)
fun assemble(
value: Double,
unit: String = Elevation.METERS,
) = arrayOf(TAG_NAME, value.toString(), unit)
}
}
class ElevationLossTag {
companion object {
const val TAG_NAME = "elevation_loss"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>) = Elevation.parse(tag, TAG_NAME)
fun assemble(
value: Double,
unit: String = Elevation.METERS,
) = arrayOf(TAG_NAME, value.toString(), unit)
}
}
@@ -0,0 +1,73 @@
/*
* 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.quartz.experimental.fitness.workout.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Activity verbs used by NIP-101e clients (RUNSTR dialect). The tag value is the
* lowercase [code]; the matching capitalized [hashtag] is published as a `t` tag
* so the workout is discoverable.
*/
enum class ExerciseType(
val code: String,
val hashtag: String,
) {
RUNNING("running", "Running"),
WALKING("walking", "Walking"),
CYCLING("cycling", "Cycling"),
HIKING("hiking", "Hiking"),
SWIMMING("swimming", "Swimming"),
ROWING("rowing", "Rowing"),
STRENGTH("strength", "Strength"),
YOGA("yoga", "Yoga"),
MEDITATION("meditation", "Meditation"),
DIET("diet", "Diet"),
FASTING("fasting", "Fasting"),
;
companion object {
fun parse(code: String) = entries.firstOrNull { it.code == code.lowercase() }
}
}
class ExerciseTag {
companion object {
const val TAG_NAME = "exercise"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
/** Returns the raw verb. Other clients may publish verbs outside [ExerciseType]. */
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun parseType(tag: Array<String>) = parse(tag)?.let { ExerciseType.parse(it) }
fun assemble(code: String) = arrayOf(TAG_NAME, code)
fun assemble(type: ExerciseType) = assemble(type.code)
}
}
@@ -0,0 +1,58 @@
/*
* 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.quartz.experimental.fitness.workout.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class AvgHeartRateTag {
companion object {
const val TAG_NAME = "avg_heart_rate"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
/** Returns the average heart rate in bpm. */
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return tag[1].toIntOrNull()
}
fun assemble(bpm: Int) = arrayOf(TAG_NAME, bpm.toString())
}
}
class MaxHeartRateTag {
companion object {
const val TAG_NAME = "max_heart_rate"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
/** Returns the maximum heart rate in bpm. */
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return tag[1].toIntOrNull()
}
fun assemble(bpm: Int) = arrayOf(TAG_NAME, bpm.toString())
}
}
@@ -0,0 +1,45 @@
/*
* 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.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`. */
class SourceTag {
companion object {
const val TAG_NAME = "source"
const val GPS = "gps"
const val MANUAL = "manual"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(source: String) = arrayOf(TAG_NAME, source)
}
}
@@ -0,0 +1,55 @@
/*
* 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.quartz.experimental.fitness.workout.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** A per-distance split: [number] is the 1-based split index, [cumulativeSeconds] the elapsed time at its end. */
@Stable
class SplitTag(
val number: Int,
val cumulativeSeconds: Long,
) {
companion object {
const val TAG_NAME = "split"
fun isTag(tag: Array<String>) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty() && tag[2].isNotEmpty()
fun parse(tag: Array<String>): SplitTag? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val number = tag[1].toIntOrNull() ?: return null
val seconds = DurationTag.parseTime(tag[2]) ?: return null
return SplitTag(number, seconds)
}
fun assemble(
number: Int,
cumulativeSeconds: Long,
) = arrayOf(TAG_NAME, number.toString(), DurationTag.formatTime(cumulativeSeconds))
fun assemble(split: SplitTag) = assemble(split.number, split.cumulativeSeconds)
fun assemble(splits: List<SplitTag>) = splits.map { split -> assemble(split) }
}
}
@@ -0,0 +1,40 @@
/*
* 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.quartz.experimental.fitness.workout.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class StepsTag {
companion object {
const val TAG_NAME = "steps"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return tag[1].toIntOrNull()
}
fun assemble(steps: Int) = arrayOf(TAG_NAME, steps.toString())
}
}
@@ -0,0 +1,89 @@
/*
* 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.quartz.experimental.fitness.workout.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class SetsTag {
companion object {
const val TAG_NAME = "sets"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return tag[1].toIntOrNull()
}
fun assemble(sets: Int) = arrayOf(TAG_NAME, sets.toString())
}
}
class RepsTag {
companion object {
const val TAG_NAME = "reps"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return tag[1].toIntOrNull()
}
fun assemble(reps: Int) = arrayOf(TAG_NAME, reps.toString())
}
}
@Stable
class WeightTag(
val value: Double,
val unit: String,
) {
fun toKilograms() = if (unit == POUNDS) value * KILOGRAMS_PER_POUND else value
companion object {
const val TAG_NAME = "weight"
const val POUNDS = "lbs"
const val KILOGRAMS = "kg"
const val KILOGRAMS_PER_POUND = 0.45359237
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
/** Lax by design: a missing or unknown unit defaults to pounds (RUNSTR's default). */
fun parse(tag: Array<String>): WeightTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val value = tag[1].toDoubleOrNull() ?: return null
return WeightTag(value, tag.getOrNull(2) ?: POUNDS)
}
fun assemble(
value: Double,
unit: String = POUNDS,
) = arrayOf(TAG_NAME, value.toString(), unit)
}
}
@@ -0,0 +1,41 @@
/*
* 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.quartz.experimental.fitness.workout.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class TitleTag {
companion object {
const val TAG_NAME = "title"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(title: String) = arrayOf(TAG_NAME, title)
}
}
@@ -0,0 +1,41 @@
/*
* 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.quartz.experimental.fitness.workout.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class WorkoutStartTimeTag {
companion object {
const val TAG_NAME = "workout_start_time"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
/** Returns the workout start as a unix timestamp in seconds. */
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return tag[1].toLongOrNull()
}
fun assemble(timestamp: Long) = arrayOf(TAG_NAME, timestamp.toString())
}
}
@@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
@@ -606,6 +607,7 @@ class EventFactory {
WakeUpEvent.KIND -> WakeUpEvent(id, pubKey, createdAt, tags, content, sig)
WebBookmarkEvent.KIND -> WebBookmarkEvent(id, pubKey, createdAt, tags, content, sig)
WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig)
WorkoutRecordEvent.KIND -> WorkoutRecordEvent(id, pubKey, createdAt, tags, content, sig)
else -> factories[kind]?.build(id, pubKey, createdAt, tags, content, sig) ?: Event(id, pubKey, createdAt, kind, tags, content, sig)
} as T
@@ -0,0 +1,173 @@
/*
* 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.quartz.experimental.fitness
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.calories
import com.vitorpamplona.quartz.experimental.fitness.workout.distance
import com.vitorpamplona.quartz.experimental.fitness.workout.source
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DurationTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class WorkoutRecordEventTest {
private fun parse(
tags: Array<Array<String>>,
content: String = "",
): Event =
EventFactory.create(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1718000000L,
kind = WorkoutRecordEvent.KIND,
tags = tags,
content = content,
sig = "c".repeat(128),
)
/** Tag layout as published by RUNSTR (docs/KIND_1301_SPEC.md in RUNSTR-LLC/RUNSTR). */
@Test
fun parsesRunstrDialect() {
val event =
parse(
arrayOf(
arrayOf("d", "57b08a45-2c2f-4b51-9b6f-21f3936b3ef1"),
arrayOf("title", "Morning Run"),
arrayOf("exercise", "running"),
arrayOf("distance", "5.20", "km"),
arrayOf("duration", "00:31:30"),
arrayOf("elevation_gain", "50", "m"),
arrayOf("elevation_loss", "48", "m"),
arrayOf("calories", "312"),
arrayOf("steps", "8421"),
arrayOf("source", "gps"),
arrayOf("client", "RUNSTR", "1.0.5"),
arrayOf("t", "Running"),
arrayOf("split", "1", "00:06:01"),
arrayOf("split", "2", "00:12:10"),
),
content = "Felt great today!",
)
assertTrue(event is WorkoutRecordEvent)
assertEquals("57b08a45-2c2f-4b51-9b6f-21f3936b3ef1", event.dTag())
assertEquals("Morning Run", event.title())
assertEquals("running", event.exercise())
assertEquals(ExerciseType.RUNNING, event.exerciseType())
assertEquals(31 * 60 + 30L, event.durationSeconds())
assertEquals(5200.0, event.distance()?.toMeters())
assertEquals(50.0, event.elevationGain()?.toMeters())
assertEquals(48.0, event.elevationLoss()?.toMeters())
assertEquals(312, event.calories())
assertEquals(8421, event.steps())
assertEquals("gps", event.workoutSource())
assertEquals("Felt great today!", event.content)
val splits = event.splits()
assertEquals(2, splits.size)
assertEquals(1, splits[0].number)
assertEquals(6 * 60 + 1L, splits[0].cumulativeSeconds)
assertEquals(12 * 60 + 10L, splits[1].cumulativeSeconds)
}
@Test
fun parsesLaxUnitsAndRawSecondsLikeRunstr() {
val event =
parse(
arrayOf(
arrayOf("exercise", "Running"),
arrayOf("distance", "3.1", "mi"),
arrayOf("duration", "1800"),
arrayOf("elevation_gain", "100", "ft"),
),
) as WorkoutRecordEvent
assertEquals(ExerciseType.RUNNING, event.exerciseType())
assertEquals(3.1 * 1609.344, event.distance()!!.toMeters())
assertEquals(1800L, event.durationSeconds())
assertEquals(100 * 0.3048, event.elevationGain()!!.toMeters())
assertNull(event.title())
}
@Test
fun parsesStrengthWorkout() {
val event =
parse(
arrayOf(
arrayOf("exercise", "strength"),
arrayOf("duration", "00:45:00"),
arrayOf("sets", "5"),
arrayOf("reps", "10"),
arrayOf("weight", "165", "lbs"),
),
) as WorkoutRecordEvent
assertEquals(ExerciseType.STRENGTH, event.exerciseType())
assertEquals(5, event.sets())
assertEquals(10, event.reps())
assertEquals(165 * 0.45359237, event.weight()!!.toKilograms())
assertNull(event.distance())
}
@Test
fun durationFormatsAsPaddedTime() {
assertEquals("00:31:30", DurationTag.formatTime(31 * 60 + 30L))
assertEquals("01:00:05", DurationTag.formatTime(3605L))
assertEquals(3605L, DurationTag.parseTime("01:00:05"))
assertEquals(125L, DurationTag.parseTime("02:05"))
assertNull(DurationTag.parseTime("not-a-time"))
}
@Test
fun buildEmitsCanonicalTags() {
val template =
WorkoutRecordEvent.build(
exercise = ExerciseType.RUNNING,
durationSeconds = 31 * 60 + 30L,
notes = "Easy pace",
title = "Morning Run",
workoutId = "fixed-id",
) {
distance(5.2)
calories(312)
source("manual")
}
val tags = template.tags
assertEquals(arrayOf("d", "fixed-id").toList(), tags.first { it[0] == "d" }.toList())
assertEquals(arrayOf("exercise", "running").toList(), tags.first { it[0] == "exercise" }.toList())
assertEquals(arrayOf("t", "Running").toList(), tags.first { it[0] == "t" }.toList())
assertEquals(arrayOf("duration", "00:31:30").toList(), tags.first { it[0] == "duration" }.toList())
assertEquals(arrayOf("title", "Morning Run").toList(), tags.first { it[0] == "title" }.toList())
assertEquals(arrayOf("distance", "5.2", "km").toList(), tags.first { it[0] == "distance" }.toList())
assertEquals(arrayOf("calories", "312").toList(), tags.first { it[0] == "calories" }.toList())
assertEquals(arrayOf("source", "manual").toList(), tags.first { it[0] == "source" }.toList())
assertEquals("Easy pace", template.content)
}
}