mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
Merge pull request #3184 from vitorpamplona/claude/beautiful-turing-j0czsm
Add NIP-101e fitness workout support (Kind 1301)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+9
@@ -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)
|
||||
}
|
||||
|
||||
+4
@@ -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)
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+4
@@ -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) {
|
||||
|
||||
+52
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+200
@@ -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,
|
||||
)
|
||||
}
|
||||
+102
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+243
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+75
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -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()
|
||||
}
|
||||
}
|
||||
+70
@@ -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,
|
||||
)
|
||||
}
|
||||
+77
@@ -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)
|
||||
}
|
||||
+58
@@ -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()
|
||||
}
|
||||
+50
@@ -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() }
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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)
|
||||
}
|
||||
+97
@@ -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() }
|
||||
}
|
||||
}
|
||||
+151
@@ -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()
|
||||
}
|
||||
+92
@@ -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()
|
||||
}
|
||||
+44
@@ -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()
|
||||
}
|
||||
}
|
||||
+70
@@ -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()
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.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()
|
||||
}
|
||||
+49
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
+10
@@ -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")
|
||||
|
||||
@@ -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 1351–1399; *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 **1101–1103** 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 1351–1399, 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.
|
||||
+93
@@ -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))
|
||||
+73
@@ -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)
|
||||
+109
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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())
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.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)
|
||||
}
|
||||
}
|
||||
+66
@@ -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')}"
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -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)
|
||||
}
|
||||
}
|
||||
+73
@@ -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)
|
||||
}
|
||||
}
|
||||
+58
@@ -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())
|
||||
}
|
||||
}
|
||||
+45
@@ -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)
|
||||
}
|
||||
}
|
||||
+55
@@ -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) }
|
||||
}
|
||||
}
|
||||
+40
@@ -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())
|
||||
}
|
||||
}
|
||||
+89
@@ -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)
|
||||
}
|
||||
}
|
||||
+41
@@ -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)
|
||||
}
|
||||
}
|
||||
+41
@@ -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
|
||||
|
||||
|
||||
+173
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user