diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 019ff8fb9c..c1b92834bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b99f434a54..dbba6d1824 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -526,6 +526,9 @@ class Account( val livePicturesFollowLists: StateFlow = topNavFilterFlow(settings.defaultPicturesFollowList) val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow + val liveWorkoutsFollowLists: StateFlow = topNavFilterFlow(settings.defaultWorkoutsFollowList) + val liveWorkoutsFollowListsPerRelay = OutboxLoaderState(liveWorkoutsFollowLists, cache, scope).flow + val liveCalendarsFollowLists: StateFlow = topNavFilterFlow(settings.defaultCalendarsFollowList) val liveCalendarsFollowListsPerRelay = OutboxLoaderState(liveCalendarsFollowLists, cache, scope).flow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 201de4677c..13679ab480 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -179,6 +179,7 @@ class AccountSettings( val defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultPollsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultPicturesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultWorkoutsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultCalendarsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultProductsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AroundMe), val defaultShortsFollowList: MutableStateFlow = 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 6c8b5dc779..b9d7d39e4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 9357f8fee5..50c4684257 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index c6d8f835d4..1a0fdee837 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -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" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b6b99019e1..bd51f7ce33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { ProfileBadgesScreen(accountViewModel, nav) } composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } composableFromEnd { PicturesScreen(accountViewModel, nav) } + composableFromEnd { WorkoutsScreen(accountViewModel, nav) } composableFromEnd { SoftwareAppsScreen(accountViewModel, nav) } composableFromEndArgs { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { CalendarsScreen(accountViewModel, nav) } @@ -500,6 +503,13 @@ fun BuildNavigation( ) } + composableFromBottom { + NewWorkoutScreen( + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromBottomArgs { HashtagPostScreen( hashtag = it.hashtag, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 01dc584878..b9bfc64888 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -50,6 +50,7 @@ enum class NavBarItem { COMMUNITIES, ARTICLES, PICTURES, + WORKOUTS, SOFTWARE_APPS, CALENDARS, CALENDAR_COLLECTIONS, @@ -206,6 +207,13 @@ val NavBarCatalog: Map = 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.COMMUNITIES, NavBarItem.ARTICLES, NavBarItem.PICTURES, + NavBarItem.WORKOUTS, NavBarItem.SOFTWARE_APPS, NavBarItem.CALENDARS, NavBarItem.CALENDAR_COLLECTIONS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 748a84b465..a3225becc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index b33649a6a0..70b421d3b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 84a3a6b6c6..2471d0dbb1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 02063940a6..844deed5ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 108ef90cf5..f521fa90d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 2de99d5f54..0872ca7095 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutButton.kt new file mode 100644 index 0000000000..9e7dacd27e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutButton.kt @@ -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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt new file mode 100644 index 0000000000..f4f9516444 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutViewModel.kt new file mode 100644 index 0000000000..69b461b7c5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutViewModel.kt @@ -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? { + 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) } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutCardCompose.kt new file mode 100644 index 0000000000..8c60b9fc3b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutCardCompose.kt @@ -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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt new file mode 100644 index 0000000000..765fd34d30 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt @@ -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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutFeedLoaded.kt new file mode 100644 index 0000000000..c496e9d54e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutFeedLoaded.kt @@ -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)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt new file mode 100644 index 0000000000..08769e1980 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt @@ -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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsTopBar.kt new file mode 100644 index 0000000000..505babc56b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsTopBar.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/dal/WorkoutFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/dal/WorkoutFeedFilter.kt new file mode 100644 index 0000000000..ab4d8e6af0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/dal/WorkoutFeedFilter.kt @@ -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() { + 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 { + 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): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.liveWorkoutsFollowLists.value, + account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + 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): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/SubAssemblyHelper.kt new file mode 100644 index 0000000000..cb44f698ca --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/SubAssemblyHelper.kt @@ -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 = + 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() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/WorkoutsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/WorkoutsFilterAssembler.kt new file mode 100644 index 0000000000..baab033a30 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/WorkoutsFilterAssembler.kt @@ -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() { + val group = + listOf( + WorkoutsSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/WorkoutsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/WorkoutsFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..69bf6db70b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/WorkoutsFilterAssemblerSubscription.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/WorkoutsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/WorkoutsSubAssembler.kt new file mode 100644 index 0000000000..7d46d1954f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/WorkoutsSubAssembler.kt @@ -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, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: WorkoutsQueryState, + since: SincePerRelayMap?, + ): List { + 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>() + + @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() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAllCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAllCommunities.kt new file mode 100644 index 0000000000..1c6c7c2cf1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAllCommunities.kt @@ -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, + since: Long? = null, +): List { + 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 { + 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?, + since: Long? = null, +): List { + 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 { + 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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAuthors.kt new file mode 100644 index 0000000000..f17dad581a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAuthors.kt @@ -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, + since: Long? = null, +): List { + 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 { + 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 { + 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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByFollows.kt new file mode 100644 index 0000000000..84892a981d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByFollows.kt @@ -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 { + 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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByGeohashes.kt new file mode 100644 index 0000000000..5665bda320 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByGeohashes.kt @@ -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, + since: Long?, +): List { + 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 { + 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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByHashtag.kt new file mode 100644 index 0000000000..53c0de12a2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByHashtag.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.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, + since: Long? = null, +): List = + 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 { + 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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsGlobal.kt new file mode 100644 index 0000000000..bca6d8c207 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsGlobal.kt @@ -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 { + 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, + ), + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a83a791ef2..d8709a7067 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -623,6 +623,35 @@ Choose which of the badges you\'ve received appear on your profile. You haven\'t received any badges yet. Pictures + Workouts + Workout + New Workout + Title + Duration + Distance + Pace + Elevation + Calories + Steps + Heart rate + Sets + Reps + Weight + Notes + Hours + Minutes + Seconds + Running + Walking + Cycling + Hiking + Swimming + Rowing + Strength + Yoga + Meditation + Diet + Fasting Apps Apps Source: %1$s @@ -1999,6 +2028,7 @@ Global Shorts Pictures + Workouts Calendars Calendar lists Chess @@ -2907,6 +2937,7 @@ PayTo People Lists Pictures + Workouts Pins Zap Poll Poll diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index 733b19cde6..89a43d0a2a 100644 Binary files a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index 88c122bfdd..18f9818e3b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -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") diff --git a/quartz/plans/2026-06-11-runstr-interop.md b/quartz/plans/2026-06-11-runstr-interop.md new file mode 100644 index 0000000000..e14d55e631 --- /dev/null +++ b/quartz/plans/2026-06-11-runstr-interop.md @@ -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. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..c4a4e04a13 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/TagArrayBuilderExt.kt @@ -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.dTag(workoutId: String) = addUnique(DTag.assemble(workoutId)) + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.exercise(type: ExerciseType) = addUnique(ExerciseTag.assemble(type)) + +fun TagArrayBuilder.exercise(code: String) = addUnique(ExerciseTag.assemble(code)) + +fun TagArrayBuilder.duration(seconds: Long) = addUnique(DurationTag.assemble(seconds)) + +fun TagArrayBuilder.source(source: String) = addUnique(SourceTag.assemble(source)) + +fun TagArrayBuilder.distance( + value: Double, + unit: String = DistanceTag.KILOMETERS, +) = addUnique(DistanceTag.assemble(value, unit)) + +fun TagArrayBuilder.elevationGain( + value: Double, + unit: String = Elevation.METERS, +) = addUnique(ElevationGainTag.assemble(value, unit)) + +fun TagArrayBuilder.elevationLoss( + value: Double, + unit: String = Elevation.METERS, +) = addUnique(ElevationLossTag.assemble(value, unit)) + +fun TagArrayBuilder.calories(kcal: Int) = addUnique(CaloriesTag.assemble(kcal)) + +fun TagArrayBuilder.steps(steps: Int) = addUnique(StepsTag.assemble(steps)) + +fun TagArrayBuilder.avgHeartRate(bpm: Int) = addUnique(AvgHeartRateTag.assemble(bpm)) + +fun TagArrayBuilder.maxHeartRate(bpm: Int) = addUnique(MaxHeartRateTag.assemble(bpm)) + +fun TagArrayBuilder.splits(splits: List) = addAll(SplitTag.assemble(splits)) + +fun TagArrayBuilder.sets(sets: Int) = addUnique(SetsTag.assemble(sets)) + +fun TagArrayBuilder.reps(reps: Int) = addUnique(RepsTag.assemble(reps)) + +fun TagArrayBuilder.weight( + value: Double, + unit: String = WeightTag.POUNDS, +) = addUnique(WeightTag.assemble(value, unit)) + +fun TagArrayBuilder.workoutStartTime(timestamp: Long) = addUnique(WorkoutStartTimeTag.assemble(timestamp)) + +fun TagArrayBuilder.hashtag(hashtag: String) = add(HashtagTag.assemble(hashtag)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/TagArrayExt.kt new file mode 100644 index 0000000000..2f56b1fa0b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/TagArrayExt.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/WorkoutRecordEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/WorkoutRecordEvent.kt new file mode 100644 index 0000000000..814221acc5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/WorkoutRecordEvent.kt @@ -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>, + 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.() -> Unit = {}, + ) = eventTemplate(KIND, notes, createdAt) { + alt(ALT_DESCRIPTION) + dTag(workoutId) + exercise(exercise) + hashtag(exercise.hashtag) + duration(durationSeconds) + title?.let { title(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/CaloriesTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/CaloriesTag.kt new file mode 100644 index 0000000000..46001e64c9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/CaloriesTag.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + /** Returns the energy burned in kcal. */ + fun parse(tag: Array): 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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/DistanceTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/DistanceTag.kt new file mode 100644 index 0000000000..8f8b29fd80 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/DistanceTag.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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) = 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): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/DurationTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/DurationTag.kt new file mode 100644 index 0000000000..4390c5cea6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/DurationTag.kt @@ -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) = 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): 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')}" + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ElevationTags.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ElevationTags.kt new file mode 100644 index 0000000000..04d45831e9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ElevationTags.kt @@ -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, + 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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array) = 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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array) = Elevation.parse(tag, TAG_NAME) + + fun assemble( + value: Double, + unit: String = Elevation.METERS, + ) = arrayOf(TAG_NAME, value.toString(), unit) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseTag.kt new file mode 100644 index 0000000000..0621bdf17b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseTag.kt @@ -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) = 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? { + 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) = parse(tag)?.let { ExerciseType.parse(it) } + + fun assemble(code: String) = arrayOf(TAG_NAME, code) + + fun assemble(type: ExerciseType) = assemble(type.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/HeartRateTags.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/HeartRateTags.kt new file mode 100644 index 0000000000..c8f3ef96ae --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/HeartRateTags.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + /** Returns the average heart rate in bpm. */ + fun parse(tag: Array): 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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + /** Returns the maximum heart rate in bpm. */ + fun parse(tag: Array): 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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/SourceTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/SourceTag.kt new file mode 100644 index 0000000000..055b77152c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/SourceTag.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/SplitTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/SplitTag.kt new file mode 100644 index 0000000000..8bfa96141b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/SplitTag.kt @@ -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) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty() && tag[2].isNotEmpty() + + fun parse(tag: Array): 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) = splits.map { split -> assemble(split) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/StepsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/StepsTag.kt new file mode 100644 index 0000000000..067e81b9bc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/StepsTag.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/StrengthTags.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/StrengthTags.kt new file mode 100644 index 0000000000..fe845d6e92 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/StrengthTags.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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) = 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): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/TitleTag.kt new file mode 100644 index 0000000000..27d64db048 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/TitleTag.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/WorkoutStartTimeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/WorkoutStartTimeTag.kt new file mode 100644 index 0000000000..689e3bf85b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/WorkoutStartTimeTag.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + /** Returns the workout start as a unix timestamp in seconds. */ + fun parse(tag: Array): 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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 8e4e1747cd..6752dcd986 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -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 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/fitness/WorkoutRecordEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/fitness/WorkoutRecordEventTest.kt new file mode 100644 index 0000000000..581052ce34 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/fitness/WorkoutRecordEventTest.kt @@ -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>, + 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) + } +}