refactor: move the composers, calendars, chess, geocache and voice-reply screens

Clear the next ring of platform blockers and move the screens they freed:

- Date pickers (poll, zap-poll, schedule-at) read the local clock through
  LocalClock and rememberIs24HourClock instead of java.time / DateFormat.
- FileSharer.shareTextFile replaces the app's .ics share; PhoneCalendar
  wraps the system event composer; relativeTimeSpan wraps DateUtils (and
  NSRelativeDateTimeFormatter on iOS).
- rememberWindowViewModel + LocalWindowViewModelStoreOwner keep state that
  spans destinations (chess lobby and board, the Cordn group draft) without
  reaching for FragmentActivity.
- isHlsMedia moves to commons.video; ChessEventBroadcaster to commonMain;
  the host gains supportsWritingAssistant.
- geo: links and nostr: shares go through LocalUriHandler / TextSharer.

204 of 252 screens are now shared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-09-30 13:46:21 +00:00
parent 7a5000ce50
commit 532ee55a59
88 changed files with 768 additions and 476 deletions
@@ -46,6 +46,7 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.ContentFrame
import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW
import com.vitorpamplona.amethyst.commons.ui.components.getDialogWindow
import com.vitorpamplona.amethyst.commons.video.isHlsMedia
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.playback.composable.controls.BottomGradientOverlay
import com.vitorpamplona.amethyst.service.playback.composable.controls.FullscreenSwipeControlsState
@@ -61,7 +62,6 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.applyView
import com.vitorpamplona.amethyst.service.playback.composable.controls.clampViewportShortSide
import com.vitorpamplona.amethyst.service.playback.composable.controls.fullscreenSwipeControls
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.AudioPlayingAnimation
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.rememberIsAudioTrack
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.PinBottomIconSize
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.video.isHlsMedia
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import com.vitorpamplona.amethyst.service.cast.CastRequest
@@ -80,7 +81,6 @@ import com.vitorpamplona.amethyst.service.cast.resolveCastLiveness
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.amethyst.service.playback.diskCache.HlsLivenessCache
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
import com.vitorpamplona.amethyst.ui.cast.CastDevicePickerDialog
@@ -1,51 +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.service.playback.composable.mediaitem
import androidx.media3.common.MimeTypes
/**
* Whether a URL plus its imeta mime identifies HLS.
*
* This is the caller-side form for code that holds a URL and a mime but no `MediaItem` yet — UI that
* has a [MediaItemData] or a `MediaUrlContent`. Once a `MediaItem` exists,
* `isHlsMediaItem` is the equivalent, and both must answer the same way: the UI decides what to
* render, the factory decides how to load it, and a disagreement shows up as a player that streams
* something the surrounding chrome says is a still image.
*
* Defined in terms of [MediaItemCache.toExoPlayerMimeType] rather than re-deriving the rules, so it
* cannot drift from the mime that actually reaches ExoPlayer. That normalizer prefers an explicit
* mime (mapping the four HLS aliases onto [MimeTypes.APPLICATION_M3U8]) and otherwise falls back to a
* **path-anchored** `.m3u8` test.
*
* Both halves matter. A BUD-10 blossom playlist is `https://host/<sha256>` with no extension at all,
* so only the mime identifies it; and anchoring to the path stops `video.mp4?ref=a.m3u8` counting as
* HLS on the strength of its query string.
*
* Note this answers *is it HLS*, which callers use as a proxy for *is it live*. The proxy is
* imprecise in the same way for every HLS URL — an on-demand HLS playlist also answers true — and
* that imprecision is older than this function. Liveness is only truly knowable from
* `#EXT-X-ENDLIST` once the playlist is loaded, which is what `HlsLivenessCache` records.
*/
fun isHlsMedia(
url: String,
mimeType: String?,
): Boolean = MediaItemCache.toExoPlayerMimeType(mimeType, url) == MimeTypes.APPLICATION_M3U8
@@ -25,9 +25,9 @@ import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.commons.ui.state.GenericBaseCache
import com.vitorpamplona.amethyst.commons.video.normalizeStreamMimeType
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -45,23 +45,7 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
internal fun toExoPlayerMimeType(
mimeType: String?,
videoUri: String? = null,
): String? {
if (!mimeType.isNullOrBlank()) {
return when (mimeType.lowercase()) {
"application/vnd.apple.mpegurl",
"application/x-mpegurl",
"audio/x-mpegurl",
"audio/mpegurl",
-> MimeTypes.APPLICATION_M3U8
else -> mimeType
}
}
if (videoUri != null && hasM3u8PathExtension(videoUri)) {
return MimeTypes.APPLICATION_M3U8
}
return null
}
): String? = normalizeStreamMimeType(mimeType, videoUri)
// A side-loaded track needs its media type declared: a NIP-71 `text-track` says nothing
// about the format, and a blossom-hosted one is `https://host/<sha256>` with no extension
@@ -81,14 +65,6 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
language != null && type != null -> "$language \u00B7 $type"
else -> language ?: type
}
// Restrict `.m3u8` matching to the path component so a query param or
// fragment that happens to mention .m3u8 (e.g. `?ref=a.m3u8`) on an
// MP4 URI doesn't misroute to HlsMediaSource.
private fun hasM3u8PathExtension(uri: String): Boolean {
val path = uri.substringBefore('?').substringBefore('#')
return path.endsWith(".m3u8", ignoreCase = true)
}
}
override suspend fun compute(key: MediaItemData): LoadedMediaItem =
@@ -26,7 +26,9 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.CompositionLocalProvider
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.ui.components.LocalWindowViewModelStoreOwner
import com.vitorpamplona.amethyst.commons.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
@@ -61,7 +63,11 @@ class MainActivity : AppCompatActivity() {
setContent {
AmethystTheme {
NowProvider {
AccountScreen(Amethyst.instance.sessionManager)
// The Activity outlives every nav destination: screens that share state
// across destinations (chess lobby + board, the Cordn group draft) keep it here.
CompositionLocalProvider(LocalWindowViewModelStoreOwner provides this) {
AccountScreen(Amethyst.instance.sessionManager)
}
}
}
}
@@ -88,9 +88,9 @@ import com.vitorpamplona.amethyst.commons.ui.components.getDialogWindow
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewerControlsVisibility
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier
import com.vitorpamplona.amethyst.commons.video.isHlsMedia
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.LocalTabResel
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.TabReselectCoordinator
import com.vitorpamplona.amethyst.commons.ui.navigation.findQueryParameterValue
import com.vitorpamplona.amethyst.commons.ui.note.nip22Comments.ReplyCommentPostScreen
import com.vitorpamplona.amethyst.commons.ui.note.share.ShareNoteAsQrScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.apps.recommendations.ProfileAppRecommendationsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.articles.ArticlesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.backups.BackupConflictReviewScreen
@@ -95,7 +96,11 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.BuzzInviteScre
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.BuzzNewDmScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.JobBoardScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.WorkflowRunBoardScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.create.NewCalendarCollectionScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.detail.CalendarEventDetailScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup.CordnCreateGroupScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup.CordnCreateMembersScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup.CordnGroupInfoScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup.CordnGroupListScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup.CordnInvitationsScreen
@@ -123,6 +128,7 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannel
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata.NewEphemeralChatScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupBrowseScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChatScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupCreateScreen
@@ -131,6 +137,8 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannel
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupMembersScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupThreadsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.share.ShareToDMScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess.ChessGameScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess.ChessLobbyScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.communities.CommunityScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.communities.list.CommunitiesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.communities.newCommunity.EditCommunityScreen
@@ -150,6 +158,8 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.emojipacks.membersh
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.emojipacks.membershipManagement.MyEmojiListScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.favorites.FavoriteAppsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.followPacks.list.FollowPacksScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.create.NewGeocacheScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.detail.GeocacheDetailScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.hunt.GeocacheHuntScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.hunt.NewGeocacheHuntScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.log.LogGeocacheFindScreen
@@ -161,6 +171,8 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.hashtag.HashtagScre
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.highlights.HighlightsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.highlights.NewHighlightScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.HomeScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.ShortNotePostScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.VoiceReplyScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.nip75Goals.NewGoalScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.interestSets.display.InterestSetScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.interestSets.list.ListOfInterestSetsScreen
@@ -190,6 +202,7 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.PodcastsSc
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring.EditPodcastShowScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring.NewPodcastTrailerScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring.PodcastAuthoringScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.polls.PollPostScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.polls.PollsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.polls.results.PollResultsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
@@ -212,6 +225,7 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.AudioVisua
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.BlockedUsersScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.BottomBarSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.CallSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.ComposeSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.DrawerSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.HiddenWordsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.HomeTabsSettingsScreen
@@ -279,24 +293,16 @@ import com.vitorpamplona.amethyst.ui.note.PayViaIntentScreen
import com.vitorpamplona.amethyst.ui.note.UpdateReactionTypeScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageFileScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsQrScreen
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarEventScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail.CalendarEventDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnCreateGroupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnCreateMembersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnGroupChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChannelListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabAccountWatcher
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabPreloader
@@ -305,15 +311,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FavoriteAppManifestPr
import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.NostrAppScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.GeocachesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.create.NewGeocacheScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.detail.GeocacheDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitNewIssueScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryCodeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryIssuesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryPullsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.AccountBackupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.AddToMusicPlaylistSheet
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicTrackScreen
@@ -322,13 +324,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.NappletsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.NestsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.NewPodcastEpisodeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.SendPaymentScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ScanQrImageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86.RelayManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.ScheduledPostsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ComposeSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NotificationSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.commons.resources.share_as_image_watermark
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.UploadProgressIndicator
import com.vitorpamplona.amethyst.commons.ui.components.LoadNote
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
@@ -103,7 +104,6 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -48,13 +48,13 @@ import com.vitorpamplona.amethyst.commons.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.commons.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeOfDayFormatter
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatCalendarRange
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarAppointmentLines
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarDateBadge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detailRouteFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
@@ -60,9 +60,9 @@ import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess.ChessViewModelFactory
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
@@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size75Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.VoiceHeightModifier
import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier
import com.vitorpamplona.amethyst.commons.video.isHlsMedia
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
@@ -74,7 +75,6 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.PictureIn
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
@@ -99,6 +99,8 @@ class AndroidAccountViewModelHost(
): Result<AnonymizedResult> = VoiceAnonymizer().anonymize(input.toFile(), VoicePreset.valueOf(presetName))
// Built on the application context: the assistant outlives any one Activity inside the ViewModel.
override val supportsWritingAssistant: Boolean get() = WritingAssistantFactory.IS_SUPPORTED
override fun createWritingAssistant(): WritingAssistant = WritingAssistantFactory.create(modules.appContext)
override val savedAccounts: Flow<Set<HexKey>> =
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry
import com.vitorpamplona.amethyst.commons.model.navigation.NavBarItem
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.communities.list.datasource.CommunitiesListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription
@@ -51,7 +52,6 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.softwareapps.dataso
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.workouts.datasource.WorkoutsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssemblerSubscription
@@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeOfDayFormatter
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatCalendarRange
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
@@ -39,8 +39,8 @@ import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import kotlinx.coroutines.launch
@Composable
@@ -1,60 +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.calendars
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
/**
* Writes [content] to a `.ics` file in the app's cache dir and opens the system share sheet so
* the user can hand the file to a calendar app, email client, file manager, etc.
*
* The file lives in `cacheDir/calendar/` — covered by the existing `<cache-path>` entry in
* `file_paths.xml`, so [FileProvider] can hand out a `content://` URI without further config.
* The receiver gets a read-permission grant via [Intent.FLAG_GRANT_READ_URI_PERMISSION] that
* lasts only for the duration of the share.
*/
fun shareIcs(
context: Context,
filename: String,
content: String,
chooserTitle: String,
) {
val dir = File(context.cacheDir, "calendar").apply { mkdirs() }
val file = File(dir, filename)
file.writeText(content)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
val intent =
Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val chooser =
Intent
.createChooser(intent, chooserTitle)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(chooser)
}
@@ -38,10 +38,10 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.new_chess_game
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess.ChessViewModelFactory
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory
/**
* Floating action button for creating new chess game challenges
@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.commons.ui.note.CheckHiddenFeedWatchBlockAndReport
import com.vitorpamplona.amethyst.commons.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.profile.gallery.GalleryThumbnail
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.note.share
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.note.share.QrPayloadMode
import com.vitorpamplona.amethyst.commons.ui.note.share.qrPayloadFor
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -466,7 +466,18 @@ assemblers, `EventSync`, …). Packages are renamed on the way:
wraps BiometricPrompt with the keyguard fallback. `CalendarTimeFormat` is shared on
`DateSkeletonFormatter`. Moved: search, the long-form composer, the calendar collection
editor, NWC setup and the zap-amount settings. 185 of 252 screens shared.
- **Next:** 67 screens remain, and what blocks them is the platform itself:
- **Wave 10 (2026-09-30):** Home, Discover, the nest lobby and the Marmot group editor
first; then the date pickers on `LocalClock` (poll and zap-poll deadlines, schedule-at
with its presets), `FileSharer.shareTextFile` (the .ics export), `PhoneCalendar` (the
system event composer), `relativeTimeSpan` (`DateUtils` / `NSRelativeDateTimeFormatter`),
`rememberWindowViewModel` + `LocalWindowViewModelStoreOwner` (state shared across
destinations: the chess lobby and board, the Cordn group draft), `isHlsMedia` in
`commons.video`, `ChessEventBroadcaster` in commonMain and `supportsWritingAssistant` on
the host. `geo:` links go through `LocalUriHandler`. Moved: the note and poll composers,
voice reply, the calendar collections and event detail, the geocache detail and editor,
the live-activity channel, chess, Cordn group creation, the QR share and the compose
settings. 204 of 252 screens shared.
- **Next:** 48 screens remain, and what blocks them is the platform itself:
- `ReactionsRow` (2.7k lines: pay-to-app, voice replies, wallet intents).
- The Nests activity and PiP.
- The call screens.
@@ -25,6 +25,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import okio.IOException
@@ -64,3 +65,11 @@ class ChessDismissedGamesStore(
}
}
}
/**
* Where the app keeps the dismissed-chess-games store.
*
* A new file rather than a migration of `chess_dismissed_games`: anyone who had dismissed a game
* sees it once more and dismisses it again.
*/
fun AppPreferenceStores.chessDismissedGamesStore(): ChessDismissedGamesStore = ChessDismissedGamesStore(getDataStore("chess_dismissed_games_v2"))
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.currentTimeMillis
import kotlinx.coroutines.delay
/**
@@ -84,7 +85,7 @@ class ChessEventBroadcaster(
val dummyFilter =
Filter(
kinds = listOf(JesterProtocol.KIND),
since = (System.currentTimeMillis() / 1000) + 3600, // 1 hour in future = no results
since = (currentTimeMillis() / 1000) + 3600, // 1 hour in future = no results
limit = 1,
)
@@ -136,10 +137,10 @@ class ChessEventBroadcaster(
targetRelays: Set<NormalizedRelayUrl>,
timeoutMs: Long,
): Set<NormalizedRelayUrl> {
val startTime = System.currentTimeMillis()
val startTime = currentTimeMillis()
val pollInterval = 200L
while (System.currentTimeMillis() - startTime < timeoutMs) {
while (currentTimeMillis() - startTime < timeoutMs) {
val connected = client.connectedRelaysFlow().value
val targetConnected = targetRelays.intersect(connected)
@@ -148,7 +149,7 @@ class ChessEventBroadcaster(
}
// At least one connected is good enough after half the timeout
if (targetConnected.isNotEmpty() && System.currentTimeMillis() - startTime > timeoutMs / 2) {
if (targetConnected.isNotEmpty() && currentTimeMillis() - startTime > timeoutMs / 2) {
return targetConnected
}
@@ -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.commons.video
/** The mime HLS playlists are normalized to — the value of media3's `MimeTypes.APPLICATION_M3U8`. */
const val HLS_MIME_TYPE = "application/x-mpegURL"
/**
* The mime a player should be handed for [mimeType] on [url]: an explicit mime wins (the four HLS
* aliases map onto [HLS_MIME_TYPE]); without one, a path-anchored `.m3u8` means HLS; otherwise
* null, letting the player infer the format.
*/
fun normalizeStreamMimeType(
mimeType: String?,
url: String? = null,
): String? {
if (!mimeType.isNullOrBlank()) {
return when (mimeType.lowercase()) {
"application/vnd.apple.mpegurl",
"application/x-mpegurl",
"audio/x-mpegurl",
"audio/mpegurl",
-> HLS_MIME_TYPE
else -> mimeType
}
}
if (url != null && hasM3u8PathExtension(url)) {
return HLS_MIME_TYPE
}
return null
}
/**
* Whether a URL plus its imeta mime identifies HLS, for code that holds a URL and a mime but no
* player item yet. Defined through [normalizeStreamMimeType] so it cannot drift from the mime that
* actually reaches the player.
*
* Both halves matter. A BUD-10 blossom playlist is `https://host/<sha256>` with no extension at all,
* so only the mime identifies it; and anchoring to the path stops `video.mp4?ref=a.m3u8` counting as
* HLS on the strength of its query string.
*
* Note this answers *is it HLS*, which callers use as a proxy for *is it live*: an on-demand HLS
* playlist also answers true. Liveness is only truly knowable from `#EXT-X-ENDLIST` once the
* playlist is loaded.
*/
fun isHlsMedia(
url: String,
mimeType: String?,
): Boolean = normalizeStreamMimeType(mimeType, url) == HLS_MIME_TYPE
// Restrict `.m3u8` matching to the path component so a query param or fragment that happens to
// mention .m3u8 (e.g. `?ref=a.m3u8`) on an MP4 URI doesn't misroute to the HLS source.
private fun hasM3u8PathExtension(uri: String): Boolean {
val path = uri.substringBefore('?').substringBefore('#')
return path.endsWith(".m3u8", ignoreCase = true)
}
@@ -91,6 +91,9 @@ interface AccountViewModelHost {
presetName: String,
): Result<AnonymizedResult> = Result.failure(UnsupportedOperationException("Voice anonymization is not available on this platform"))
/** Whether this build can offer an on-device writing assistant at all (it shows the setting). */
val supportsWritingAssistant: Boolean get() = false
/** A new on-device writing assistant, or null where the platform has none. */
fun createWritingAssistant(): WritingAssistant? = null
@@ -18,11 +18,11 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
package com.vitorpamplona.amethyst.commons.video
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The two cases that motivated replacing the old `.m3u8`-substring predicate are
@@ -39,7 +39,7 @@ class IsHlsMediaTest {
assertTrue(isHlsMedia(blossom, "application/x-mpegurl"))
assertTrue(isHlsMedia(blossom, "application/vnd.apple.mpegurl"))
assertFalse("no mime and no extension leaves nothing to go on", isHlsMedia(blossom, null))
assertFalse(isHlsMedia(blossom, null), "no mime and no extension leaves nothing to go on")
}
@Test
@@ -54,7 +54,7 @@ class IsHlsMediaTest {
fun recognisesAPlainM3u8Path() {
assertTrue(isHlsMedia("https://host/live.m3u8", null))
assertTrue(isHlsMedia("https://host/live.M3U8", null))
assertTrue("query strings don't hide the path", isHlsMedia("https://host/live.m3u8?vt=abc", null))
assertTrue(isHlsMedia("https://host/live.m3u8?vt=abc", null), "query strings don't hide the path")
}
@Test
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.ui.components
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -33,29 +34,62 @@ import java.util.zip.ZipOutputStream
@Composable
actual fun rememberFileSharer(): FileSharer {
val context = LocalContext.current
return remember(context) {
FileSharer { zipName, files, title ->
val zipFile = File(context.cacheDir, zipName)
ZipOutputStream(FileOutputStream(zipFile)).use { zip ->
files.forEach { (name, content) ->
zip.putNextEntry(ZipEntry(name))
zip.write(content.toByteArray())
zip.closeEntry()
}
return remember(context) { AndroidFileSharer(context) }
}
private class AndroidFileSharer(
val context: Context,
) : FileSharer {
override fun shareTextFilesAsZip(
zipName: String,
files: List<Pair<String, String>>,
title: String,
) {
val zipFile = File(context.cacheDir, zipName)
ZipOutputStream(FileOutputStream(zipFile)).use { zip ->
files.forEach { (name, content) ->
zip.putNextEntry(ZipEntry(name))
zip.write(content.toByteArray())
zip.closeEntry()
}
}
val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", zipFile)
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "application/zip"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_TITLE, title)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", zipFile)
context.startActivity(Intent.createChooser(sendIntent, title))
}
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "application/zip"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_TITLE, title)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
/**
* The file lives in `cacheDir/shared/`, covered by the `<cache-path>` entry in
* `file_paths.xml`; the receiver's read grant lasts only for the share.
*/
override fun shareTextFile(
fileName: String,
mimeType: String,
content: String,
title: String,
) {
val dir = File(context.cacheDir, "shared").apply { mkdirs() }
val file = File(dir, fileName)
file.writeText(content)
context.startActivity(Intent.createChooser(sendIntent, title))
}
val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
val intent =
Intent(Intent.ACTION_SEND).apply {
type = mimeType
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(Intent.createChooser(intent, title).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
@@ -18,17 +18,26 @@
* 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.calendars
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.provider.CalendarContract
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.parseIsoDateToUnixSeconds
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
@Composable
actual fun rememberPhoneCalendar(): PhoneCalendar {
val context = LocalContext.current
return remember(context) { PhoneCalendar { addToPhoneCalendar(context, it) } }
}
/**
* Opens the system "New Event" composer (Google Calendar / Samsung / iCloud / etc.) pre-populated
* with this appointment's title, time, location, and description. The user sees their normal
@@ -38,7 +47,7 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
* Returns true if a calendar app handled the intent, false if no handler was found — the caller
* can decide whether to fall back to the .ics share path.
*/
fun addToPhoneCalendar(
private fun addToPhoneCalendar(
context: Context,
event: Event,
): Boolean {
@@ -0,0 +1,36 @@
/*
* 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.commons.ui.screen.loggedIn.calendars
import android.text.format.DateUtils
actual suspend fun relativeTimeSpan(
targetSeconds: Long,
nowSeconds: Long,
dayResolution: Boolean,
): String =
DateUtils
.getRelativeTimeSpanString(
targetSeconds * 1000L,
nowSeconds * 1000L,
if (dayResolution) DateUtils.DAY_IN_MILLIS else DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE,
).toString()
@@ -18,7 +18,7 @@
* 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.actions.mediaServers
package com.vitorpamplona.amethyst.commons.ui.actions.mediaServers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -18,7 +18,7 @@
* 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.actions.uploads
package com.vitorpamplona.amethyst.commons.ui.actions.uploads
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
@@ -18,7 +18,7 @@
* 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.actions.uploads
package com.vitorpamplona.amethyst.commons.ui.actions.uploads
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -18,7 +18,7 @@
* 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.actions.uploads
package com.vitorpamplona.amethyst.commons.ui.actions.uploads
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
@@ -32,7 +32,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.audio.VoicePreset
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -44,7 +43,6 @@ fun VoicePresetSelector(
onPresetSelected: (VoicePreset) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val isProcessing = processingPreset != null
Row(
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.runtime.Composable
/** Hands generated files to the platform's share sheet. */
fun interface FileSharer {
interface FileSharer {
/**
* Packs [files] (name to text content) into a zip named [zipName] and shares it, behind a
* chooser titled [title] where the platform has one.
@@ -33,6 +33,17 @@ fun interface FileSharer {
files: List<Pair<String, String>>,
title: String,
)
/**
* Writes [content] to a file named [fileName] and shares it as [mimeType], behind a chooser
* titled [title] where the platform has one.
*/
fun shareTextFile(
fileName: String,
mimeType: String,
content: String,
title: String,
)
}
/**
@@ -67,6 +67,7 @@ inline fun <reified VM : ViewModel> rememberViewModel(
* when the store has no view model yet.
*/
@Composable
@PublishedApi
internal fun <VM : ViewModel> rememberLambdaFactory(factory: () -> VM): ViewModelProvider.Factory {
val currentFactory by rememberUpdatedState(factory)
return remember {
@@ -0,0 +1,64 @@
/*
* 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.commons.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStoreOwner
import kotlin.reflect.KClass
/**
* The store that outlives every navigation destination in this window: Android's Activity. The
* platform entry point provides it above the nav host, whose destinations each get a store of
* their own.
*/
val LocalWindowViewModelStoreOwner: ProvidableCompositionLocal<ViewModelStoreOwner?> = staticCompositionLocalOf { null }
/**
* A view model shared by every destination of this window, for state that has to survive
* navigating between screens (a draft filled in over two screens, a game the lobby and the board
* both watch). Without a window store it falls back to [rememberViewModel], scoped to the caller.
*/
@Composable
fun <VM : ViewModel> rememberWindowViewModel(
modelClass: KClass<VM>,
key: String,
factory: ViewModelProvider.Factory,
): VM {
val owner = LocalWindowViewModelStoreOwner.current ?: return rememberViewModel(modelClass, key, factory)
return remember(owner, key) { ViewModelProvider.create(owner, factory).get(key, modelClass) }
}
@Composable
inline fun <reified VM : ViewModel> rememberWindowViewModel(
key: String,
factory: ViewModelProvider.Factory,
): VM = rememberWindowViewModel(VM::class, key, factory)
@Composable
inline fun <reified VM : ViewModel> rememberWindowViewModel(
key: String,
noinline factory: () -> VM,
): VM = rememberWindowViewModel(VM::class, key, rememberLambdaFactory(factory))
@@ -18,7 +18,7 @@
* 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.note.creators.polls
package com.vitorpamplona.amethyst.commons.ui.note.creators.polls
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -18,7 +18,7 @@
* 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.note.creators.polls
package com.vitorpamplona.amethyst.commons.ui.note.creators.polls
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
@@ -18,9 +18,8 @@
* 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.note.creators.scheduling
package com.vitorpamplona.amethyst.commons.ui.note.creators.scheduling
import android.text.format.DateFormat
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -54,7 +53,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -76,18 +74,15 @@ import com.vitorpamplona.amethyst.commons.resources.schedule_post_time_label
import com.vitorpamplona.amethyst.commons.resources.schedule_post_warning_multi
import com.vitorpamplona.amethyst.commons.resources.schedule_post_warning_single
import com.vitorpamplona.amethyst.commons.resources.schedule_post_warning_title
import com.vitorpamplona.amethyst.commons.search.calendar.LocalClock
import com.vitorpamplona.amethyst.commons.search.calendar.SearchDate
import com.vitorpamplona.amethyst.commons.ui.note.rememberIs24HourClock
import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.quartz.utils.TimeUtils
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.temporal.TemporalAdjusters
import com.vitorpamplona.quartz.utils.currentTimeMillis
/**
* Two-stage date + time picker for scheduling a post for future publication.
@@ -107,29 +102,26 @@ fun ScheduleAtPicker(
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
val currentTime =
Instant
.ofEpochMilli(scheduledForSec * 1000)
.atZone(ZoneId.systemDefault())
.toLocalDateTime()
// The scheduled time as a local wall-clock time: seconds since the epoch shifted by this zone's offset.
val localSeconds = scheduledForSec + LocalClock.utcOffsetSeconds(scheduledForSec)
val localSecondOfDay = localSeconds.mod(TimeUtils.ONE_DAY)
val currentYear = SearchDate.civilFromDays(localSeconds.floorDiv(TimeUtils.ONE_DAY)).year
val datePickerState =
rememberDatePickerState(
initialSelectedDateMillis = scheduledForSec * 1000,
yearRange = currentTime.year..2050,
yearRange = currentYear..2050,
selectableDates =
object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= System.currentTimeMillis() - 86_400_000
override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= currentTimeMillis() - 86_400_000
},
)
val context = LocalContext.current
val timePickerState =
rememberTimePickerState(
initialHour = currentTime.hour,
initialMinute = currentTime.minute,
is24Hour = DateFormat.is24HourFormat(context),
initialHour = localSecondOfDay / TimeUtils.ONE_HOUR,
initialMinute = (localSecondOfDay % TimeUtils.ONE_HOUR) / TimeUtils.ONE_MINUTE,
is24Hour = rememberIs24HourClock(),
)
Column(Modifier.fillMaxWidth()) {
@@ -220,8 +212,7 @@ fun ScheduleAtPicker(
(timePickerState.minute * TimeUtils.ONE_MINUTE)
} ?: TimeUtils.oneDayAhead()
val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now())
val rawSec = datetimeLocalTimeZone - offset.totalSeconds
val rawSec = datetimeLocalTimeZone - LocalClock.utcOffsetSeconds(TimeUtils.now())
onChanged(roundUpToNextQuarterHour(rawSec))
showTimePicker = false
@@ -300,24 +291,22 @@ private fun PresetChips(onPick: (Long) -> Unit) {
}
}
private fun presetInOneHour(): Long = (System.currentTimeMillis() / 1000) + 3600
private fun presetInOneHour(): Long = (currentTimeMillis() / 1000) + 3600
private fun presetTomorrowMorning(): Long {
val zone = ZoneId.systemDefault()
val tomorrow9am = LocalDate.now(zone).plusDays(1).atTime(LocalTime.of(9, 0))
return tomorrow9am.atZone(zone).toEpochSecond()
}
private fun presetTomorrowMorning(): Long = nineAm(LocalClock.today().plusDays(1))
private fun presetNextMondayMorning(): Long {
val zone = ZoneId.systemDefault()
// Always step at least one day forward — if today is Monday, return next Monday.
val target =
LocalDate
.now(zone)
.plusDays(1)
.with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY))
.atTime(LocalTime.of(9, 0))
return target.atZone(zone).toEpochSecond()
val tomorrow = LocalClock.today().plusDays(1)
val monday = 1
return nineAm(tomorrow.plusDays((monday - tomorrow.dayOfWeek()).mod(7)))
}
/** 09:00 local time on [date], read with the offset in force at 09:00, not at midnight. */
private fun nineAm(date: SearchDate): Long {
val midnight = LocalClock.startOfDay(date)
val guess = midnight + 9 * TimeUtils.ONE_HOUR
return guess + LocalClock.utcOffsetSeconds(midnight) - LocalClock.utcOffsetSeconds(guess)
}
/**
@@ -326,9 +315,9 @@ private fun presetNextMondayMorning(): Long {
* (rare — only if the user picks the exact current quarter-hour), bump forward
* one slot.
*/
internal fun roundUpToNextQuarterHour(epochSec: Long): Long {
fun roundUpToNextQuarterHour(epochSec: Long): Long {
val quarter = 15 * 60L
val rounded = ((epochSec + quarter - 1) / quarter) * quarter
val nowSec = System.currentTimeMillis() / 1000
val nowSec = currentTimeMillis() / 1000
return if (rounded <= nowSec) rounded + quarter else rounded
}
@@ -18,9 +18,8 @@
* 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.note.creators.zappolls
package com.vitorpamplona.amethyst.commons.ui.note.creators.zappolls
import android.text.format.DateFormat
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -45,7 +44,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -57,13 +55,14 @@ import com.vitorpamplona.amethyst.commons.resources.confirm
import com.vitorpamplona.amethyst.commons.resources.next
import com.vitorpamplona.amethyst.commons.resources.poll_closing_date_time
import com.vitorpamplona.amethyst.commons.resources.poll_closing_in
import com.vitorpamplona.amethyst.commons.search.calendar.LocalClock
import com.vitorpamplona.amethyst.commons.search.calendar.SearchDate
import com.vitorpamplona.amethyst.commons.ui.note.rememberIs24HourClock
import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.ShortNotePostViewModel
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import com.vitorpamplona.quartz.utils.currentTimeMillis
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -72,26 +71,28 @@ fun ZapPollDeadlinePicker(model: ShortNotePostViewModel) {
var showTimePicker by remember { mutableStateOf(false) }
// Get current time details
val currentTime = Instant.ofEpochMilli(model.zapPollClosedAt * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime()
// The deadline as a local wall-clock time: seconds since the epoch shifted by this zone's offset.
val localSeconds = model.zapPollClosedAt + LocalClock.utcOffsetSeconds(model.zapPollClosedAt)
val localSecondOfDay = localSeconds.mod(TimeUtils.ONE_DAY)
val currentYear = SearchDate.civilFromDays(localSeconds.floorDiv(TimeUtils.ONE_DAY)).year
val datePickerState =
rememberDatePickerState(
initialSelectedDateMillis = model.zapPollClosedAt * 1000,
yearRange = currentTime.year..2050,
yearRange = currentYear..2050,
selectableDates =
object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
// Only allow today and future dates
return utcTimeMillis >= System.currentTimeMillis() - 86400000 // minus 24h buffer
return utcTimeMillis >= currentTimeMillis() - 86400000 // minus 24h buffer
}
},
)
val context = LocalContext.current
val timePickerState =
rememberTimePickerState(
initialHour = currentTime.hour,
initialMinute = currentTime.minute,
is24Hour = DateFormat.is24HourFormat(context),
initialHour = localSecondOfDay / TimeUtils.ONE_HOUR,
initialMinute = (localSecondOfDay % TimeUtils.ONE_HOUR) / TimeUtils.ONE_MINUTE,
is24Hour = rememberIs24HourClock(),
)
OutlinedCard(
@@ -148,10 +149,7 @@ fun ZapPollDeadlinePicker(model: ShortNotePostViewModel) {
(timePickerState.minute * TimeUtils.ONE_MINUTE)
} ?: TimeUtils.oneDayAhead()
// Get the offset from UTC for the current instant in the local time zone
val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now())
model.zapPollClosedAt = datetimeLocalTimeZone - offset.totalSeconds
model.zapPollClosedAt = datetimeLocalTimeZone - LocalClock.utcOffsetSeconds(TimeUtils.now())
showTimePicker = false
},
@@ -18,7 +18,7 @@
* 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.note.creators.zappolls
package com.vitorpamplona.amethyst.commons.ui.note.creators.zappolls
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
@@ -18,7 +18,7 @@
* 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.note.creators.zappolls
package com.vitorpamplona.amethyst.commons.ui.note.creators.zappolls
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.KeyboardOptions
@@ -67,7 +67,7 @@ fun ZapPollOption(
},
label = {
Text(
text = stringRes(Res.string.poll_option_index).format(optionIndex + 1),
text = stringRes(Res.string.poll_option_index, (optionIndex + 1).toString()),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -18,7 +18,7 @@
* 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.note.creators.zappolls
package com.vitorpamplona.amethyst.commons.ui.note.creators.zappolls
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -18,7 +18,7 @@
* 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.note.share
package com.vitorpamplona.amethyst.commons.ui.note.share
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.note.externalLinkForNote
@@ -18,7 +18,7 @@
* 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.note.share
package com.vitorpamplona.amethyst.commons.ui.note.share
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -18,7 +18,7 @@
* 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.note.share
package com.vitorpamplona.amethyst.commons.ui.note.share
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -57,10 +57,10 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.ui.components.collectContentWarningReasons
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.profile.gallery.GalleryThumbnail
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.profile.gallery.UrlImageView
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.GalleryThumbnail
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.UrlImageView
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent
@@ -18,7 +18,7 @@
* 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.calendars
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -32,8 +32,8 @@ import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
/**
* Top-level screen for browsing NIP-52 kind-31924 calendars (collections of appointments). Reuses
@@ -18,7 +18,7 @@
* 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.calendars
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -18,7 +18,7 @@
* 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.calendars
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -42,7 +42,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -62,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_empty_collections_s
import com.vitorpamplona.amethyst.commons.resources.calendar_empty_collections_title
import com.vitorpamplona.amethyst.commons.resources.calendar_export_event
import com.vitorpamplona.amethyst.commons.resources.calendar_export_share_title
import com.vitorpamplona.amethyst.commons.ui.components.rememberFileSharer
import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop
@@ -157,7 +157,7 @@ fun CalendarCollectionCard(
val title = remember(note.idHex) { event.title() }
val description = remember(note.idHex) { event.content.take(180) }
val count = remember(note.idHex) { event.calendarEventAddresses().size }
val context = LocalContext.current
val fileSharer = rememberFileSharer()
val icsChooserTitle = stringRes(Res.string.calendar_export_share_title)
// Calendar collections are addressable (kind 31924); route to the dedicated
@@ -223,7 +223,7 @@ fun CalendarCollectionCard(
val members = collectMembers(event)
val ics = IcsExport.calendarToIcs(event, members, TimeUtils.now())
val filename = IcsExport.calendarFilename(event)
shareIcs(context, filename, ics, icsChooserTitle)
fileSharer.shareTextFile(filename, "text/calendar", ics, icsChooserTitle)
}) {
Icon(
symbol = MaterialSymbols.Share,
@@ -18,9 +18,8 @@
* 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.calendars
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars
import android.text.format.DateUtils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
@@ -29,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.calendar_relative_ongoing
import com.vitorpamplona.amethyst.commons.resources.calendar_relative_ongoing_with_end
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.relativeTimeSpan
import com.vitorpamplona.quartz.utils.TimeUtils
/**
@@ -36,14 +36,14 @@ import com.vitorpamplona.quartz.utils.TimeUtils
* label for an appointment. Returns null when the event has no parseable start (nothing to
* anchor a relative phrase to).
*
* Uses [DateUtils.getRelativeTimeSpanString] for the underlying minute/hour/day phrasing — that
* Uses the platform's relative-time formatter ([relativeTimeSpan]) for the underlying minute/hour/day phrasing — that
* helper is locale-aware and ages from "just now" through "in N days" to absolute date for
* far-out events. For all-day events we extend the resolution to DAY so we get "tomorrow",
* "in 3 days" instead of an hour-precision phrase that would lie about the start moment.
*
* Ongoing events (start ≤ now ≤ end) get a composite "Happening now · ends in X" so a user
* mid-event sees how much time is left rather than the misleading "started X minutes ago" that
* DateUtils would produce on its own.
* the formatter would produce on its own.
*/
suspend fun relativeTimeLabel(
view: CalendarAppointmentView,
@@ -54,31 +54,11 @@ suspend fun relativeTimeLabel(
if (end != null && start <= nowSeconds && nowSeconds <= end) {
val ongoing = loadStringRes(Res.string.calendar_relative_ongoing)
val endsIn =
DateUtils
.getRelativeTimeSpanString(
end * 1000L,
nowSeconds * 1000L,
if (view.isAllDay) DateUtils.DAY_IN_MILLIS else DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE,
).toString()
val endsIn = relativeTimeSpan(end, nowSeconds, view.isAllDay)
return loadStringRes(Res.string.calendar_relative_ongoing_with_end, ongoing, endsIn)
}
val minResolution =
if (view.isAllDay) {
DateUtils.DAY_IN_MILLIS
} else {
DateUtils.MINUTE_IN_MILLIS
}
return DateUtils
.getRelativeTimeSpanString(
start * 1000L,
nowSeconds * 1000L,
minResolution,
DateUtils.FORMAT_ABBREV_RELATIVE,
).toString()
return relativeTimeSpan(start, nowSeconds, view.isAllDay)
}
/**
@@ -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.amethyst.commons.ui.screen.loggedIn.calendars
import androidx.compose.runtime.Composable
import com.vitorpamplona.quartz.nip01Core.core.Event
/** Hands a NIP-52 appointment to the device's own calendar app. */
fun interface PhoneCalendar {
/**
* Opens the calendar's "New Event" composer pre-filled with [event]. Returns false when there
* is no calendar app to take it (or [event] is not an appointment), so the caller can fall
* back to sharing an .ics file.
*/
fun add(event: Event): Boolean
}
/**
* Android opens the system calendar composer. Desktop and iOS have no calendar composer wired
* yet and always report false.
*/
@Composable
expect fun rememberPhoneCalendar(): PhoneCalendar
@@ -18,18 +18,16 @@
* 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.chess
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars
/**
* Where Android keeps the dismissed-chess-games store.
*
* A new file rather than a migration of `chess_dismissed_games`: the dismissed
* list is a convenience, and chess has few enough users that carrying the old
* data over is not worth the code. Anyone who had dismissed a game sees it once
* more and dismisses it again.
* The platform's own phrase for how far [targetSeconds] is from [nowSeconds] ("in 2 hr.",
* "5 min. ago", "tomorrow"), no finer than a day when [dayResolution] is set. Android asks
* `DateUtils`, iOS `NSRelativeDateTimeFormatter`; Desktop has no such formatter and writes a
* compact signed span.
*/
internal fun chessDismissedGamesData(stores: AppPreferenceStores): DataStore<Preferences> = stores.getDataStore("chess_dismissed_games_v2")
expect suspend fun relativeTimeSpan(
targetSeconds: Long,
nowSeconds: Long,
dayResolution: Boolean,
): String
@@ -18,7 +18,7 @@
* 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.calendars.datasource
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -18,7 +18,7 @@
* 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.calendars.detail
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.detail
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -18,10 +18,8 @@
* 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.calendars.detail
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.detail
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -52,11 +50,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -91,6 +88,8 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_untitled
import com.vitorpamplona.amethyst.commons.resources.edit_calendar_event
import com.vitorpamplona.amethyst.commons.resources.route_calendar_event_detail
import com.vitorpamplona.amethyst.commons.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.commons.ui.components.rememberFileSharer
import com.vitorpamplona.amethyst.commons.ui.components.rememberTextSharer
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture
@@ -100,16 +99,15 @@ import com.vitorpamplona.amethyst.commons.ui.note.platform.ReactionsRow
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeOfDayFormatter
import com.vitorpamplona.amethyst.commons.ui.note.types.CalendarRsvpRow
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatCalendarRange
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.formatLongDate
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.rememberPhoneCalendar
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size30dp
import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.addToPhoneCalendar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.shareIcs
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
@@ -177,7 +175,9 @@ fun CalendarEventDetailScreen(
}
},
actions = {
val context = LocalContext.current
val phoneCalendar = rememberPhoneCalendar()
val fileSharer = rememberFileSharer()
val textSharer = rememberTextSharer()
val icsChooserTitle = stringRes(Res.string.calendar_export_share_title)
// Two share modes:
// - .ics for non-nostr calendar apps (Google Calendar / iOS / Outlook)
@@ -190,10 +190,10 @@ fun CalendarEventDetailScreen(
// has no calendar app registered for ACTION_INSERT (rare; Wear OS, some
// GrapheneOS profiles).
IconButton(onClick = {
if (!addToPhoneCalendar(context, event)) {
if (!phoneCalendar.add(event)) {
val ics = IcsExport.appointmentToIcs(event, targetAddress, TimeUtils.now())
val filename = IcsExport.appointmentFilename(event, targetAddress)
shareIcs(context, filename, ics, icsChooserTitle)
fileSharer.shareTextFile(filename, "text/calendar", ics, icsChooserTitle)
}
}) {
Icon(
@@ -206,7 +206,7 @@ fun CalendarEventDetailScreen(
IconButton(onClick = {
val ics = IcsExport.appointmentToIcs(event, targetAddress, TimeUtils.now())
val filename = IcsExport.appointmentFilename(event, targetAddress)
shareIcs(context, filename, ics, icsChooserTitle)
fileSharer.shareTextFile(filename, "text/calendar", ics, icsChooserTitle)
}) {
Icon(
symbol = MaterialSymbols.Share,
@@ -224,15 +224,7 @@ fun CalendarEventDetailScreen(
targetAddress.dTag,
null,
)
val intent =
Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_TEXT, "nostr:$naddr")
context.startActivity(
Intent
.createChooser(intent, shareTitle)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
textSharer.share("nostr:$naddr", null, shareTitle)
}) {
Icon(
symbol = MaterialSymbols.AutoMirrored.Send,
@@ -347,7 +339,6 @@ private fun EventBody(
fontWeight = FontWeight.Bold,
)
}
val context = LocalContext.current
val timeFormatter = rememberTimeOfDayFormatter()
formatCalendarRange(note, timeFormatter)?.let { range ->
Text(
@@ -595,7 +586,7 @@ private fun HeroImage(
@Composable
private fun LocationRow(location: String) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
// NIP-52 `location` is free-text — a place name, an address, OR a meeting URL (Zoom, Jitsi,
// a livestream link). Treat http(s) URLs as web links so they open in the browser; otherwise
// hand to the maps app via the geo: intent.
@@ -607,17 +598,10 @@ private fun LocationRow(location: String) {
.clickable {
runCatching {
val trimmed = location.trim()
val intent =
if (isUrl) {
Intent(Intent.ACTION_VIEW, trimmed.toUri())
} else {
// `geo:0,0?q=<location>` is the Android geo intent; the user's
// installed maps app handles it.
Intent(Intent.ACTION_VIEW, "geo:0,0?q=${Uri.encode(trimmed)}".toUri())
}
// runCatching swallows ActivityNotFoundException when no handler is
// `geo:0,0?q=<location>` is the Android geo URI; the user's installed maps
// app handles it. runCatching swallows the failure when no handler is
// installed — we don't have anywhere useful to fall back to.
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
uriHandler.openUri(if (isUrl) trimmed else "geo:0,0?q=${encodeUriComponent(trimmed)}")
}
}.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -905,3 +889,18 @@ private fun rememberCalendarsContaining(targetAddress: Address): State<List<Cale
return calendars.collectAsStateWithLifecycle(emptyList())
}
/** Percent-encodes everything but the unreserved characters, as Android's `Uri.encode` does. */
private fun encodeUriComponent(text: String): String {
val sb = StringBuilder(text.length)
for (b in text.encodeToByteArray()) {
val c = (b.toInt() and 0xFF).toChar()
if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c in "_-!.~'()*") {
sb.append(c)
} else {
sb.append('%')
sb.append((b.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase())
}
}
return sb.toString()
}
@@ -18,7 +18,7 @@
* 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.chats.cordnGroup
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -18,9 +18,8 @@
* 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.chats.cordnGroup
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -48,9 +47,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.User
@@ -75,6 +72,7 @@ import com.vitorpamplona.amethyst.commons.resources.cordn_info_has_key_package
import com.vitorpamplona.amethyst.commons.resources.cordn_info_remove_member
import com.vitorpamplona.amethyst.commons.resources.cordn_member_count
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.rememberWindowViewModel
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.note.UserPicture
@@ -367,13 +365,10 @@ private fun MemberRow(
* a roster built under another key.
*/
@Composable
fun rememberCordnGroupDraft(accountViewModel: AccountViewModel): CordnGroupDraft {
val activity = LocalActivity.current as FragmentActivity
return viewModel(
viewModelStoreOwner = activity,
fun rememberCordnGroupDraft(accountViewModel: AccountViewModel): CordnGroupDraft =
rememberWindowViewModel(
key = "CordnGroupDraft-${accountViewModel.account.signer.pubKey}",
)
}
) { CordnGroupDraft() }
/**
* What each coordinator this account uses could do with [roster].
@@ -18,7 +18,7 @@
* 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.chats.publicChannels.nip53LiveActivities
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -28,9 +28,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel
import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.LoadLiveActivityChannel
@@ -38,13 +38,13 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.Refreshi
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ShowVideoStreaming
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamGoalHeader
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamTopZappers
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.nests.room.lobby.NestJoinCard
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamGoalHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamTopZappers
import com.vitorpamplona.quartz.nip01Core.core.Address
@Composable
@@ -77,7 +77,7 @@ fun PrepareChannelViewModels(
nav: INav,
) {
val feedViewModel: ChannelFeedViewModel =
viewModel(
rememberViewModel(
key = baseChannel.address.toValue() + "ChannelFeedViewModel",
factory =
ChannelFeedViewModel.Factory(
@@ -86,7 +86,7 @@ fun PrepareChannelViewModels(
),
)
val channelScreenModel: ChannelNewMessageViewModel = viewModel()
val channelScreenModel: ChannelNewMessageViewModel = rememberViewModel { ChannelNewMessageViewModel() }
channelScreenModel.init(accountViewModel)
channelScreenModel.load(baseChannel)
@@ -18,7 +18,7 @@
* 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.chats.publicChannels.nip53LiveActivities
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@@ -27,12 +27,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.service.OnlineChecker
import com.vitorpamplona.amethyst.commons.service.OnlineStatusCache
import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.LoadLiveActivityChannel
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -55,7 +55,7 @@ fun LiveActivityChannelScreen(
LoadLiveActivityChannel(channelId) {
LaunchedEffect(it.info) {
it.info?.streaming()?.let {
OnlineChecker.resetIfOfflineToRetry(it)
OnlineStatusCache.resetIfOfflineToRetry(it)
}
}
LiveActivityTopBar(it, accountViewModel, nav)
@@ -18,7 +18,7 @@
* 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.chats.publicChannels.nip53LiveActivities.header
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
@@ -18,7 +18,7 @@
* 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.chats.publicChannels.nip53LiveActivities.header
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -18,7 +18,7 @@
* 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.chats.publicChannels.nip53LiveActivities.header
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
@@ -47,13 +47,14 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.CreationExtras
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.TopZapperEntry
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.chat_zap_anonymous
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.UserPicture
import com.vitorpamplona.amethyst.commons.ui.note.ZapIcon
@@ -64,7 +65,8 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size24dp
import com.vitorpamplona.amethyst.commons.util.showAmountInteger
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.LiveStreamTopZappersViewModel
import java.math.BigDecimal
import com.vitorpamplona.quartz.utils.BigDecimal
import kotlin.reflect.KClass
/**
* Horizontally-scrollable leaderboard of the top zappers on a live stream. Matches
@@ -80,12 +82,15 @@ fun LiveStreamTopZappers(
nav: INav,
) {
val topZappersVm: LiveStreamTopZappersViewModel =
viewModel(
rememberViewModel(
key = "TopZappers-${channel.address.toValue()}",
factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = LiveStreamTopZappersViewModel(channel) as T
override fun <T : ViewModel> create(
modelClass: KClass<T>,
extras: CreationExtras,
): T = LiveStreamTopZappersViewModel(channel) as T
},
)
@@ -164,7 +169,7 @@ private fun ZapperPill(
}
ZapIcon(Size16Modifier, BitcoinOrange)
Text(
text = showAmountInteger(BigDecimal.valueOf(entry.totalSats)),
text = showAmountInteger(BigDecimal(entry.totalSats)),
style = MaterialTheme.typography.labelMedium,
)
Spacer(Modifier.padding(start = 2.dp))
@@ -18,7 +18,7 @@
* 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.chess
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
@@ -18,9 +18,8 @@
* 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.chess
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -56,8 +55,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
@@ -75,12 +72,13 @@ import com.vitorpamplona.amethyst.commons.resources.chess_game_waiting
import com.vitorpamplona.amethyst.commons.resources.chess_loading_game
import com.vitorpamplona.amethyst.commons.resources.go_back
import com.vitorpamplona.amethyst.commons.resources.relay_settings
import com.vitorpamplona.amethyst.commons.ui.components.rememberWindowViewModel
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess.datasource.ChessSubscription
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessSubscription
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
/**
@@ -100,10 +98,8 @@ fun ChessGameScreen(
nav: INav,
) {
// Scope ViewModel to Activity so it's shared between lobby and game screens
val activity = LocalActivity.current as FragmentActivity
val chessViewModel: ChessViewModel =
viewModel(
viewModelStoreOwner = activity,
rememberWindowViewModel(
key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}",
factory = ChessViewModelFactory(accountViewModel.account, LocalAppServices.current.appStores),
)
@@ -18,9 +18,8 @@
* 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.chess
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -56,8 +55,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
@@ -79,13 +76,14 @@ import com.vitorpamplona.amethyst.commons.resources.back
import com.vitorpamplona.amethyst.commons.resources.dismiss
import com.vitorpamplona.amethyst.commons.resources.relay_settings
import com.vitorpamplona.amethyst.commons.resources.route_chess
import com.vitorpamplona.amethyst.commons.ui.components.rememberWindowViewModel
import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess.datasource.ChessSubscription
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessSubscription
import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
/**
@@ -98,10 +96,8 @@ fun ChessLobbyScreen(
nav: INav,
) {
// Scope ViewModel to Activity so it's shared between lobby and game screens
val activity = LocalActivity.current as FragmentActivity
val chessViewModel: ChessViewModel =
viewModel(
viewModelStoreOwner = activity,
rememberWindowViewModel(
key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}",
factory = ChessViewModelFactory(accountViewModel.account, LocalAppServices.current.appStores),
)
@@ -18,7 +18,7 @@
* 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.chess
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -18,16 +18,16 @@
* 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.chess
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessDismissedGamesStore
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessPollingDefaults
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessViewModel
import com.vitorpamplona.amethyst.commons.nip64Chess.chessDismissedGamesStore
import kotlin.reflect.KClass
/**
@@ -49,7 +49,7 @@ class ChessViewModelFactory(
fetcher = AndroidRelayFetcher(account),
metadataProvider = AndroidMetadataProvider(),
pollingConfig = ChessPollingDefaults.android,
dismissedStorage = ChessDismissedGamesStore(chessDismissedGamesData(appStores)),
dismissedStorage = appStores.chessDismissedGamesStore(),
) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
@@ -18,7 +18,7 @@
* 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.chess.datasource
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chess.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -18,11 +18,8 @@
* 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.geocaches.create
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.create
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -62,6 +59,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -70,7 +68,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
@@ -99,7 +96,9 @@ import com.vitorpamplona.amethyst.commons.resources.geocache_owner_qr_warning
import com.vitorpamplona.amethyst.commons.resources.geocache_photo_remove
import com.vitorpamplona.amethyst.commons.resources.route_edit_geocache
import com.vitorpamplona.amethyst.commons.resources.route_new_geocache
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelectSingle
import com.vitorpamplona.amethyst.commons.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.geocacheEmoji
@@ -139,7 +138,7 @@ fun NewGeocacheScreen(
editPubKeyHex: String? = null,
editDTag: String? = null,
) {
val model: NewGeocacheViewModel = viewModel()
val model: NewGeocacheViewModel = rememberViewModel { NewGeocacheViewModel() }
val scope = rememberCoroutineScope()
remember(editKind, editPubKeyHex, editDTag, prefillGeohash) {
@@ -154,10 +153,13 @@ fun NewGeocacheScreen(
var pickingLocation by remember { mutableStateOf(false) }
val photoPicker =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) scope.launch { model.uploadImage(uri, null, accountViewModel.host.mediaUploader) }
var pickingPhoto by rememberSaveable { mutableStateOf(false) }
if (pickingPhoto) {
GallerySelectSingle(imagesOnly = true) { media ->
pickingPhoto = false
if (media != null) scope.launch { model.uploadImage(media.uri, media.mimeType, accountViewModel.host.mediaUploader) }
}
}
Scaffold(
topBar = {
@@ -285,7 +287,7 @@ fun NewGeocacheScreen(
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedButton(
enabled = !model.isUploading.value,
onClick = { photoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
onClick = { pickingPhoto = true },
) {
if (model.isUploading.value) {
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
@@ -18,9 +18,8 @@
* 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.geocaches.create
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.create
import android.net.Uri
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
@@ -28,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUri
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -175,7 +175,7 @@ class NewGeocacheViewModel : ViewModel() {
fun isValid() = hasName() && hasLocation()
suspend fun uploadImage(
uri: Uri,
uri: MediaUri,
mimeType: String?,
uploader: MediaUploader,
): Boolean {
@@ -18,7 +18,7 @@
* 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.geocaches.detail
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.detail
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -18,9 +18,8 @@
* 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.geocaches.detail
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.detail
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -40,7 +39,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -57,6 +55,8 @@ import com.vitorpamplona.amethyst.commons.resources.geocache_log_type_dnf
import com.vitorpamplona.amethyst.commons.resources.geocache_log_type_maintenance
import com.vitorpamplona.amethyst.commons.resources.geocache_log_type_note
import com.vitorpamplona.amethyst.commons.resources.geocache_verified_find
import com.vitorpamplona.amethyst.commons.resources.quick_action_share
import com.vitorpamplona.amethyst.commons.ui.components.rememberTextSharer
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.commons.ui.note.GeocacheChip
@@ -162,14 +162,11 @@ fun GeocacheWinnerStrip(
/** Shares the cache as `nostr:naddr…`, which is how it opens in another NIP-CC client. */
@Composable
fun GeocacheShareAction(address: Address) {
val context = LocalContext.current
val sharer = rememberTextSharer()
val shareStr = stringRes(Res.string.quick_action_share)
IconButton(onClick = {
val naddr = NAddress.create(address.kind, address.pubKeyHex, address.dTag, null)
val intent =
Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_TEXT, "nostr:$naddr")
context.startActivity(Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
sharer.share("nostr:$naddr", null, shareStr)
}) {
Icon(
symbol = MaterialSymbols.AutoMirrored.Send,
@@ -18,9 +18,8 @@
* 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.geocaches.detail
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.detail
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -64,10 +63,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
@@ -427,12 +425,11 @@ private fun GeocacheNavigateRow(
longitude: Double,
name: String,
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
OutlinedButton(onClick = {
val label = name.ifBlank { "Geocache" }
val uri = "geo:$latitude,$longitude?q=$latitude,$longitude($label)".toUri()
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
uriHandler.openUri("geo:$latitude,$longitude?q=$latitude,$longitude($label)")
}
}) {
Icon(
@@ -18,7 +18,7 @@
* 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.geocaches.detail
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.detail
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -18,7 +18,7 @@
* 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.geocaches.detail
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.detail
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -18,7 +18,7 @@
* 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.home
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
@@ -68,7 +68,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.BooleanType
@@ -102,14 +101,18 @@ import com.vitorpamplona.amethyst.commons.resources.what_s_on_your_mind
import com.vitorpamplona.amethyst.commons.resources.zapraiser
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.OnIncomingShare
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.UploadProgressIndicator
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.VoiceAnonymizationSection
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar
@@ -129,13 +132,17 @@ import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.MessageF
import com.vitorpamplona.amethyst.commons.ui.note.creators.notify.AudienceFlap
import com.vitorpamplona.amethyst.commons.ui.note.creators.notify.AudienceSheet
import com.vitorpamplona.amethyst.commons.ui.note.creators.notify.rememberAudienceLists
import com.vitorpamplona.amethyst.commons.ui.note.creators.polls.PollOptionsField
import com.vitorpamplona.amethyst.commons.ui.note.creators.pow.PowOverrideButton
import com.vitorpamplona.amethyst.commons.ui.note.creators.previews.DisplayPreviews
import com.vitorpamplona.amethyst.commons.ui.note.creators.scheduling.ScheduleAtButton
import com.vitorpamplona.amethyst.commons.ui.note.creators.scheduling.ScheduleAtPicker
import com.vitorpamplona.amethyst.commons.ui.note.creators.scheduling.roundUpToNextQuarterHour
import com.vitorpamplona.amethyst.commons.ui.note.creators.secretEmoji.AddSecretEmojiButton
import com.vitorpamplona.amethyst.commons.ui.note.creators.secretEmoji.SecretEmojiRequest
import com.vitorpamplona.amethyst.commons.ui.note.creators.uploads.ImageVideoDescription
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.commons.ui.note.creators.zappolls.ZapPollField
import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.AddZapraiserButton
import com.vitorpamplona.amethyst.commons.ui.note.creators.zapraiser.ZapRaiserRequest
import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ForwardZapTo
@@ -161,14 +168,8 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection
import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.ScheduleAtPicker
import com.vitorpamplona.amethyst.ui.note.creators.scheduling.roundUpToNextQuarterHour
import com.vitorpamplona.amethyst.ui.note.creators.zappolls.ZapPollField
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
@@ -189,7 +190,7 @@ fun ShortNotePostScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val postViewModel: ShortNotePostViewModel = viewModel()
val postViewModel: ShortNotePostViewModel = rememberViewModel { ShortNotePostViewModel() }
postViewModel.init(accountViewModel)
postViewModel.setGroupThread(groupThreadId, groupThreadRelayUrl)
@@ -739,7 +740,7 @@ private fun NewPostScreenBody(
showAlwaysOnPrompt = true
} else {
postViewModel.scheduledForSec =
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
roundUpToNextQuarterHour(TimeUtils.now() + 60 * 60)
}
},
)
@@ -761,7 +762,7 @@ private fun NewPostScreenBody(
TextButton(onClick = {
showAlwaysOnPrompt = false
postViewModel.scheduledForSec =
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
roundUpToNextQuarterHour(TimeUtils.now() + 60 * 60)
}) {
Text(stringRes(Res.string.schedule_post_always_on_prompt_continue))
}
@@ -779,7 +780,7 @@ private fun BottomRowActions(
if (postViewModel.scheduledForSec != null) {
null
} else {
roundUpToNextQuarterHour((System.currentTimeMillis() / 1000) + 60 * 60)
roundUpToNextQuarterHour(TimeUtils.now() + 60 * 60)
}
},
) {
@@ -18,7 +18,7 @@
* 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.home
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -38,8 +38,11 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.UploadProgressIndicator
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.VoiceAnonymizationSection
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose
@@ -49,9 +52,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -64,7 +64,7 @@ fun VoiceReplyScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: VoiceReplyViewModel = viewModel()
val viewModel: VoiceReplyViewModel = rememberViewModel { VoiceReplyViewModel() }
LaunchedEffect(replyToNoteId, recordingFilePath, accountViewModel) {
viewModel.init(accountViewModel)
@@ -18,7 +18,7 @@
* 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.home
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
@@ -26,7 +26,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.audio.RecordingResult
import com.vitorpamplona.amethyst.commons.audio.VoiceAnonymizationController
import com.vitorpamplona.amethyst.commons.audio.VoicePreset
@@ -58,6 +57,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -215,7 +215,6 @@ class VoiceReplyViewModel : ViewModel() {
} catch (e: CancellationException) {
Log.w("VoiceReplyViewModel", "User canceled, or ViewModel cleared", e)
} catch (e: Exception) {
val appContext = Amethyst.instance.appContext
accountViewModel.toastManager.toast(
loadStringRes(Res.string.upload_error_title),
loadStringRes(
@@ -239,7 +238,6 @@ class VoiceReplyViewModel : ViewModel() {
result: UploadingState,
onSuccess: () -> Unit,
) {
val appContext = Amethyst.instance.appContext
val uploadErrorTitle = loadStringRes(Res.string.upload_error_title)
val uploadVoiceNip95NotSupported = loadStringRes(Res.string.upload_error_voice_message_nip95_not_supported)
val uploadVoiceFailed = loadStringRes(Res.string.upload_error_voice_message_failed)
@@ -18,18 +18,18 @@
* 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.polls
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.polls
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.NewPostScreenInner
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.home.ShortNotePostViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.NewPostScreenInner
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.FlowPreview
@@ -41,7 +41,7 @@ fun PollPostScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val postViewModel: ShortNotePostViewModel = viewModel()
val postViewModel: ShortNotePostViewModel = rememberViewModel { ShortNotePostViewModel() }
postViewModel.init(accountViewModel)
LaunchedEffect(postViewModel, accountViewModel) {
@@ -18,9 +18,8 @@
* 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.profile.gallery
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.profile.gallery
import androidx.annotation.OptIn
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -35,7 +34,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.media3.common.util.UnstableApi
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
@@ -61,8 +59,8 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.BannerImage
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.commons.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.commons.video.isHlsMedia
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
@@ -216,7 +214,6 @@ fun DisplayGalleryAuthorBanner(
}
}
@OptIn(UnstableApi::class)
@Composable
fun GalleryContentView(
contentList: List<MediaUrlContent>,
@@ -18,7 +18,7 @@
* 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.settings
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -39,7 +39,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -109,7 +108,6 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import com.vitorpamplona.amethyst.service.ai.WritingAssistantFactory
import kotlinx.coroutines.flow.MutableStateFlow
import org.jetbrains.compose.resources.StringResource
@@ -153,7 +151,7 @@ fun ComposeSettingsContent(
)
SettingsDivider()
// The F-Droid build ships no on-device model, so the toggle would do nothing there.
if (WritingAssistantFactory.IS_SUPPORTED) {
if (accountViewModel.host.supportsWritingAssistant) {
BooleanSwitchTile(
flow = sharedPrefs.automaticallyProposeAiImprovements,
icon = MaterialSymbols.AutoAwesome,
@@ -246,7 +244,6 @@ private fun PowDifficultyTile(accountViewModel: AccountViewModel) {
private fun PowTimeEstimate(difficulty: Int) {
if (difficulty <= 0) return
val context = LocalContext.current
val estimate by
produceState<String?>(initialValue = null, difficulty) {
val rate = deviceHashesPerSecond()
@@ -22,7 +22,20 @@ package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.runtime.Composable
private val NoFileSharer = FileSharer { _, _, _ -> }
private object NoFileSharer : FileSharer {
override fun shareTextFilesAsZip(
zipName: String,
files: List<Pair<String, String>>,
title: String,
) {}
override fun shareTextFile(
fileName: String,
mimeType: String,
content: String,
title: String,
) {}
}
/** No share sheet wired yet. */
@Composable
@@ -0,0 +1,29 @@
/*
* 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.commons.ui.screen.loggedIn.calendars
import androidx.compose.runtime.Composable
private val NoPhoneCalendar = PhoneCalendar { false }
/** No calendar composer wired yet: callers fall back to the .ics share. */
@Composable
actual fun rememberPhoneCalendar(): PhoneCalendar = NoPhoneCalendar
@@ -0,0 +1,46 @@
/*
* 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.commons.ui.screen.loggedIn.calendars
import platform.Foundation.NSDate
import platform.Foundation.NSRelativeDateTimeFormatter
import platform.Foundation.NSRelativeDateTimeFormatterUnitsStyleAbbreviated
import platform.Foundation.dateWithTimeIntervalSince1970
actual suspend fun relativeTimeSpan(
targetSeconds: Long,
nowSeconds: Long,
dayResolution: Boolean,
): String {
val formatter = NSRelativeDateTimeFormatter()
formatter.unitsStyle = NSRelativeDateTimeFormatterUnitsStyleAbbreviated
val day = 86_400L
val (target, now) =
if (dayResolution) {
Pair(targetSeconds.floorDiv(day) * day, nowSeconds.floorDiv(day) * day)
} else {
Pair(targetSeconds, nowSeconds)
}
return formatter.localizedStringForDate(
NSDate.dateWithTimeIntervalSince1970(target.toDouble()),
relativeToDate = NSDate.dateWithTimeIntervalSince1970(now.toDouble()),
)
}
@@ -22,7 +22,20 @@ package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.runtime.Composable
private val NoFileSharer = FileSharer { _, _, _ -> }
private object NoFileSharer : FileSharer {
override fun shareTextFilesAsZip(
zipName: String,
files: List<Pair<String, String>>,
title: String,
) {}
override fun shareTextFile(
fileName: String,
mimeType: String,
content: String,
title: String,
) {}
}
/** No share sheet wired yet. */
@Composable
@@ -0,0 +1,29 @@
/*
* 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.commons.ui.screen.loggedIn.calendars
import androidx.compose.runtime.Composable
private val NoPhoneCalendar = PhoneCalendar { false }
/** No calendar composer wired yet: callers fall back to the .ics share. */
@Composable
actual fun rememberPhoneCalendar(): PhoneCalendar = NoPhoneCalendar
@@ -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.commons.ui.screen.loggedIn.calendars
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.d
import com.vitorpamplona.amethyst.commons.resources.h
import com.vitorpamplona.amethyst.commons.resources.m
import com.vitorpamplona.amethyst.commons.resources.now
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.math.abs
/** The JDK has no relative-time formatter: a compact signed span ("+2h", "-5m"). */
actual suspend fun relativeTimeSpan(
targetSeconds: Long,
nowSeconds: Long,
dayResolution: Boolean,
): String {
val diff = targetSeconds - nowSeconds
val span = abs(diff)
val sign = if (diff < 0) "-" else "+"
return when {
span >= TimeUtils.ONE_DAY -> sign + (span / TimeUtils.ONE_DAY) + loadStringRes(Res.string.d)
dayResolution -> loadStringRes(Res.string.now)
span >= TimeUtils.ONE_HOUR -> sign + (span / TimeUtils.ONE_HOUR) + loadStringRes(Res.string.h)
span >= TimeUtils.ONE_MINUTE -> sign + (span / TimeUtils.ONE_MINUTE) + loadStringRes(Res.string.m)
else -> loadStringRes(Res.string.now)
}
}
@@ -18,7 +18,7 @@
* 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.note.share
package com.vitorpamplona.amethyst.commons.ui.note.share
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -18,9 +18,8 @@
* 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
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.cordnGroup
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.disambiguate
import org.junit.Assert.assertEquals
import org.junit.Test