From 6775ad8e627256f46713d36a662fd73e435392fb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 20:12:30 +0000 Subject: [PATCH 1/3] docs: add RUNSTR interop research plan (kind 1301 + related events) Catalogs every Nostr kind the RUNSTR app publishes/consumes, the exact 1301 tag dialect, the Supabase-migration caveats, and a phased plan for Quartz event classes and Amethyst fitness screens. https://claude.ai/code/session_01Kpx53UEeJqqR7CASzMu6GB --- quartz/plans/2026-06-11-runstr-interop.md | 207 ++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 quartz/plans/2026-06-11-runstr-interop.md 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. From 4361f95a17d638b311e65413edd913929ab05673 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 21:48:32 +0000 Subject: [PATCH 2/3] feat: NIP-101e workout records (kind 1301) + Workouts feed screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quartz: new experimental/fitness/workout package shaped like nip88Polls — WorkoutRecordEvent with per-tag classes (exercise, duration, distance, elevation, calories, steps, heart rate, splits, strength sets/reps/weight, source, workout_start_time), TagArrayBuilder/TagArray extensions, lax RUNSTR-dialect parsing (unit defaults, HH:MM:SS or raw seconds), and EventFactory + LocalCache registration. Covered by fixture tests. Amethyst: new Workouts feed (drawer entry, route, follow-list top bar, per-relay filter assemblers mirroring the Pictures feed) with a + FAB opening a manual workout composer that publishes canonical kind-1301 events. Workout cards render stats chips and also display inside threads via NoteCompose. Adds fitness Material Symbols glyphs and regenerates the subset font. https://claude.ai/code/session_01Kpx53UEeJqqR7CASzMu6GB --- .../amethyst/LocalPreferences.kt | 5 + .../vitorpamplona/amethyst/model/Account.kt | 29 +++ .../amethyst/model/AccountSettings.kt | 12 + .../amethyst/model/LocalCache.kt | 5 + .../RelaySubscriptionsCoordinator.kt | 3 + .../ui/feeds/RememberForeverStates.kt | 1 + .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/bottombars/NavBarItem.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../amethyst/ui/note/NoteCompose.kt | 6 + .../loggedIn/AccountFeedContentStates.kt | 4 + .../loggedIn/BottomBarFeedPreloaders.kt | 3 + .../loggedIn/relays/RelayInformationScreen.kt | 2 + .../loggedIn/workouts/NewWorkoutButton.kt | 68 ++++++ .../loggedIn/workouts/NewWorkoutDialog.kt | 221 ++++++++++++++++++ .../loggedIn/workouts/WorkoutCardCompose.kt | 73 ++++++ .../loggedIn/workouts/WorkoutDisplay.kt | 204 ++++++++++++++++ .../loggedIn/workouts/WorkoutFeedLoaded.kt | 75 ++++++ .../loggedIn/workouts/WorkoutsScreen.kt | 119 ++++++++++ .../loggedIn/workouts/WorkoutsTopBar.kt | 70 ++++++ .../workouts/dal/WorkoutFeedFilter.kt | 77 ++++++ .../workouts/datasource/SubAssemblyHelper.kt | 58 +++++ .../datasource/WorkoutsFilterAssembler.kt | 50 ++++ .../WorkoutsFilterAssemblerSubscription.kt | 48 ++++ .../datasource/WorkoutsSubAssembler.kt | 97 ++++++++ .../FilterWorkoutsByAllCommunities.kt | 151 ++++++++++++ .../subassemblies/FilterWorkoutsByAuthors.kt | 92 ++++++++ .../subassemblies/FilterWorkoutsByFollows.kt | 44 ++++ .../FilterWorkoutsByGeohashes.kt | 70 ++++++ .../subassemblies/FilterWorkoutsByHashtag.kt | 67 ++++++ .../subassemblies/FilterWorkoutsGlobal.kt | 49 ++++ amethyst/src/main/res/values/strings.xml | 31 +++ .../font/material_symbols_outlined.ttf | Bin 437648 -> 455704 bytes .../commons/icons/symbols/MaterialSymbols.kt | 10 + .../fitness/workout/TagArrayBuilderExt.kt | 93 ++++++++ .../fitness/workout/TagArrayExt.kt | 73 ++++++ .../fitness/workout/WorkoutRecordEvent.kt | 109 +++++++++ .../fitness/workout/tags/CaloriesTag.kt | 41 ++++ .../fitness/workout/tags/DistanceTag.kt | 67 ++++++ .../fitness/workout/tags/DurationTag.kt | 66 ++++++ .../fitness/workout/tags/ElevationTags.kt | 81 +++++++ .../fitness/workout/tags/ExerciseTag.kt | 73 ++++++ .../fitness/workout/tags/HeartRateTags.kt | 58 +++++ .../fitness/workout/tags/SourceTag.kt | 45 ++++ .../fitness/workout/tags/SplitTag.kt | 55 +++++ .../fitness/workout/tags/StepsTag.kt | 40 ++++ .../fitness/workout/tags/StrengthTags.kt | 89 +++++++ .../fitness/workout/tags/TitleTag.kt | 41 ++++ .../workout/tags/WorkoutStartTimeTag.kt | 41 ++++ .../quartz/utils/EventFactory.kt | 2 + .../fitness/WorkoutRecordEventTest.kt | 173 ++++++++++++++ 51 files changed, 2904 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutCardCompose.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutFeedLoaded.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutsTopBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/dal/WorkoutFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/SubAssemblyHelper.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/WorkoutsFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/WorkoutsFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/WorkoutsSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAllCommunities.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByAuthors.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByFollows.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByGeohashes.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsByHashtag.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/datasource/subassemblies/FilterWorkoutsGlobal.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/WorkoutRecordEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/CaloriesTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/DistanceTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/DurationTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ElevationTags.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/HeartRateTags.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/SourceTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/SplitTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/StepsTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/StrengthTags.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/TitleTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/WorkoutStartTimeTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/fitness/WorkoutRecordEventTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 720d8d6375..5b609dc71a 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" @@ -380,6 +381,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)) @@ -695,6 +697,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), @@ -782,6 +785,7 @@ object LocalPreferences { val discovery: TopFilter, val polls: TopFilter, val pictures: TopFilter, + val workouts: TopFilter, val calendars: TopFilter, val products: TopFilter, val shorts: TopFilter, @@ -809,6 +813,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 4d9bd5df60..d9c9c668e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -120,6 +120,13 @@ import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent +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.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent @@ -525,6 +532,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 @@ -1767,6 +1777,25 @@ class Account( signAndComputeBroadcast(template) } + suspend fun sendWorkout( + exercise: ExerciseType, + durationSeconds: Long, + notes: String, + title: String?, + distanceValue: Double? = null, + distanceUnit: String = DistanceTag.KILOMETERS, + kcal: Int? = null, + ): WorkoutRecordEvent { + val template = + WorkoutRecordEvent.build(exercise, durationSeconds, notes, title) { + source(SourceTag.MANUAL) + distanceValue?.let { distance(it, distanceUnit) } + kcal?.let { calories(it) } + } + + return signAndComputeBroadcast(template) + } + suspend fun sendHeader( url: String, magnetUri: String?, 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 89e3159f35..83ee8bc905 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -178,6 +178,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), @@ -619,6 +620,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..909addca9d 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,7 @@ 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.WorkoutsScreen import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.nip01Core.core.Address @@ -277,6 +278,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) } 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..94f20eae86 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( 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 b72c382605..d9f9c2f33f 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.launch @@ -106,6 +107,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) @@ -201,6 +203,7 @@ class AccountFeedContentStates( communitiesList.updateFeedWith(newNotes) picturesFeed.updateFeedWith(newNotes) + workoutsFeed.updateFeedWith(newNotes) productsFeed.updateFeedWith(newNotes) shortsFeed.updateFeedWith(newNotes) publicChatsFeed.updateFeedWith(newNotes) @@ -261,6 +264,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/workouts/NewWorkoutButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutButton.kt new file mode 100644 index 0000000000..0c695e5ad4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutButton.kt @@ -0,0 +1,68 @@ +/* + * 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.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@Composable +fun NewWorkoutButton( + accountViewModel: AccountViewModel, + navScrollToTop: () -> Unit, +) { + var wantsToPost by remember { mutableStateOf(false) } + + if (wantsToPost) { + NewWorkoutDialog( + onClose = { wantsToPost = false }, + onPosted = navScrollToTop, + accountViewModel = accountViewModel, + ) + } + + FloatingActionButton( + onClick = { wantsToPost = true }, + 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/NewWorkoutDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutDialog.kt new file mode 100644 index 0000000000..d85d94247a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutDialog.kt @@ -0,0 +1,221 @@ +/* + * 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.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.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vitorpamplona.amethyst.R +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 NewWorkoutDialog( + onClose: () -> Unit, + onPosted: () -> Unit, + accountViewModel: AccountViewModel, +) { + var exercise by remember { mutableStateOf(ExerciseType.RUNNING) } + var title by remember { mutableStateOf("") } + var hours by remember { mutableStateOf("") } + var minutes by remember { mutableStateOf("") } + var seconds by remember { mutableStateOf("") } + var distance by remember { mutableStateOf("") } + var distanceUnit by remember { mutableStateOf(DistanceTag.KILOMETERS) } + var calories by remember { mutableStateOf("") } + var notes by remember { mutableStateOf("") } + + fun durationSeconds(): Long = + (hours.toLongOrNull() ?: 0L) * 3600 + + (minutes.toLongOrNull() ?: 0L) * 60 + + (seconds.toLongOrNull() ?: 0L) + + Dialog( + onDismissRequest = onClose, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), + ) { + Scaffold( + topBar = { + PostingTopBar( + titleRes = R.string.new_workout, + isActive = { durationSeconds() > 0 }, + onPost = { + accountViewModel.launchSigner { + accountViewModel.account.sendWorkout( + exercise = exercise, + durationSeconds = durationSeconds(), + notes = notes, + title = title.ifBlank { null }, + distanceValue = distance.toDoubleOrNull(), + distanceUnit = distanceUnit, + kcal = calories.toIntOrNull(), + ) + } + onClose() + onPosted() + }, + onCancel = onClose, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .fillMaxSize(), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 10.dp) + .imePadding() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + ExerciseTypeSelector(exercise) { exercise = it } + + OutlinedTextField( + value = title, + onValueChange = { 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(hours, { hours = it }, stringRes(R.string.workout_hours), Modifier.weight(1f)) + NumberField(minutes, { minutes = it }, stringRes(R.string.workout_minutes), Modifier.weight(1f)) + NumberField(seconds, { seconds = it }, stringRes(R.string.workout_seconds), Modifier.weight(1f)) + } + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = distance, + onValueChange = { distance = it }, + label = { Text(stringRes(R.string.workout_distance)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(2f), + singleLine = true, + ) + + Column(modifier = Modifier.weight(1f)) { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + FilterChip( + selected = distanceUnit == DistanceTag.KILOMETERS, + onClick = { distanceUnit = DistanceTag.KILOMETERS }, + label = { Text(DistanceTag.KILOMETERS) }, + ) + FilterChip( + selected = distanceUnit == DistanceTag.MILES, + onClick = { distanceUnit = DistanceTag.MILES }, + label = { Text(DistanceTag.MILES) }, + ) + } + } + } + + NumberField(calories, { calories = it }, stringRes(R.string.workout_calories), Modifier.fillMaxWidth()) + + OutlinedTextField( + value = notes, + onValueChange = { 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/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..332c58f068 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt @@ -0,0 +1,204 @@ +/* + * 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.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.DurationTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType + +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) toInt().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')}" +} + +@Composable +fun WorkoutDisplay(baseNote: Note) { + val event = (baseNote.event as? WorkoutRecordEvent) ?: return + + val type = event.exerciseType() + val typeLabel = type?.let { stringRes(it.labelRes()) } ?: event.exercise() ?: stringRes(R.string.workout) + + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = type.symbol(), + contentDescription = typeLabel, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Column { + Text( + text = event.title() ?: typeLabel, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + ) + if (event.title() != null) { + Text( + text = typeLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + WorkoutStatsRow(event) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun WorkoutStatsRow(event: WorkoutRecordEvent) { + val duration = event.durationSeconds() + val distance = event.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), + ) + } + + event.elevationGain()?.let { + WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_elevation)) + } + + event.calories()?.let { + WorkoutStat("$it kcal", stringRes(R.string.workout_calories)) + } + + event.steps()?.let { + WorkoutStat("$it", stringRes(R.string.workout_steps)) + } + + event.avgHeartRate()?.let { + WorkoutStat("$it bpm", stringRes(R.string.workout_heart_rate)) + } + + event.sets()?.let { + WorkoutStat("$it", stringRes(R.string.workout_sets)) + } + + event.reps()?.let { + WorkoutStat("$it", stringRes(R.string.workout_reps)) + } + + event.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..75b4261899 --- /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(accountViewModel, workoutsFeedContentState::sendToTop) + } + }, + 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 a90bbac779..e638a3340d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -620,6 +620,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 @@ -1996,6 +2025,7 @@ Global Shorts Pictures + Workouts Calendars Calendar lists Chess @@ -2904,6 +2934,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 733b19cde6283483dd86d761b7043208ddf0dd99..89a43d0a2afc3d509e8d6ac78e14ac66fdb31e08 100644 GIT binary patch delta 22297 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 IM*-gd2Dy-69RL6T delta 4124 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 McGk1km9TdI2CcrEs{jB1 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/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) + } +} From 1883f1b032a4be1d0a6f6077f307d0ac13be6b15 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 22:09:12 +0000 Subject: [PATCH 3/3] refactor: full-screen NewWorkout route + audit fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the NewWorkoutDialog with a Route.NewWorkout full screen (NewGoalScreen pattern: NewWorkoutViewModel + PostingTopBar), which also fixes the audit-found race where the dialog closed before the signer finished — the screen now pops back only after a successful sign/broadcast, keeping external-signer (Amber) flows alive. - Move template building from Account.sendWorkout into the ViewModel. - Render workout cards in thread view (ThreadFeedView fall-through gap). - Snapshot parsed workout tags once per note (remember + WorkoutInfo) instead of re-scanning the tag array on every recomposition. - Fix Double.trimmed() Int overflow on absurd distances. - Flatten the distance-unit chip layout. https://claude.ai/code/session_01Kpx53UEeJqqR7CASzMu6GB --- .../vitorpamplona/amethyst/model/Account.kt | 26 --- .../amethyst/ui/navigation/AppNavigation.kt | 8 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/threadview/ThreadFeedView.kt | 4 + .../loggedIn/workouts/NewWorkoutButton.kt | 24 +- .../loggedIn/workouts/NewWorkoutDialog.kt | 221 ------------------ .../loggedIn/workouts/NewWorkoutScreen.kt | 200 ++++++++++++++++ .../loggedIn/workouts/NewWorkoutViewModel.kt | 102 ++++++++ .../loggedIn/workouts/WorkoutDisplay.kt | 77 ++++-- .../loggedIn/workouts/WorkoutsScreen.kt | 2 +- 10 files changed, 379 insertions(+), 287 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutViewModel.kt 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 d9c9c668e6..22c7d84baf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -120,13 +120,6 @@ import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent -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.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent @@ -1777,25 +1770,6 @@ class Account( signAndComputeBroadcast(template) } - suspend fun sendWorkout( - exercise: ExerciseType, - durationSeconds: Long, - notes: String, - title: String?, - distanceValue: Double? = null, - distanceUnit: String = DistanceTag.KILOMETERS, - kcal: Int? = null, - ): WorkoutRecordEvent { - val template = - WorkoutRecordEvent.build(exercise, durationSeconds, notes, title) { - source(SourceTag.MANUAL) - distanceValue?.let { distance(it, distanceUnit) } - kcal?.let { calories(it) } - } - - return signAndComputeBroadcast(template) - } - suspend fun sendHeader( url: String, magnetUri: String?, 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 909addca9d..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,7 @@ 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 @@ -502,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/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 94f20eae86..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 @@ -593,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/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 index 0c695e5ad4..9e7dacd27e 100644 --- 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 @@ -24,36 +24,20 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue 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.screen.loggedIn.AccountViewModel +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( - accountViewModel: AccountViewModel, - navScrollToTop: () -> Unit, -) { - var wantsToPost by remember { mutableStateOf(false) } - - if (wantsToPost) { - NewWorkoutDialog( - onClose = { wantsToPost = false }, - onPosted = navScrollToTop, - accountViewModel = accountViewModel, - ) - } - +fun NewWorkoutButton(nav: INav) { FloatingActionButton( - onClick = { wantsToPost = true }, + onClick = { nav.nav(Route.NewWorkout) }, modifier = Size55Modifier, shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutDialog.kt deleted file mode 100644 index d85d94247a..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/NewWorkoutDialog.kt +++ /dev/null @@ -1,221 +0,0 @@ -/* - * 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.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.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.vitorpamplona.amethyst.R -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 NewWorkoutDialog( - onClose: () -> Unit, - onPosted: () -> Unit, - accountViewModel: AccountViewModel, -) { - var exercise by remember { mutableStateOf(ExerciseType.RUNNING) } - var title by remember { mutableStateOf("") } - var hours by remember { mutableStateOf("") } - var minutes by remember { mutableStateOf("") } - var seconds by remember { mutableStateOf("") } - var distance by remember { mutableStateOf("") } - var distanceUnit by remember { mutableStateOf(DistanceTag.KILOMETERS) } - var calories by remember { mutableStateOf("") } - var notes by remember { mutableStateOf("") } - - fun durationSeconds(): Long = - (hours.toLongOrNull() ?: 0L) * 3600 + - (minutes.toLongOrNull() ?: 0L) * 60 + - (seconds.toLongOrNull() ?: 0L) - - Dialog( - onDismissRequest = onClose, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false, - ), - ) { - Scaffold( - topBar = { - PostingTopBar( - titleRes = R.string.new_workout, - isActive = { durationSeconds() > 0 }, - onPost = { - accountViewModel.launchSigner { - accountViewModel.account.sendWorkout( - exercise = exercise, - durationSeconds = durationSeconds(), - notes = notes, - title = title.ifBlank { null }, - distanceValue = distance.toDoubleOrNull(), - distanceUnit = distanceUnit, - kcal = calories.toIntOrNull(), - ) - } - onClose() - onPosted() - }, - onCancel = onClose, - ) - }, - ) { pad -> - Surface( - modifier = - Modifier - .padding(pad) - .fillMaxSize(), - ) { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = 10.dp) - .imePadding() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - ExerciseTypeSelector(exercise) { exercise = it } - - OutlinedTextField( - value = title, - onValueChange = { 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(hours, { hours = it }, stringRes(R.string.workout_hours), Modifier.weight(1f)) - NumberField(minutes, { minutes = it }, stringRes(R.string.workout_minutes), Modifier.weight(1f)) - NumberField(seconds, { seconds = it }, stringRes(R.string.workout_seconds), Modifier.weight(1f)) - } - - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - OutlinedTextField( - value = distance, - onValueChange = { distance = it }, - label = { Text(stringRes(R.string.workout_distance)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.weight(2f), - singleLine = true, - ) - - Column(modifier = Modifier.weight(1f)) { - Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { - FilterChip( - selected = distanceUnit == DistanceTag.KILOMETERS, - onClick = { distanceUnit = DistanceTag.KILOMETERS }, - label = { Text(DistanceTag.KILOMETERS) }, - ) - FilterChip( - selected = distanceUnit == DistanceTag.MILES, - onClick = { distanceUnit = DistanceTag.MILES }, - label = { Text(DistanceTag.MILES) }, - ) - } - } - } - - NumberField(calories, { calories = it }, stringRes(R.string.workout_calories), Modifier.fillMaxWidth()) - - OutlinedTextField( - value = notes, - onValueChange = { 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/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/WorkoutDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt index 332c58f068..765fd34d30 100644 --- 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 @@ -33,6 +33,8 @@ 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 @@ -45,8 +47,12 @@ 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) { @@ -79,7 +85,7 @@ fun ExerciseType.labelRes(): Int = ExerciseType.FASTING -> R.string.exercise_fasting } -private fun Double.trimmed(): String = if (this % 1.0 == 0.0) toInt().toString() else toString() +private fun Double.trimmed(): String = if (this % 1.0 == 0.0 && abs(this) < 1e15) toLong().toString() else toString() private fun paceMinPerUnit( durationSeconds: Long, @@ -89,17 +95,52 @@ private fun paceMinPerUnit( 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 type = event.exerciseType() - val typeLabel = type?.let { stringRes(it.labelRes()) } ?: event.exercise() ?: stringRes(R.string.workout) + 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 = type.symbol(), + symbol = info.type.symbol(), contentDescription = typeLabel, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.primary, @@ -109,11 +150,11 @@ fun WorkoutDisplay(baseNote: Note) { Column { Text( - text = event.title() ?: typeLabel, + text = info.title ?: typeLabel, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium, ) - if (event.title() != null) { + if (info.title != null) { Text( text = typeLabel, style = MaterialTheme.typography.bodySmall, @@ -123,17 +164,15 @@ fun WorkoutDisplay(baseNote: Note) { } } - Spacer(modifier = Modifier.width(8.dp)) - - WorkoutStatsRow(event) + WorkoutStatsRow(info) } } @OptIn(ExperimentalLayoutApi::class) @Composable -private fun WorkoutStatsRow(event: WorkoutRecordEvent) { - val duration = event.durationSeconds() - val distance = event.distance() +private fun WorkoutStatsRow(info: WorkoutInfo) { + val duration = info.durationSeconds + val distance = info.distance FlowRow( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), @@ -154,31 +193,31 @@ private fun WorkoutStatsRow(event: WorkoutRecordEvent) { ) } - event.elevationGain()?.let { + info.elevationGain?.let { WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_elevation)) } - event.calories()?.let { + info.calories?.let { WorkoutStat("$it kcal", stringRes(R.string.workout_calories)) } - event.steps()?.let { + info.steps?.let { WorkoutStat("$it", stringRes(R.string.workout_steps)) } - event.avgHeartRate()?.let { + info.avgHeartRate?.let { WorkoutStat("$it bpm", stringRes(R.string.workout_heart_rate)) } - event.sets()?.let { + info.sets?.let { WorkoutStat("$it", stringRes(R.string.workout_sets)) } - event.reps()?.let { + info.reps?.let { WorkoutStat("$it", stringRes(R.string.workout_reps)) } - event.weight()?.let { + info.weight?.let { WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_weight)) } } 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 index 75b4261899..08769e1980 100644 --- 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 @@ -76,7 +76,7 @@ fun WorkoutsScreen( }, floatingButton = { FabBottomBarPadded(nav) { - NewWorkoutButton(accountViewModel, workoutsFeedContentState::sendToTop) + NewWorkoutButton(nav) } }, accountViewModel = accountViewModel,